diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index cbd0231fdb..1f937e1837 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "jetbrains.resharper.globaltools": { - "version": "2022.1.1", + "version": "2022.2.3", "commands": [ "jb" ] @@ -21,7 +21,7 @@ ] }, "ppy.localisationanalyser.tools": { - "version": "2022.607.0", + "version": "2022.809.0", "commands": [ "localisation" ] diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 8be6479043..b85862270b 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1,2 +1,8 @@ # Normalize all the line endings 32a74f95a5c80a0ed18e693f13a47522099df5c3 +# Partial everything +7bc8908ca9c026fed1d831eb6e58df7624a8d614 +# Add a few more missing partial specs +212d78865a6b5f091173a347bad5686834d1d5fe +# Add partial specs in mobile projects too +00c11b2b4e389e48f3995d63484a6bc66a7afbdb diff --git a/.github/ISSUE_TEMPLATE/bug-issue.yml b/.github/ISSUE_TEMPLATE/bug-issue.yml index 91ca622f55..ff6d869e72 100644 --- a/.github/ISSUE_TEMPLATE/bug-issue.yml +++ b/.github/ISSUE_TEMPLATE/bug-issue.yml @@ -58,7 +58,8 @@ body: The default places to find the logs on desktop platforms are as follows: - `%AppData%/osu/logs` *on Windows* - - `~/.local/share/osu/logs` *on Linux & macOS* + - `~/.local/share/osu/logs` *on Linux* + - `~/Library/Application Support/osu/logs` *on macOS* If you have selected a custom location for the game files, you can find the `logs` folder there. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 320197b88e..e60e0a39ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,11 @@ on: [push, pull_request] name: Continuous Integration +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read # to fetch code (actions/checkout) jobs: inspect-code: @@ -7,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" @@ -25,13 +31,13 @@ jobs: run: dotnet tool restore - name: Restore Packages - run: dotnet restore + run: dotnet restore osu.Desktop.slnf - name: Restore inspectcode cache uses: actions/cache@v3 with: path: ${{ github.workspace }}/inspectcode - key: inspectcode-${{ hashFiles('.config/dotnet-tools.json', '.github/workflows/ci.yml', 'osu.sln*', '.editorconfig', '.globalconfig', 'CodeAnalysis/*') }} + key: inspectcode-${{ hashFiles('.config/dotnet-tools.json', '.github/workflows/ci.yml', 'osu.sln*', 'osu*.slnf', '.editorconfig', '.globalconfig', 'CodeAnalysis/*', '**/*.csproj', '**/*.props') }} - name: Dotnet code style run: dotnet build -c Debug -warnaserror osu.Desktop.slnf -p:EnforceCodeStyleInBuild=true @@ -71,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" @@ -82,13 +88,13 @@ jobs: run: dotnet build -c Debug -warnaserror osu.Desktop.slnf - name: Test - run: dotnet test $pwd/*.Tests/bin/Debug/*/*.Tests.dll --logger "trx;LogFileName=TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx" + run: dotnet test $pwd/**/*.Tests/bin/Debug/*/*.Tests.dll --logger "trx;LogFileName=TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx" -- NUnit.ConsoleOut=0 shell: pwsh # Attempt to upload results even if test fails. # https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#always - name: Upload Test Results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 if: ${{ always() }} with: name: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}} @@ -96,29 +102,22 @@ jobs: build-only-android: name: Build only (Android) - runs-on: macos-latest + runs-on: windows-latest timeout-minutes: 60 steps: - name: Checkout - uses: actions/checkout@v2 - - # Pin Xamarin.Android version to 11.2 for now to avoid build failures caused by a Xamarin-side regression. - # See: https://github.com/xamarin/xamarin-android/issues/6284 - # This can be removed/reverted when the fix makes it to upstream and is deployed on github runners. - - name: Set default Xamarin SDK version - run: | - $VM_ASSETS/select-xamarin-sdk-v2.sh --mono=6.12 --android=11.2 + 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" - # Contrary to seemingly any other msbuild, msbuild running on macOS/Mono - # cannot accept .sln(f) files as arguments. - # Build just the main game for now. - - name: Build - run: msbuild osu.Android/osu.Android.csproj /restore /p:Configuration=Debug + - name: Install .NET workloads + run: dotnet workload install maui-android + + - name: Compile + run: dotnet build -c Debug osu.Android.slnf build-only-ios: name: Build only (iOS) @@ -126,15 +125,15 @@ 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" - # Contrary to seemingly any other msbuild, msbuild running on macOS/Mono - # cannot accept .sln(f) files as arguments. - # Build just the main game for now. + - name: Install .NET Workloads + run: dotnet workload install maui-ios + - name: Build - run: msbuild osu.iOS/osu.iOS.csproj /restore /p:Configuration=Debug + run: dotnet build -c Debug osu.iOS 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/report-nunit.yml b/.github/workflows/report-nunit.yml index 358cbda17a..99e39f6f56 100644 --- a/.github/workflows/report-nunit.yml +++ b/.github/workflows/report-nunit.yml @@ -8,8 +8,12 @@ on: workflows: ["Continuous Integration"] types: - completed +permissions: {} jobs: annotate: + permissions: + checks: write # to create checks (dorny/test-reporter) + name: Annotate CI run with test results runs-on: ubuntu-latest if: ${{ github.event.workflow_run.conclusion != 'cancelled' }} @@ -24,7 +28,7 @@ jobs: timeout-minutes: 5 steps: - name: Annotate CI run with test results - uses: dorny/test-reporter@v1.4.2 + uses: dorny/test-reporter@v1.6.0 with: artifact: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}} name: Test Results (${{matrix.os.prettyname}}, ${{matrix.threadingMode}}) diff --git a/.github/workflows/sentry-release.yml b/.github/workflows/sentry-release.yml index 442b97c473..ff4165c414 100644 --- a/.github/workflows/sentry-release.yml +++ b/.github/workflows/sentry-release.yml @@ -5,12 +5,15 @@ on: tags: - '*' +permissions: + contents: read # to fetch code (actions/checkout) + jobs: sentry_release: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 0 diff --git a/.globalconfig b/.globalconfig index 462dbc74ed..a7b652c454 100644 --- a/.globalconfig +++ b/.globalconfig @@ -53,3 +53,7 @@ dotnet_diagnostic.CA2225.severity = none # Banned APIs dotnet_diagnostic.RS0030.severity = error + +# Temporarily disable analysing CanBeNull = true in NRT contexts due to mobile issues. +# See: https://github.com/ppy/osu/pull/19677 +dotnet_diagnostic.OSUF001.severity = none \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ae2bdd2e82..9f7d88f5c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,136 +2,87 @@ Thank you for showing interest in the development of osu!. We aim to provide a good collaborating environment for everyone involved, and as such have decided to list some of the most important things to keep in mind in the process. The guidelines below have been chosen based on past experience. -These are not "official rules" *per se*, but following them will help everyone deal with things in the most efficient manner. - ## Table of contents -1. [I would like to submit an issue!](#i-would-like-to-submit-an-issue) -2. [I would like to submit a pull request!](#i-would-like-to-submit-a-pull-request) +1. [Reporting bugs](#reporting-bugs) +2. [Providing general feedback](#providing-general-feedback) +3. [Issue or discussion?](#issue-or-discussion) +4. [Submitting pull requests](#submitting-pull-requests) +5. [Resources](#resources) -## I would like to submit an issue! +## Reporting bugs -Issues, bug reports and feature suggestions are welcomed, though please keep in mind that at any point in time, hundreds of issues are open, which vary in severity and the amount of time needed to address them. As such it's not uncommon for issues to remain unresolved for a long time or even closed outright if they are deemed not important enough to fix in the foreseeable future. Issues that are required to "go live" or otherwise achieve parity with stable are prioritised the most. +A **bug** is a situation in which there is something clearly *and objectively* wrong with the game. Examples of applicable bug reports are: -* **Before submitting an issue, try searching existing issues first.** +- The game crashes to desktop when I start a beatmap +- Friends appear twice in the friend listing +- The game slows down a lot when I play this specific map +- A piece of text is overlapping another piece of text on the screen - For housekeeping purposes, we close issues that overlap with or duplicate other pre-existing issues - you can help us not to have to do that by searching existing issues yourself first. The issue search box, as well as the issue tag system, are tools you can use to check if an issue has been reported before. +To track bug reports, we primarily use GitHub **issues**. When opening an issue, please keep in mind the following: -* **When submitting a bug report, please try to include as much detail as possible.** +- Before opening the issue, please search for any similar existing issues using the text search bar and the issue labels. This includes both open and closed issues (we may have already fixed something, but the fix hasn't yet been released). +- When opening the issue, please fill out as much of the issue template as you can. In particular, please make sure to include logs and screenshots as much as possible. The instructions on how to find the log files are included in the issue template. +- We may ask you for follow-up information to reproduce or debug the problem. Please look out for this and provide follow-up info if we request it. - Bugs are not equal - some of them will be reproducible every time on pretty much all hardware, while others will be hard to track down due to being specific to particular hardware or even somewhat random in nature. As such, providing as much detail as possible when reporting a bug is hugely appreciated. A good starting set of information consists of: +If we cannot reproduce the issue, it is deemed low priority, or it is deemed to be specific to your setup in some way, the issue may be downgraded to a discussion. This will be done by a maintainer for you. - * the in-game logs, which are located at: - * `%AppData%/osu/logs` (on Windows), - * `~/.local/share/osu/logs` (on Linux and macOS), - * `Android/data/sh.ppy.osulazer/files/logs` (on Android), - * on iOS they can be obtained by connecting your device to your desktop and [copying the `logs` directory from the app's own document storage using iTunes](https://support.apple.com/en-us/HT201301#copy-to-computer), - * your system specifications (including the operating system and platform you are playing on), - * a reproduction scenario (list of steps you have performed leading up to the occurrence of the bug), - * a video or picture of the bug, if at all possible. +## Providing general feedback -* **Provide more information when asked to do so.** +If you wish to: - Sometimes when a bug is more elusive or complicated, none of the information listed above will pinpoint a concrete cause of the problem. In this case we will most likely ask you for additional info, such as a Windows Event Log dump or a copy of your local osu! database (`client.db`). Providing that information is beneficial to both parties - we can track down the problem better, and hopefully fix it for you at some point once we know where it is! +- provide *subjective* feedback on the game (about how the UI looks, about how the default skin works, about game mechanics, about how the PP and scoring systems work, etc.), +- suggest a new feature to be added to the game, +- report a non-specific problem with the game that you think may be connected to your hardware or operating system specifically, -* **When submitting a feature proposal, please describe it in the most understandable way you can.** +then it is generally best to start with a **discussion** first. Discussions are a good avenue to group subjective feedback on a single topic, or gauge interest in a particular feature request. - Communicating your idea for a feature can often be hard, and we would like to avoid any misunderstandings. As such, please try to explain your idea in a short, but understandable manner - it's best to avoid jargon or terms and references that could be considered obscure. A mock-up picture (doesn't have to be good!) of the feature can also go a long way in explaining. +When opening a discussion, please keep in mind the following: -* **Refrain from posting "+1" comments.** +- Use the search function to see if your idea has been proposed before, or if there is already a thread about a particular issue you wish to raise. +- If proposing a feature, please try to explain the feature in as much detail as possible. +- If you're reporting a non-specific problem, please provide applicable logs, screenshots, or video that illustrate the issue. - If an issue has already been created, saying that you also experience it without providing any additional details doesn't really help us in any way. To express support for a proposal or indicate that you are also affected by a particular bug, you can use comment reactions instead. +If a discussion gathers enough traction, then it may be converted into an issue. This will be done by a maintainer for you. -* **Refrain from asking if an issue has been resolved yet.** +## Issue or discussion? - As mentioned above, the issue tracker has hundreds of issues open at any given time. Currently the game is being worked on by two members of the core team, and a handful of outside contributors who offer their free time to help out. As such, it can happen that an issue gets placed on the backburner due to being less important; generally posting a comment demanding its resolution some months or years after it is reported is not very likely to increase its priority. +We realise that the line between an issue and a discussion may be fuzzy, so while we ask you to use your best judgement based on the description above, please don't think about it too hard either. Feedback in a slightly wrong place is better than no feedback at all. -* **Avoid long discussions about non-development topics.** +When in doubt, it's probably best to start with a discussion first. We will escalate to issues as needed. - GitHub is mostly a developer space, and as such isn't really fit for lengthened discussions about gameplay mechanics (which might not even be in any way confirmed for the final release) and similar non-technical matters. Such matters are probably best addressed at the osu! forums. +## Submitting pull requests -## I would like to submit a pull request! +While pull requests from unaffiliated contributors are welcome, please note that due to significant community interest and limited review throughput, the core team's primary focus is on the issues which are currently [on the roadmap](https://github.com/orgs/ppy/projects/7/views/6). Reviewing PRs that fall outside of the scope of the roadmap is done on a best-effort basis, so please be aware that it may take a while before a core maintainer gets around to review your change. -We also welcome pull requests from unaffiliated contributors. The [issue tracker](https://github.com/ppy/osu/issues) should provide plenty of issues that you can work on; we also mark issues that we think would be good for newcomers with the [`good-first-issue`](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+label%3Agood-first-issue) label. +The [issue tracker](https://github.com/ppy/osu/issues) should provide plenty of issues to start with. We also have a [`good-first-issue`](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+label%3Agood-first-issue) label, although from experience it is not used very often, as it is relatively rare that we can spot an issue that will definitively be a good first issue for a new contributor regardless of their programming experience. -However, do keep in mind that the core team is committed to bringing osu!(lazer) up to par with osu!(stable) first and foremost, so depending on what your contribution concerns, it might not be merged and released right away. Our approach to managing issues and their priorities is described [in the wiki](https://github.com/ppy/osu/wiki/Project-management). +In the case of simple issues, a direct PR is okay. However, if you decide to work on an existing issue which doesn't seem trivial, **please ask us first**. This way we can try to estimate if it is a good fit for you and provide the correct direction on how to address it. In addition, note that while we do not rule out external contributors from working on roadmapped issues, we will generally prefer to handle them ourselves unless they're not very time sensitive. -Here are some key things to note before jumping in: +If you'd like to propose a subjective change to one of the visual aspects of the game, or there is a bigger task you'd like to work on, but there is no corresponding issue or discussion thread yet for it, **please open a discussion or issue first** to avoid wasted effort. This in particular applies if you want to work on [one of the available designs from the osu! public Figma library](https://www.figma.com/file/6m10GiGEncVFWmgOoSyakH/osu!-Figma-Library). -* **Make sure you are comfortable with C\# and your development environment.** +Aside from the above, below is a brief checklist of things to watch out when you're preparing your code changes: - While we are accepting of all kinds of contributions, we also have a certain quality standard we'd like to uphold and limited time to review your code. Therefore, we would like to avoid providing entry-level advice, and as such if you're not very familiar with C\# as a programming language, we'd recommend that you start off with a few personal projects to get acquainted with the language's syntax, toolchain and principles of object-oriented programming first. +- Make sure you're comfortable with the principles of object-oriented programming, the syntax of C\# and your development environment. +- Make sure you are familiar with [git](https://git-scm.com/) and [the pull request workflow](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/proposing-changes-to-your-work-with-pull-requests). +- Please do not make code changes via the GitHub web interface. +- Please add tests for your changes. We expect most new features and bugfixes to have test coverage, unless the effort of adding them is prohibitive. The visual testing methodology we use is described in more detail [here](https://github.com/ppy/osu-framework/wiki/Development-and-Testing). +- Please run tests and code style analysis (via `InspectCode.{ps1,sh}` scripts in the root of this repository) before opening the PR. This is particularly important if you're a first-time contributor, as CI will not run for your PR until we allow it to do so. - In addition, please take the time to take a look at and get acquainted with the [development and testing](https://github.com/ppy/osu-framework/wiki/Development-and-Testing) procedure we have set up. +After you're done with your changes and you wish to open the PR, please observe the following recommendations: -* **Make sure you are familiar with git and the pull request workflow.** +- Please submit the pull request from a [topic branch](https://git-scm.com/book/en/v2/Git-Branching-Branching-Workflows#_topic_branch) (not `master`), and keep the *Allow edits from maintainers* check box selected, so that we can push fixes to your PR if necessary. +- Please avoid pushing untested or incomplete code. +- Please do not force-push or rebase unless we ask you to. +- Please do not merge `master` continually if there are no conflicts to resolve. We will do this for you when the change is ready for merge. - [git](https://git-scm.com/) is a distributed version control system that might not be very intuitive at the beginning if you're not familiar with version control. In particular, projects using git have a particular workflow for submitting code changes, which is called the pull request workflow. +We are highly committed to quality when it comes to the osu! project. This means that contributions from less experienced community members can take multiple rounds of review to get to a mergeable state. We try our utmost best to never conflate a person with the code they authored, and to keep the discussion focused on the code at all times. Please consider our comments and requests a learning experience. - To make things run more smoothly, we recommend that you look up some online resources to familiarise yourself with the git vocabulary and commands, and practice working with forks and submitting pull requests at your own pace. A high-level overview of the process can be found in [this article by GitHub](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/proposing-changes-to-your-work-with-pull-requests). +If you're uncertain about some part of the codebase or some inner workings of the game and framework, please reach out either by leaving a comment in the relevant issue, discussion, or PR thread, or by posting a message in the [development Discord server](https://discord.gg/ppy). We will try to help you as much as we can. -* **Double-check designs before starting work on new functionality.** +## Resources - When implementing new features, keep in mind that we already have a lot of the UI designed. If you wish to work on something with the intention of having it included in the official distribution, please open an issue for discussion and we will give you what you need from a design perspective to proceed. If you want to make *changes* to the design, we recommend you open an issue with your intentions before spending too much time to ensure no effort is wasted. - -* **Make sure to submit pull requests off of a topic branch.** - - As described in the article linked in the previous point, topic branches help you parallelise your work and separate it from the main `master` branch, and additionally are easier for maintainers to work with. Working with multiple `master` branches across many remotes is difficult to keep track of, and it's easy to make a mistake and push to the wrong `master` branch by accident. - -* **Refrain from making changes through the GitHub web interface.** - - Even though GitHub provides an option to edit code or replace files in the repository using the web interface, we strongly discourage using it in most scenarios. Editing files this way is inefficient and likely to introduce whitespace or file encoding changes that make it more difficult to review the code. - - Code written through the web interface will also very likely be questioned outright by the reviewers, as it is likely that it has not been properly tested or that it will fail continuous integration checks. We strongly encourage using an IDE like [Visual Studio](https://visualstudio.microsoft.com/), [Visual Studio Code](https://code.visualstudio.com/) or [JetBrains Rider](https://www.jetbrains.com/rider/) instead. - -* **Add tests for your code whenever possible.** - - Automated tests are an essential part of a quality and reliable codebase. They help to make the code more maintainable by ensuring it is safe to reorganise (or refactor) the code in various ways, and also prevent regressions - bugs that resurface after having been fixed at some point in the past. If it is viable, please put in the time to add tests, so that the changes you make can last for a (hopefully) very long time. - -* **Run tests before opening a pull request.** - - Tying into the previous point, sometimes changes in one part of the codebase can result in unpredictable changes in behaviour in other pieces of the code. This is why it is best to always try to run tests before opening a PR. - - Continuous integration will always run the tests for you (and us), too, but it is best not to rely on it, as there might be many builds queued at any time. Running tests on your own will help you be more certain that at the point of clicking the "Create pull request" button, your changes are as ready as can be. - -* **Run code style analysis before opening a pull request.** - - As part of continuous integration, we also run code style analysis, which is supposed to make sure that your code is formatted the same way as all the pre-existing code in the repository. The reason we enforce a particular code style everywhere is to make sure the codebase is consistent in that regard - having one whitespace convention in one place and another one elsewhere causes disorganisation. - -* **Make sure that the pull request is complete before opening it.** - - Whether it's fixing a bug or implementing new functionality, it's best that you make sure that the change you want to submit as a pull request is as complete as it can be before clicking the *Create pull request* button. Having to track if a pull request is ready for review or not places additional burden on reviewers. - - Draft pull requests are an option, but use them sparingly and within reason. They are best suited to discuss code changes that cannot be easily described in natural language or have a potential large impact on the future direction of the project. When in doubt, don't open drafts unless a maintainer asks you to do so. - -* **Only push code when it's ready.** - - As an extension of the above, when making changes to an already-open PR, please try to only push changes you are reasonably certain of. Pushing after every commit causes the continuous integration build queue to grow in size, slowing down work and taking up time that could be spent verifying other changes. - -* **Make sure to keep the *Allow edits from maintainers* check box checked.** - - To speed up the merging process, collaborators and team members will sometimes want to push changes to your branch themselves, to make minor code style adjustments or to otherwise refactor the code without having to describe how they'd like the code to look like in painstaking detail. Having the *Allow edits from maintainers* check box checked lets them do that; without it they are forced to report issues back to you and wait for you to address them. - -* **Refrain from continually merging the master branch back to the PR.** - - Unless there are merge conflicts that need resolution, there is no need to keep merging `master` back to a branch over and over again. One of the maintainers will merge `master` themselves before merging the PR itself anyway, and continual merge commits can cause CI to get overwhelmed due to queueing up too many builds. - -* **Refrain from force-pushing to the PR branch.** - - Force-pushing should be avoided, as it can lead to accidentally overwriting a maintainer's changes or CI building wrong commits. We value all history in the project, so there is no need to squash or amend commits in most cases. - - The cases in which force-pushing is warranted are very rare (such as accidentally leaking sensitive info in one of the files committed, adding unrelated files, or mis-merging a dependent PR). - -* **Be patient when waiting for the code to be reviewed and merged.** - - As much as we'd like to review all contributions as fast as possible, our time is limited, as team members have to work on their own tasks in addition to reviewing code. As such, work needs to be prioritised, and it can unfortunately take weeks or months for your PR to be merged, depending on how important it is deemed to be. - -* **Don't mistake criticism of code for criticism of your person.** - - As mentioned before, we are highly committed to quality when it comes to the osu! project. This means that contributions from less experienced community members can take multiple rounds of review to get to a mergeable state. We try our utmost best to never conflate a person with the code they authored, and to keep the discussion focused on the code at all times. Please consider our comments and requests a learning experience, and don't treat it as a personal attack. - -* **Feel free to reach out for help.** - - If you're uncertain about some part of the codebase or some inner workings of the game and framework, please reach out either by leaving a comment in the relevant issue or PR thread, or by posting a message in the [development Discord server](https://discord.gg/ppy). We will try to help you as much as we can. - - When it comes to which form of communication is best, GitHub generally lends better to longer-form discussions, while Discord is better for snappy call-and-response answers. Use your best discretion when deciding, and try to keep a single discussion in one place instead of moving back and forth. +- [Development roadmap](https://github.com/orgs/ppy/projects/7/views/6): What the core team is currently working on +- [`ppy/osu-framework` wiki](https://github.com/ppy/osu-framework/wiki): Contains introductory information about osu!framework, the bespoke 2D game framework we use for the game +- [`ppy/osu` wiki](https://github.com/ppy/osu/wiki): Contains articles about various technical aspects of the game +- [Public Figma library](https://www.figma.com/file/6m10GiGEncVFWmgOoSyakH/osu!-Figma-Library): Contains finished and draft designs for osu! diff --git a/CodeAnalysis/BannedSymbols.txt b/CodeAnalysis/BannedSymbols.txt index b72df0a306..03fd21829d 100644 --- a/CodeAnalysis/BannedSymbols.txt +++ b/CodeAnalysis/BannedSymbols.txt @@ -6,8 +6,6 @@ T:System.IComparable;Don't use non-generic IComparable. Use generic version inst T:SixLabors.ImageSharp.IDeepCloneable`1;Use osu.Game.Utils.IDeepCloneable instead. M:osu.Framework.Graphics.Sprites.SpriteText.#ctor;Use OsuSpriteText. M:osu.Framework.Bindables.IBindableList`1.GetBoundCopy();Fails on iOS. Use manual ctor + BindTo instead. (see https://github.com/mono/mono/issues/19900) -T:Microsoft.EntityFrameworkCore.Internal.EnumerableExtensions;Don't use internal extension methods. -T:Microsoft.EntityFrameworkCore.Internal.TypeExtensions;Don't use internal extension methods. T:NuGet.Packaging.CollectionExtensions;Don't use internal extension methods. M:System.Enum.HasFlag(System.Enum);Use osu.Framework.Extensions.EnumExtensions.HasFlagFast() instead. M:Realms.IRealmCollection`1.SubscribeForNotifications`1(Realms.NotificationCallbackDelegate{``0});Use osu.Game.Database.RealmObjectExtensions.QueryAsyncWithNotifications(IRealmCollection,NotificationCallbackDelegate) instead. @@ -16,3 +14,12 @@ M:Realms.CollectionExtensions.SubscribeForNotifications`1(System.Linq.IQueryable M:Realms.CollectionExtensions.SubscribeForNotifications`1(System.Collections.Generic.IList{``0},Realms.NotificationCallbackDelegate{``0});Use osu.Game.Database.RealmObjectExtensions.QueryAsyncWithNotifications(IList,NotificationCallbackDelegate) instead. M:System.Threading.Tasks.Task.Wait();Don't use Task.Wait. Use Task.WaitSafely() to ensure we avoid deadlocks. P:System.Threading.Tasks.Task`1.Result;Don't use Task.Result. Use Task.GetResultSafely() to ensure we avoid deadlocks. +M:System.Threading.ManualResetEventSlim.Wait();Specify a timeout to avoid waiting forever. +M:System.Char.ToLower(System.Char);char.ToLower() changes behaviour depending on CultureInfo.CurrentCulture. Use char.ToLowerInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture. +M:System.Char.ToUpper(System.Char);char.ToUpper() changes behaviour depending on CultureInfo.CurrentCulture. Use char.ToUpperInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture. +M:System.String.ToLower();string.ToLower() changes behaviour depending on CultureInfo.CurrentCulture. Use string.ToLowerInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture or use LocalisableString. +M:System.String.ToUpper();string.ToUpper() changes behaviour depending on CultureInfo.CurrentCulture. Use string.ToUpperInvariant() instead. If wanting culture-sensitive behaviour, explicitly provide CultureInfo.CurrentCulture or use LocalisableString. +M:Humanizer.InflectorExtensions.Pascalize(System.String);Humanizer's .Pascalize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToPascalCase() instead. +M:Humanizer.InflectorExtensions.Camelize(System.String);Humanizer's .Camelize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToCamelCase() instead. +M:Humanizer.InflectorExtensions.Underscore(System.String);Humanizer's .Underscore() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToSnakeCase() instead. +M:Humanizer.InflectorExtensions.Kebaberize(System.String);Humanizer's .Kebaberize() extension method changes behaviour depending on CultureInfo.CurrentCulture. Use StringDehumanizeExtensions.ToKebabCase() instead. diff --git a/Directory.Build.props b/Directory.Build.props index 73a150d3e3..734374c840 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,8 +1,9 @@  - 8.0 + 10.0 true + enable $(MSBuildThisFileDirectory)app.manifest @@ -16,7 +17,7 @@ - + diff --git a/Gemfile b/Gemfile deleted file mode 100644 index cdd3a6b349..0000000000 --- a/Gemfile +++ /dev/null @@ -1,6 +0,0 @@ -source "https://rubygems.org" - -gem "fastlane" - -plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') -eval_gemfile(plugins_path) if File.exist?(plugins_path) diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index ddab497657..0000000000 --- a/Gemfile.lock +++ /dev/null @@ -1,234 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - CFPropertyList (3.0.5) - rexml - addressable (2.8.0) - public_suffix (>= 2.0.2, < 5.0) - artifactory (3.0.15) - atomos (0.1.3) - aws-eventstream (1.2.0) - aws-partitions (1.570.0) - aws-sdk-core (3.130.0) - aws-eventstream (~> 1, >= 1.0.2) - aws-partitions (~> 1, >= 1.525.0) - aws-sigv4 (~> 1.1) - jmespath (~> 1.0) - aws-sdk-kms (1.55.0) - aws-sdk-core (~> 3, >= 3.127.0) - aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.113.0) - aws-sdk-core (~> 3, >= 3.127.0) - aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.4) - aws-sigv4 (1.4.0) - aws-eventstream (~> 1, >= 1.0.2) - babosa (1.0.4) - claide (1.1.0) - colored (1.2) - colored2 (3.1.2) - commander (4.6.0) - highline (~> 2.0.0) - declarative (0.0.20) - digest-crc (0.6.4) - rake (>= 12.0.0, < 14.0.0) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) - dotenv (2.7.6) - emoji_regex (3.2.3) - excon (0.92.1) - faraday (1.10.0) - faraday-em_http (~> 1.0) - faraday-em_synchrony (~> 1.0) - faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0) - faraday-multipart (~> 1.0) - faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.0) - faraday-patron (~> 1.0) - faraday-rack (~> 1.0) - faraday-retry (~> 1.0) - ruby2_keywords (>= 0.0.4) - faraday-cookie_jar (0.0.7) - faraday (>= 0.8.0) - http-cookie (~> 1.0.0) - faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.0) - faraday-excon (1.1.0) - faraday-httpclient (1.0.1) - faraday-multipart (1.0.3) - multipart-post (>= 1.2, < 3) - faraday-net_http (1.0.1) - faraday-net_http_persistent (1.2.0) - faraday-patron (1.0.0) - faraday-rack (1.0.0) - faraday-retry (1.0.3) - faraday_middleware (1.2.0) - faraday (~> 1.0) - fastimage (2.2.6) - fastlane (2.205.1) - CFPropertyList (>= 2.3, < 4.0.0) - addressable (>= 2.8, < 3.0.0) - artifactory (~> 3.0) - aws-sdk-s3 (~> 1.0) - babosa (>= 1.0.3, < 2.0.0) - bundler (>= 1.12.0, < 3.0.0) - colored - commander (~> 4.6) - dotenv (>= 2.1.1, < 3.0.0) - emoji_regex (>= 0.1, < 4.0) - excon (>= 0.71.0, < 1.0.0) - faraday (~> 1.0) - faraday-cookie_jar (~> 0.0.6) - faraday_middleware (~> 1.0) - fastimage (>= 2.1.0, < 3.0.0) - gh_inspector (>= 1.1.2, < 2.0.0) - google-apis-androidpublisher_v3 (~> 0.3) - google-apis-playcustomapp_v1 (~> 0.1) - google-cloud-storage (~> 1.31) - highline (~> 2.0) - json (< 3.0.0) - jwt (>= 2.1.0, < 3) - mini_magick (>= 4.9.4, < 5.0.0) - multipart-post (~> 2.0.0) - naturally (~> 2.2) - optparse (~> 0.1.1) - plist (>= 3.1.0, < 4.0.0) - rubyzip (>= 2.0.0, < 3.0.0) - security (= 0.1.3) - simctl (~> 1.6.3) - terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (>= 1.4.5, < 2.0.0) - tty-screen (>= 0.6.3, < 1.0.0) - tty-spinner (>= 0.8.0, < 1.0.0) - word_wrap (~> 1.0.0) - xcodeproj (>= 1.13.0, < 2.0.0) - xcpretty (~> 0.3.0) - xcpretty-travis-formatter (>= 0.0.3) - fastlane-plugin-clean_testflight_testers (0.3.0) - fastlane-plugin-souyuz (0.11.1) - souyuz (= 0.11.1) - fastlane-plugin-xamarin (0.6.3) - gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.16.0) - google-apis-core (>= 0.4, < 2.a) - google-apis-core (0.4.2) - addressable (~> 2.5, >= 2.5.1) - googleauth (>= 0.16.2, < 2.a) - httpclient (>= 2.8.1, < 3.a) - mini_mime (~> 1.0) - representable (~> 3.0) - retriable (>= 2.0, < 4.a) - rexml - webrick - google-apis-iamcredentials_v1 (0.10.0) - google-apis-core (>= 0.4, < 2.a) - google-apis-playcustomapp_v1 (0.7.0) - google-apis-core (>= 0.4, < 2.a) - google-apis-storage_v1 (0.11.0) - google-apis-core (>= 0.4, < 2.a) - google-cloud-core (1.6.0) - google-cloud-env (~> 1.0) - google-cloud-errors (~> 1.0) - google-cloud-env (1.6.0) - faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.2.0) - google-cloud-storage (1.36.1) - addressable (~> 2.8) - digest-crc (~> 0.4) - google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.1) - google-cloud-core (~> 1.6) - googleauth (>= 0.16.2, < 2.a) - mini_mime (~> 1.0) - googleauth (1.1.2) - faraday (>= 0.17.3, < 3.a) - jwt (>= 1.4, < 3.0) - memoist (~> 0.16) - multi_json (~> 1.11) - os (>= 0.9, < 2.0) - signet (>= 0.16, < 2.a) - highline (2.0.3) - http-cookie (1.0.4) - domain_name (~> 0.5) - httpclient (2.8.3) - jmespath (1.6.1) - json (2.6.1) - jwt (2.3.0) - memoist (0.16.2) - mini_magick (4.11.0) - mini_mime (1.1.2) - mini_portile2 (2.7.1) - multi_json (1.15.0) - multipart-post (2.0.0) - nanaimo (0.3.0) - naturally (2.2.1) - nokogiri (1.13.1) - mini_portile2 (~> 2.7.0) - racc (~> 1.4) - optparse (0.1.1) - os (1.1.4) - plist (3.6.0) - public_suffix (4.0.6) - racc (1.6.0) - rake (13.0.6) - representable (3.1.1) - declarative (< 0.1.0) - trailblazer-option (>= 0.1.1, < 0.2.0) - uber (< 0.2.0) - retriable (3.1.2) - rexml (3.2.5) - rouge (2.0.7) - ruby2_keywords (0.0.5) - rubyzip (2.3.2) - security (0.1.3) - signet (0.16.1) - addressable (~> 2.8) - faraday (>= 0.17.5, < 3.0) - jwt (>= 1.5, < 3.0) - multi_json (~> 1.10) - simctl (1.6.8) - CFPropertyList - naturally - souyuz (0.11.1) - fastlane (>= 2.182.0) - highline (~> 2.0) - nokogiri (~> 1.7) - terminal-notifier (2.0.0) - terminal-table (1.8.0) - unicode-display_width (~> 1.1, >= 1.1.1) - trailblazer-option (0.1.2) - tty-cursor (0.7.1) - tty-screen (0.8.1) - tty-spinner (0.9.3) - tty-cursor (~> 0.7) - uber (0.1.0) - unf (0.1.4) - unf_ext - unf_ext (0.0.8.1) - unicode-display_width (1.8.0) - webrick (1.7.0) - word_wrap (1.0.0) - xcodeproj (1.21.0) - CFPropertyList (>= 2.3.3, < 4.0) - atomos (~> 0.1.3) - claide (>= 1.0.2, < 2.0) - colored2 (~> 3.1) - nanaimo (~> 0.3.0) - rexml (~> 3.2.4) - xcpretty (0.3.0) - rouge (~> 2.0.7) - xcpretty-travis-formatter (1.0.1) - xcpretty (~> 0.2, >= 0.0.7) - -PLATFORMS - ruby - -DEPENDENCIES - fastlane - fastlane-plugin-clean_testflight_testers - fastlane-plugin-souyuz - fastlane-plugin-xamarin - -BUNDLED WITH - 2.0.1 diff --git a/README.md b/README.md index 75d61dad4d..eb2fe6d0eb 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ If you are looking to install or test osu! without setting up a development envi **Latest build:** -| [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 10+](https://osu.ppy.sh/home/testflight) | [Android 5+](https://github.com/ppy/osu/releases/latest/download/sh.ppy.osulazer.apk) | +| [Windows 8.1+ (x64)](https://github.com/ppy/osu/releases/latest/download/install.exe) | macOS 10.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) | | ------------- | ------------- | ------------- | ------------- | ------------- | - The iOS testflight link may fill up (Apple has a hard limit of 10,000 users). We reset it occasionally when this happens. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements of link resets. @@ -101,13 +101,11 @@ JetBrains ReSharper InspectCode is also used for wider rule sets. You can run it ## Contributing -When it comes to contributing to the project, the two main things you can do to help out are reporting issues and submitting pull requests. Based on past experiences, we have prepared a [list of contributing guidelines](CONTRIBUTING.md) that should hopefully ease you into our collaboration process and answer the most frequently-asked questions. - -Note that while we already have certain standards in place, nothing is set in stone. If you have an issue with the way code is structured, with any libraries we are using, or with any processes involved with contributing, *please* bring it up. We welcome all feedback so we can make contributing to this project as painless as possible. +When it comes to contributing to the project, the two main things you can do to help out are reporting issues and submitting pull requests. Please refer to the [contributing guidelines](CONTRIBUTING.md) to understand how to help in the most effective way possible. 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-empty/osu.Game.Rulesets.EmptyFreeform.Tests/TestSceneOsuGame.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/TestSceneOsuGame.cs index 5973db908c..ca73074789 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/TestSceneOsuGame.cs +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/TestSceneOsuGame.cs @@ -9,7 +9,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.EmptyFreeform.Tests { - public class TestSceneOsuGame : OsuTestScene + public partial class TestSceneOsuGame : OsuTestScene { [BackgroundDependencyLoader] private void load() diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/TestSceneOsuPlayer.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/TestSceneOsuPlayer.cs index 0f2ddf82a5..4b99fa7e09 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/TestSceneOsuPlayer.cs +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/TestSceneOsuPlayer.cs @@ -7,7 +7,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.EmptyFreeform.Tests { [TestFixture] - public class TestSceneOsuPlayer : PlayerTestScene + public partial class TestSceneOsuPlayer : PlayerTestScene { protected override Ruleset CreatePlayerRuleset() => new EmptyFreeformRuleset(); } diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj index bc285dbe11..a1c53ece03 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj @@ -9,11 +9,9 @@ false - - + - - + diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformInputManager.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformInputManager.cs index b292a28c0d..feac0f22f5 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformInputManager.cs +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformInputManager.cs @@ -7,7 +7,7 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.EmptyFreeform { - public class EmptyFreeformInputManager : RulesetInputManager + public partial class EmptyFreeformInputManager : RulesetInputManager { public EmptyFreeformInputManager(RulesetInfo ruleset) : base(ruleset, 0, SimultaneousBindingMode.Unique) diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformRuleset.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformRuleset.cs index baf503085d..d7ef84541f 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformRuleset.cs +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformRuleset.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -20,7 +21,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.EmptyFreeform { - public class EmptyFreeformRuleset : Ruleset + public partial class EmptyFreeformRuleset : Ruleset { public override string Description => "a very emptyfreeformruleset ruleset"; @@ -41,7 +42,7 @@ namespace osu.Game.Rulesets.EmptyFreeform return new[] { new EmptyFreeformModAutoplay() }; default: - return new Mod[] { null }; + return Array.Empty(); } } @@ -55,7 +56,7 @@ namespace osu.Game.Rulesets.EmptyFreeform public override Drawable CreateIcon() => new Icon(ShortName[0]); - public class Icon : CompositeDrawable + public partial class Icon : CompositeDrawable { public Icon(char c) { @@ -76,5 +77,8 @@ namespace osu.Game.Rulesets.EmptyFreeform }; } } + + // Leave this line intact. It will bake the correct version into the ruleset on each build/release. + public override string RulesetAPIVersionSupported => CURRENT_RULESET_API_VERSION; } } diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/Drawables/DrawableEmptyFreeformHitObject.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/Drawables/DrawableEmptyFreeformHitObject.cs index 0f38e9fdf8..744e207b57 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/Drawables/DrawableEmptyFreeformHitObject.cs +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/Drawables/DrawableEmptyFreeformHitObject.cs @@ -9,7 +9,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.EmptyFreeform.Objects.Drawables { - public class DrawableEmptyFreeformHitObject : DrawableHitObject + public partial class DrawableEmptyFreeformHitObject : DrawableHitObject { public DrawableEmptyFreeformHitObject(EmptyFreeformHitObject hitObject) : base(hitObject) diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/UI/DrawableEmptyFreeformRuleset.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/UI/DrawableEmptyFreeformRuleset.cs index 290f35f516..608e8d3c07 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/UI/DrawableEmptyFreeformRuleset.cs +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/UI/DrawableEmptyFreeformRuleset.cs @@ -17,7 +17,7 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.EmptyFreeform.UI { [Cached] - public class DrawableEmptyFreeformRuleset : DrawableRuleset + public partial class DrawableEmptyFreeformRuleset : DrawableRuleset { public DrawableEmptyFreeformRuleset(EmptyFreeformRuleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) : base(ruleset, beatmap, mods) diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/UI/EmptyFreeformPlayfield.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/UI/EmptyFreeformPlayfield.cs index 9df5935c45..532e88d55e 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/UI/EmptyFreeformPlayfield.cs +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/UI/EmptyFreeformPlayfield.cs @@ -8,7 +8,7 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.EmptyFreeform.UI { [Cached] - public class EmptyFreeformPlayfield : Playfield + public partial class EmptyFreeformPlayfield : Playfield { [BackgroundDependencyLoader] private void load() diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/osu.Game.Rulesets.EmptyFreeform.csproj b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/osu.Game.Rulesets.EmptyFreeform.csproj index 092a013614..d09e7647e0 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/osu.Game.Rulesets.EmptyFreeform.csproj +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/osu.Game.Rulesets.EmptyFreeform.csproj @@ -1,6 +1,6 @@  - netstandard2.1 + net6.0 osu.Game.Rulesets.EmptyFreeform Library AnyCPU @@ -12,4 +12,4 @@ - \ No newline at end of file + diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs index b75a5ec187..c105e9c040 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs @@ -9,7 +9,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Pippidon.Tests { - public class TestSceneOsuGame : OsuTestScene + public partial class TestSceneOsuGame : OsuTestScene { [BackgroundDependencyLoader] private void load() diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuPlayer.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuPlayer.cs index f00528900c..793d3933d7 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuPlayer.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuPlayer.cs @@ -7,7 +7,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Pippidon.Tests { [TestFixture] - public class TestSceneOsuPlayer : PlayerTestScene + public partial class TestSceneOsuPlayer : PlayerTestScene { protected override Ruleset CreatePlayerRuleset() => new PippidonRuleset(); } diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj index 718ada1905..683e9fd5e8 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj @@ -9,11 +9,9 @@ false - - + - - + 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 399d6adda2..29203f0a20 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 @@ -16,7 +16,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Pippidon.Objects.Drawables { - public class DrawablePippidonHitObject : DrawableHitObject + public partial class DrawablePippidonHitObject : DrawableHitObject { private const double time_preempt = 600; private const double time_fadein = 400; diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonInputManager.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonInputManager.cs index aa7fa3188b..85b264bc67 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonInputManager.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonInputManager.cs @@ -7,7 +7,7 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Pippidon { - public class PippidonInputManager : RulesetInputManager + public partial class PippidonInputManager : RulesetInputManager { public PippidonInputManager(RulesetInfo ruleset) : base(ruleset, 0, SimultaneousBindingMode.Unique) diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonRuleset.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonRuleset.cs index 15e988f466..2f6ba0dda6 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonRuleset.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonRuleset.cs @@ -1,10 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; using osu.Framework.Input.Bindings; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; @@ -37,7 +36,7 @@ namespace osu.Game.Rulesets.Pippidon return new[] { new PippidonModAutoplay() }; default: - return new Mod[] { null }; + return Array.Empty(); } } @@ -49,9 +48,9 @@ namespace osu.Game.Rulesets.Pippidon new KeyBinding(InputKey.X, PippidonAction.Button2), }; - public override Drawable CreateIcon() => new Sprite - { - Texture = new TextureStore(new TextureLoaderStore(CreateResourceStore()), false).Get("Textures/coin"), - }; + public override Drawable CreateIcon() => new PippidonRulesetIcon(this); + + // Leave this line intact. It will bake the correct version into the ruleset on each build/release. + public override string RulesetAPIVersionSupported => CURRENT_RULESET_API_VERSION; } } diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonRulesetIcon.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonRulesetIcon.cs new file mode 100644 index 0000000000..1a672d10dd --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonRulesetIcon.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; + +namespace osu.Game.Rulesets.Pippidon +{ + public partial class PippidonRulesetIcon : Sprite + { + private readonly Ruleset ruleset; + + public PippidonRulesetIcon(Ruleset ruleset) + { + this.ruleset = ruleset; + } + + [BackgroundDependencyLoader] + private void load(IRenderer renderer) + { + Texture = new TextureStore(renderer, new TextureLoaderStore(ruleset.CreateResourceStore()), false).Get("Textures/coin"); + } + } +} diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/DrawablePippidonRuleset.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/DrawablePippidonRuleset.cs index d923963bef..982a80cb7a 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/DrawablePippidonRuleset.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/DrawablePippidonRuleset.cs @@ -17,7 +17,7 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Pippidon.UI { [Cached] - public class DrawablePippidonRuleset : DrawableRuleset + public partial class DrawablePippidonRuleset : DrawableRuleset { public DrawablePippidonRuleset(PippidonRuleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) : base(ruleset, beatmap, mods) diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/PippidonCursorContainer.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/PippidonCursorContainer.cs index 9de3f4ba14..20dd2e7c31 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/PippidonCursorContainer.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/PippidonCursorContainer.cs @@ -10,7 +10,7 @@ using osuTK; namespace osu.Game.Rulesets.Pippidon.UI { - public class PippidonCursorContainer : GameplayCursorContainer + public partial class PippidonCursorContainer : GameplayCursorContainer { private Sprite cursorSprite; private Texture cursorTexture; diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfield.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfield.cs index b5a97c5ea3..62e162ae4b 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfield.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfield.cs @@ -8,7 +8,7 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Pippidon.UI { [Cached] - public class PippidonPlayfield : Playfield + public partial class PippidonPlayfield : Playfield { protected override GameplayCursorContainer CreateCursor() => new PippidonCursorContainer(); diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfieldAdjustmentContainer.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfieldAdjustmentContainer.cs index 9236683827..b46e795bbc 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfieldAdjustmentContainer.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfieldAdjustmentContainer.cs @@ -7,7 +7,7 @@ using osuTK; namespace osu.Game.Rulesets.Pippidon.UI { - public class PippidonPlayfieldAdjustmentContainer : PlayfieldAdjustmentContainer + public partial class PippidonPlayfieldAdjustmentContainer : PlayfieldAdjustmentContainer { public PippidonPlayfieldAdjustmentContainer() { diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj index a3607343c9..9c8867f4ef 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj @@ -1,6 +1,6 @@  - netstandard2.1 + net6.0 osu.Game.Rulesets.Pippidon Library AnyCPU @@ -12,4 +12,4 @@ - \ No newline at end of file + diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/TestSceneOsuGame.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/TestSceneOsuGame.cs index ffe921b54c..a9a3c2eb5b 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/TestSceneOsuGame.cs +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/TestSceneOsuGame.cs @@ -9,7 +9,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.EmptyScrolling.Tests { - public class TestSceneOsuGame : OsuTestScene + public partial class TestSceneOsuGame : OsuTestScene { [BackgroundDependencyLoader] private void load() diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/TestSceneOsuPlayer.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/TestSceneOsuPlayer.cs index 9460576196..2be3862833 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/TestSceneOsuPlayer.cs +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/TestSceneOsuPlayer.cs @@ -7,7 +7,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.EmptyScrolling.Tests { [TestFixture] - public class TestSceneOsuPlayer : PlayerTestScene + public partial class TestSceneOsuPlayer : PlayerTestScene { protected override Ruleset CreatePlayerRuleset() => new EmptyScrollingRuleset(); } diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj index 6b9c3f4d63..b7a7fff18a 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj @@ -9,11 +9,9 @@ false - - + - - + diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingInputManager.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingInputManager.cs index 632e04f301..a7b4ae61f0 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingInputManager.cs +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingInputManager.cs @@ -7,7 +7,7 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.EmptyScrolling { - public class EmptyScrollingInputManager : RulesetInputManager + public partial class EmptyScrollingInputManager : RulesetInputManager { public EmptyScrollingInputManager(RulesetInfo ruleset) : base(ruleset, 0, SimultaneousBindingMode.Unique) diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingRuleset.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingRuleset.cs index b9bed74c88..a32586c414 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingRuleset.cs +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingRuleset.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; @@ -34,7 +35,7 @@ namespace osu.Game.Rulesets.EmptyScrolling return new[] { new EmptyScrollingModAutoplay() }; default: - return new Mod[] { null }; + return Array.Empty(); } } @@ -53,5 +54,8 @@ namespace osu.Game.Rulesets.EmptyScrolling Text = ShortName[0].ToString(), Font = OsuFont.Default.With(size: 18), }; + + // Leave this line intact. It will bake the correct version into the ruleset on each build/release. + public override string RulesetAPIVersionSupported => CURRENT_RULESET_API_VERSION; } } diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Objects/Drawables/DrawableEmptyScrollingHitObject.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Objects/Drawables/DrawableEmptyScrollingHitObject.cs index b5ff0cde7c..a3c3b89105 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Objects/Drawables/DrawableEmptyScrollingHitObject.cs +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Objects/Drawables/DrawableEmptyScrollingHitObject.cs @@ -9,7 +9,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.EmptyScrolling.Objects.Drawables { - public class DrawableEmptyScrollingHitObject : DrawableHitObject + public partial class DrawableEmptyScrollingHitObject : DrawableHitObject { public DrawableEmptyScrollingHitObject(EmptyScrollingHitObject hitObject) : base(hitObject) diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/UI/DrawableEmptyScrollingRuleset.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/UI/DrawableEmptyScrollingRuleset.cs index 620a4abc51..99598fc113 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/UI/DrawableEmptyScrollingRuleset.cs +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/UI/DrawableEmptyScrollingRuleset.cs @@ -18,7 +18,7 @@ using osu.Game.Rulesets.UI.Scrolling; namespace osu.Game.Rulesets.EmptyScrolling.UI { [Cached] - public class DrawableEmptyScrollingRuleset : DrawableScrollingRuleset + public partial class DrawableEmptyScrollingRuleset : DrawableScrollingRuleset { public DrawableEmptyScrollingRuleset(EmptyScrollingRuleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) : base(ruleset, beatmap, mods) diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/UI/EmptyScrollingPlayfield.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/UI/EmptyScrollingPlayfield.cs index 56620e44b3..3770ba8172 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/UI/EmptyScrollingPlayfield.cs +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/UI/EmptyScrollingPlayfield.cs @@ -8,7 +8,7 @@ using osu.Game.Rulesets.UI.Scrolling; namespace osu.Game.Rulesets.EmptyScrolling.UI { [Cached] - public class EmptyScrollingPlayfield : ScrollingPlayfield + public partial class EmptyScrollingPlayfield : ScrollingPlayfield { [BackgroundDependencyLoader] private void load() diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/osu.Game.Rulesets.EmptyScrolling.csproj b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/osu.Game.Rulesets.EmptyScrolling.csproj index 2ea52429ab..5bf3884f53 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/osu.Game.Rulesets.EmptyScrolling.csproj +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/osu.Game.Rulesets.EmptyScrolling.csproj @@ -1,6 +1,6 @@  - netstandard2.1 + net6.0 osu.Game.Rulesets.EmptyScrolling Library AnyCPU @@ -12,4 +12,4 @@ - \ No newline at end of file + diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs index b75a5ec187..c105e9c040 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs @@ -9,7 +9,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Pippidon.Tests { - public class TestSceneOsuGame : OsuTestScene + public partial class TestSceneOsuGame : OsuTestScene { [BackgroundDependencyLoader] private void load() diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuPlayer.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuPlayer.cs index f00528900c..793d3933d7 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuPlayer.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuPlayer.cs @@ -7,7 +7,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Pippidon.Tests { [TestFixture] - public class TestSceneOsuPlayer : PlayerTestScene + public partial class TestSceneOsuPlayer : PlayerTestScene { protected override Ruleset CreatePlayerRuleset() => new PippidonRuleset(); } diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj index 718ada1905..683e9fd5e8 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj @@ -9,11 +9,9 @@ false - - + - - + diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs index 8f0b31ef1b..0a4fa84ce1 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs @@ -21,8 +21,11 @@ namespace osu.Game.Rulesets.Pippidon.Beatmaps public PippidonBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) : base(beatmap, ruleset) { - minPosition = beatmap.HitObjects.Min(getUsablePosition); - maxPosition = beatmap.HitObjects.Max(getUsablePosition); + if (beatmap.HitObjects.Any()) + { + minPosition = beatmap.HitObjects.Min(getUsablePosition); + maxPosition = beatmap.HitObjects.Max(getUsablePosition); + } } public override bool CanConvert() => Beatmap.HitObjects.All(h => h is IHasXPosition && h is IHasYPosition); 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 e458cacef9..554d03c79f 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 @@ -17,7 +17,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Pippidon.Objects.Drawables { - public class DrawablePippidonHitObject : DrawableHitObject + public partial class DrawablePippidonHitObject : DrawableHitObject { private BindableNumber currentLane; diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonInputManager.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonInputManager.cs index c9e6e6faaa..a31c1bf832 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonInputManager.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonInputManager.cs @@ -7,7 +7,7 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Pippidon { - public class PippidonInputManager : RulesetInputManager + public partial class PippidonInputManager : RulesetInputManager { public PippidonInputManager(RulesetInfo ruleset) : base(ruleset, 0, SimultaneousBindingMode.Unique) diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonRuleset.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonRuleset.cs index ea94ceb4b5..bde530feb8 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonRuleset.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonRuleset.cs @@ -1,10 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; using osu.Framework.Input.Bindings; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; @@ -34,7 +33,7 @@ namespace osu.Game.Rulesets.Pippidon return new[] { new PippidonModAutoplay() }; default: - return new Mod[] { null }; + return Array.Empty(); } } @@ -46,10 +45,9 @@ namespace osu.Game.Rulesets.Pippidon new KeyBinding(InputKey.S, PippidonAction.MoveDown), }; - public override Drawable CreateIcon() => new Sprite - { - Margin = new MarginPadding { Top = 3 }, - Texture = new TextureStore(new TextureLoaderStore(CreateResourceStore()), false).Get("Textures/coin"), - }; + public override Drawable CreateIcon() => new PippidonRulesetIcon(this); + + // Leave this line intact. It will bake the correct version into the ruleset on each build/release. + public override string RulesetAPIVersionSupported => CURRENT_RULESET_API_VERSION; } } diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonRulesetIcon.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonRulesetIcon.cs new file mode 100644 index 0000000000..75005a3743 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonRulesetIcon.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; + +namespace osu.Game.Rulesets.Pippidon +{ + public partial class PippidonRulesetIcon : Sprite + { + private readonly Ruleset ruleset; + + public PippidonRulesetIcon(Ruleset ruleset) + { + this.ruleset = ruleset; + + Margin = new MarginPadding { Top = 3 }; + } + + [BackgroundDependencyLoader] + private void load(IRenderer renderer) + { + Texture = new TextureStore(renderer, new TextureLoaderStore(ruleset.CreateResourceStore()), false).Get("Textures/coin"); + } + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/DrawablePippidonRuleset.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/DrawablePippidonRuleset.cs index 9a73dd7790..1e7e09be67 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/DrawablePippidonRuleset.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/DrawablePippidonRuleset.cs @@ -18,7 +18,7 @@ using osu.Game.Rulesets.UI.Scrolling; namespace osu.Game.Rulesets.Pippidon.UI { [Cached] - public class DrawablePippidonRuleset : DrawableScrollingRuleset + public partial class DrawablePippidonRuleset : DrawableScrollingRuleset { public DrawablePippidonRuleset(PippidonRuleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) : base(ruleset, beatmap, mods) diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/PippidonCharacter.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/PippidonCharacter.cs index 98dba622d0..33d349da62 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/PippidonCharacter.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/PippidonCharacter.cs @@ -15,7 +15,7 @@ using osuTK; namespace osu.Game.Rulesets.Pippidon.UI { - public class PippidonCharacter : BeatSyncedContainer, IKeyBindingHandler + public partial class PippidonCharacter : BeatSyncedContainer, IKeyBindingHandler { public readonly BindableInt LanePosition = new BindableInt { diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfield.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfield.cs index ab8c6bb2e9..a90326e935 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfield.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfield.cs @@ -16,7 +16,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Pippidon.UI { [Cached] - public class PippidonPlayfield : ScrollingPlayfield + public partial class PippidonPlayfield : ScrollingPlayfield { public const float LANE_HEIGHT = 70; @@ -60,7 +60,7 @@ namespace osu.Game.Rulesets.Pippidon.UI }); } - private class LaneContainer : BeatSyncedContainer + private partial class LaneContainer : BeatSyncedContainer { private OsuColour colours; private FillFlowContainer fill; @@ -99,7 +99,7 @@ namespace osu.Game.Rulesets.Pippidon.UI } } - private class Lane : CompositeDrawable + private partial class Lane : CompositeDrawable { public Lane() { diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj index a3607343c9..9c8867f4ef 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj @@ -1,6 +1,6 @@  - netstandard2.1 + net6.0 osu.Game.Rulesets.Pippidon Library AnyCPU @@ -12,4 +12,4 @@ - \ No newline at end of file + diff --git a/UseLocalFramework.ps1 b/UseLocalFramework.ps1 new file mode 100644 index 0000000000..9f4547d980 --- /dev/null +++ b/UseLocalFramework.ps1 @@ -0,0 +1,55 @@ +# Run this script to use a local copy of osu-framework rather than fetching it from nuget. +# It expects the osu-framework directory to be at the same level as the osu directory +# +# https://github.com/ppy/osu-framework/wiki/Testing-local-framework-checkout-with-other-projects + +$GAME_CSPROJ="osu.Game/osu.Game.csproj" +$ANDROID_PROPS="osu.Android.props" +$IOS_PROPS="osu.iOS.props" +$SLN="osu.sln" + +dotnet remove $GAME_CSPROJ reference ppy.osu.Framework; +dotnet remove $ANDROID_PROPS reference ppy.osu.Framework.Android; +dotnet remove $IOS_PROPS reference ppy.osu.Framework.iOS; + +dotnet sln $SLN add ../osu-framework/osu.Framework/osu.Framework.csproj ` + ../osu-framework/osu.Framework.NativeLibs/osu.Framework.NativeLibs.csproj ` + ../osu-framework/osu.Framework.Android/osu.Framework.Android.csproj ` + ../osu-framework/osu.Framework.iOS/osu.Framework.iOS.csproj; + +dotnet add $GAME_CSPROJ reference ../osu-framework/osu.Framework/osu.Framework.csproj; +dotnet add $ANDROID_PROPS reference ../osu-framework/osu.Framework.Android/osu.Framework.Android.csproj; +dotnet add $IOS_PROPS reference ../osu-framework/osu.Framework.iOS/osu.Framework.iOS.csproj; + +# workaround for dotnet add not inserting $(MSBuildThisFileDirectory) on props files +(Get-Content "osu.Android.props") -replace "`"..\\osu-framework", "`"`$(MSBuildThisFileDirectory)..\osu-framework" | Set-Content "osu.Android.props" +(Get-Content "osu.iOS.props") -replace "`"..\\osu-framework", "`"`$(MSBuildThisFileDirectory)..\osu-framework" | Set-Content "osu.iOS.props" + +# needed because iOS framework nupkg includes a set of properties to work around certain issues during building, +# and those get ignored when referencing framework via project, threfore we have to manually include it via props reference. +(Get-Content "osu.iOS.props") | + Foreach-Object { + if ($_ -match "") + { + " " + } + + $_ + } | Set-Content "osu.iOS.props" + +$TMP=New-TemporaryFile + +$SLNF=Get-Content "osu.Desktop.slnf" | ConvertFrom-Json +$SLNF.solution.projects += ("../osu-framework/osu.Framework/osu.Framework.csproj", "../osu-framework/osu.Framework.NativeLibs/osu.Framework.NativeLibs.csproj") +ConvertTo-Json $SLNF | Out-File $TMP -Encoding UTF8 +Move-Item -Path $TMP -Destination "osu.Desktop.slnf" -Force + +$SLNF=Get-Content "osu.Android.slnf" | ConvertFrom-Json +$SLNF.solution.projects += ("../osu-framework/osu.Framework/osu.Framework.csproj", "../osu-framework/osu.Framework.NativeLibs/osu.Framework.NativeLibs.csproj", "../osu-framework/osu.Framework.Android/osu.Framework.Android.csproj") +ConvertTo-Json $SLNF | Out-File $TMP -Encoding UTF8 +Move-Item -Path $TMP -Destination "osu.Android.slnf" -Force + +$SLNF=Get-Content "osu.iOS.slnf" | ConvertFrom-Json +$SLNF.solution.projects += ("../osu-framework/osu.Framework/osu.Framework.csproj", "../osu-framework/osu.Framework.NativeLibs/osu.Framework.NativeLibs.csproj", "../osu-framework/osu.Framework.iOS/osu.Framework.iOS.csproj") +ConvertTo-Json $SLNF | Out-File $TMP -Encoding UTF8 +Move-Item -Path $TMP -Destination "osu.iOS.slnf" -Force diff --git a/UseLocalFramework.sh b/UseLocalFramework.sh new file mode 100755 index 0000000000..c12b388e96 --- /dev/null +++ b/UseLocalFramework.sh @@ -0,0 +1,45 @@ +#!/bin/sh + +# Run this script to use a local copy of osu-framework rather than fetching it from nuget. +# It expects the osu-framework directory to be at the same level as the osu directory +# +# https://github.com/ppy/osu-framework/wiki/Testing-local-framework-checkout-with-other-projects + +GAME_CSPROJ="osu.Game/osu.Game.csproj" +ANDROID_PROPS="osu.Android.props" +IOS_PROPS="osu.iOS.props" +SLN="osu.sln" + +dotnet remove $GAME_CSPROJ reference ppy.osu.Framework +dotnet remove $ANDROID_PROPS reference ppy.osu.Framework.Android +dotnet remove $IOS_PROPS reference ppy.osu.Framework.iOS + +dotnet sln $SLN add ../osu-framework/osu.Framework/osu.Framework.csproj \ + ../osu-framework/osu.Framework.NativeLibs/osu.Framework.NativeLibs.csproj \ + ../osu-framework/osu.Framework.Android/osu.Framework.Android.csproj \ + ../osu-framework/osu.Framework.iOS/osu.Framework.iOS.csproj + +dotnet add $GAME_CSPROJ reference ../osu-framework/osu.Framework/osu.Framework.csproj +dotnet add $ANDROID_PROPS reference ../osu-framework/osu.Framework.Android/osu.Framework.Android.csproj +dotnet add $IOS_PROPS reference ../osu-framework/osu.Framework.iOS/osu.Framework.iOS.csproj + +# workaround for dotnet add not inserting $(MSBuildThisFileDirectory) on props files +sed -i.bak 's:"..\\osu-framework:"$(MSBuildThisFileDirectory)..\\osu-framework:g' ./osu.Android.props && rm osu.Android.props.bak +sed -i.bak 's:"..\\osu-framework:"$(MSBuildThisFileDirectory)..\\osu-framework:g' ./osu.iOS.props && rm osu.iOS.props.bak + +# needed because iOS framework nupkg includes a set of properties to work around certain issues during building, +# and those get ignored when referencing framework via project, threfore we have to manually include it via props reference. +sed -i.bak '/<\/Project>/i\ + \ +' ./osu.iOS.props && rm osu.iOS.props.bak + +tmp=$(mktemp) + +jq '.solution.projects += ["../osu-framework/osu.Framework/osu.Framework.csproj", "../osu-framework/osu.Framework.NativeLibs/osu.Framework.NativeLibs.csproj"]' osu.Desktop.slnf > $tmp +mv -f $tmp osu.Desktop.slnf + +jq '.solution.projects += ["../osu-framework/osu.Framework/osu.Framework.csproj", "../osu-framework/osu.Framework.NativeLibs/osu.Framework.NativeLibs.csproj", "../osu-framework/osu.Framework.Android/osu.Framework.Android.csproj"]' osu.Android.slnf > $tmp +mv -f $tmp osu.Android.slnf + +jq '.solution.projects += ["../osu-framework/osu.Framework/osu.Framework.csproj", "../osu-framework/osu.Framework.NativeLibs/osu.Framework.NativeLibs.csproj", "../osu-framework/osu.Framework.iOS/osu.Framework.iOS.csproj"]' osu.iOS.slnf > $tmp +mv -f $tmp osu.iOS.slnf diff --git a/UseLocalResources.ps1 b/UseLocalResources.ps1 new file mode 100644 index 0000000000..f9d9df01bb --- /dev/null +++ b/UseLocalResources.ps1 @@ -0,0 +1,12 @@ +$CSPROJ="osu.Game/osu.Game.csproj" +$SLN="osu.sln" + +dotnet remove $CSPROJ package ppy.osu.Game.Resources; +dotnet sln $SLN add ../osu-resources/osu.Game.Resources/osu.Game.Resources.csproj +dotnet add $CSPROJ reference ../osu-resources/osu.Game.Resources/osu.Game.Resources.csproj + +$SLNF=Get-Content "osu.Desktop.slnf" | ConvertFrom-Json +$TMP=New-TemporaryFile +$SLNF.solution.projects += ("../osu-resources/osu.Game.Resources/osu.Game.Resources.csproj") +ConvertTo-Json $SLNF | Out-File $TMP -Encoding UTF8 +Move-Item -Path $TMP -Destination "osu.Desktop.slnf" -Force diff --git a/UseLocalResources.sh b/UseLocalResources.sh new file mode 100755 index 0000000000..6d9d2b6016 --- /dev/null +++ b/UseLocalResources.sh @@ -0,0 +1,11 @@ +CSPROJ="osu.Game/osu.Game.csproj" +SLN="osu.sln" + +dotnet remove $CSPROJ package ppy.osu.Game.Resources; +dotnet sln $SLN add ../osu-resources/osu.Game.Resources/osu.Game.Resources.csproj +dotnet add $CSPROJ reference ../osu-resources/osu.Game.Resources/osu.Game.Resources.csproj + +SLNF="osu.Desktop.slnf" +TMP=$(mktemp) +jq '.solution.projects += ["../osu-resources/osu.Game.Resources/osu.Game.Resources.csproj"]' $SLNF > $TMP +mv -f $TMP $SLNF diff --git a/appveyor.yml b/appveyor.yml index 5be73f9875..ed48a997e8 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,6 +1,6 @@ clone_depth: 1 version: '{branch}-{build}' -image: Visual Studio 2019 +image: Visual Studio 2022 cache: - '%LOCALAPPDATA%\NuGet\v3-cache -> appveyor.yml' @@ -11,6 +11,8 @@ dotnet_csproj: before_build: - cmd: dotnet --info # Useful when version mismatch between CI and local + - cmd: dotnet workload install maui-android # Change to `dotnet workload restore` once there's no old projects + - cmd: dotnet workload install maui-ios # Change to `dotnet workload restore` once there's no old projects - cmd: nuget restore -verbosity quiet # Only nuget.exe knows both new (.NET Core) and old (Xamarin) projects build: diff --git a/appveyor_deploy.yml b/appveyor_deploy.yml index adf98848bc..175c8d0f1b 100644 --- a/appveyor_deploy.yml +++ b/appveyor_deploy.yml @@ -1,6 +1,6 @@ clone_depth: 1 version: '{build}' -image: Visual Studio 2019 +image: Visual Studio 2022 test: off skip_non_tags: true configuration: Release @@ -83,4 +83,4 @@ artifacts: deploy: - provider: Environment - name: nuget \ No newline at end of file + name: nuget diff --git a/fastlane/Appfile b/fastlane/Appfile deleted file mode 100644 index 083de66985..0000000000 --- a/fastlane/Appfile +++ /dev/null @@ -1,2 +0,0 @@ -app_identifier("sh.ppy.osulazer") # The bundle identifier of your app -apple_id("apple-dev@ppy.sh") # Your Apple email address diff --git a/fastlane/Fastfile b/fastlane/Fastfile deleted file mode 100644 index cc5abf5b03..0000000000 --- a/fastlane/Fastfile +++ /dev/null @@ -1,147 +0,0 @@ -update_fastlane - -platform :android do -desc 'Deploy to play store' - lane :beta do |options| - - update_version( - version: options[:version], - build: options[:build], - ) - - build(options) - - supply( - apk: './osu.Android/bin/Release/sh.ppy.osulazer-Signed.apk', - package_name: 'sh.ppy.osulazer', - track: 'alpha', # upload to alpha, we can promote it later - json_key: options[:json_key], - ) - end - - desc 'Deploy to github release' - lane :build_github do |options| - - update_version( - version: options[:version], - build: options[:build], - ) - - build(options) - - client = HTTPClient.new - changelog = client.get_content 'https://gist.githubusercontent.com/peppy/aaa2ec1a323554b619671cac6dbbb776/raw' - changelog.gsub!('$BUILD_ID', options[:build]) - - set_github_release( - repository_name: "ppy/osu", - api_token: ENV["GITHUB_TOKEN"], - name: options[:build], - tag_name: options[:build], - is_draft: true, - description: changelog, - commitish: "master", - upload_assets: ["osu.Android/bin/Release/sh.ppy.osulazer.apk"] - ) - - end - - desc 'Compile the project' - lane :build do |options| - nuget_restore(project_path: 'osu.Android/osu.Android.csproj') - nuget_restore(project_path: 'osu.Game/osu.Game.csproj') - nuget_restore(project_path: 'osu.Game.Rulesets.Osu/osu.Game.Rulesets.Osu.csproj') - nuget_restore(project_path: 'osu.Game.Rulesets.Taiko/osu.Game.Rulesets.Taiko.csproj') - nuget_restore(project_path: 'osu.Game.Rulesets.Catch/osu.Game.Rulesets.Catch.csproj') - nuget_restore(project_path: 'osu.Game.Rulesets.Mania/osu.Game.Rulesets.Mania.csproj') - - souyuz( - build_configuration: 'Release', - solution_path: 'osu.sln', - platform: "android", - output_path: "osu.Android/bin/Release/", - keystore_path: options[:keystore_path], - keystore_alias: options[:keystore_alias], - keystore_password: ENV["KEYSTORE_PASSWORD"] - ) - end - - lane :update_version do |options| - - split = options[:build].split('.') - split[1] = split[1].to_s.rjust(4, '0') - android_build = split.join('') - - app_version( - solution_path: 'osu.sln', - version: options[:version], - build: android_build, - ) - end - -end - -platform :ios do - desc 'Deploy to testflight' - lane :beta do |options| - update_version(options) - - provision( - type: 'appstore' - ) - - build( - build_configuration: 'Release', - build_platform: 'iPhone' - ) - - client = HTTPClient.new - changelog = client.get_content 'https://gist.githubusercontent.com/peppy/ab89c29dcc0dce95f39eb218e8fad197/raw' - changelog.gsub!('$BUILD_ID', options[:build]) - - pilot( - wait_processing_interval: 900, - changelog: changelog, - groups: ['osu! supporters', 'public'], - distribute_external: true, - ipa: './osu.iOS/bin/iPhone/Release/osu.iOS.ipa' - ) - end - - desc 'Compile the project' - lane :build do - nuget_restore(project_path: 'osu.iOS/osu.iOS.csproj') - nuget_restore(project_path: 'osu.Game/osu.Game.csproj') - nuget_restore(project_path: 'osu.Game.Rulesets.Osu/osu.Game.Rulesets.Osu.csproj') - nuget_restore(project_path: 'osu.Game.Rulesets.Taiko/osu.Game.Rulesets.Taiko.csproj') - nuget_restore(project_path: 'osu.Game.Rulesets.Catch/osu.Game.Rulesets.Catch.csproj') - nuget_restore(project_path: 'osu.Game.Rulesets.Mania/osu.Game.Rulesets.Mania.csproj') - - souyuz( - platform: "ios", - plist_path: "osu.iOS/Info.plist" - ) - end - - desc 'Install provisioning profiles using match' - lane :provision do |options| - if Helper.is_ci? - options[:readonly] = true - end - - match(options) - end - - lane :update_version do |options| - options[:plist_path] = 'osu.iOS/Info.plist' - app_version(options) - end - - lane :testflight_prune_dry do - clean_testflight_testers(days_of_inactivity:45, dry_run: true) - end - - lane :testflight_prune do - clean_testflight_testers(days_of_inactivity: 45) - end -end diff --git a/fastlane/Matchfile b/fastlane/Matchfile deleted file mode 100644 index 40c974b09e..0000000000 --- a/fastlane/Matchfile +++ /dev/null @@ -1 +0,0 @@ -git_url('https://github.com/peppy/apple-certificates') diff --git a/fastlane/Pluginfile b/fastlane/Pluginfile deleted file mode 100644 index 9f4f47f213..0000000000 --- a/fastlane/Pluginfile +++ /dev/null @@ -1,7 +0,0 @@ -# Autogenerated by fastlane -# -# Ensure this file is checked in to source control! - -gem 'fastlane-plugin-clean_testflight_testers' -gem 'fastlane-plugin-souyuz' -gem 'fastlane-plugin-xamarin' diff --git a/fastlane/README.md b/fastlane/README.md deleted file mode 100644 index 9d5e11f7cb..0000000000 --- a/fastlane/README.md +++ /dev/null @@ -1,109 +0,0 @@ -fastlane documentation ----- - -# Installation - -Make sure you have the latest version of the Xcode command line tools installed: - -```sh -xcode-select --install -``` - -For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) - -# Available Actions - -## Android - -### android beta - -```sh -[bundle exec] fastlane android beta -``` - -Deploy to play store - -### android build_github - -```sh -[bundle exec] fastlane android build_github -``` - -Deploy to github release - -### android build - -```sh -[bundle exec] fastlane android build -``` - -Compile the project - -### android update_version - -```sh -[bundle exec] fastlane android update_version -``` - - - ----- - - -## iOS - -### ios beta - -```sh -[bundle exec] fastlane ios beta -``` - -Deploy to testflight - -### ios build - -```sh -[bundle exec] fastlane ios build -``` - -Compile the project - -### ios provision - -```sh -[bundle exec] fastlane ios provision -``` - -Install provisioning profiles using match - -### ios update_version - -```sh -[bundle exec] fastlane ios update_version -``` - - - -### ios testflight_prune_dry - -```sh -[bundle exec] fastlane ios testflight_prune_dry -``` - - - -### ios testflight_prune - -```sh -[bundle exec] fastlane ios testflight_prune -``` - - - ----- - -This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. - -More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). - -The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). diff --git a/osu.Android.props b/osu.Android.props index aad8cf10d0..3ede0b85da 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -1,61 +1,24 @@  - 8.0 - bin\$(Configuration) - 4 - 2.0 - false - false - Library - 512 - Off - True - Xamarin.Android.Net.AndroidClientHandler - v10.0 - false - true - armeabi-v7a;x86;arm64-v8a - true - cjk,mideast,other,rare,west - SdkOnly - prompt - - - True - portable - False - DEBUG;TRACE - false - true - false - - - false - None - True - false - False + 21.0 + android-x86;android-arm;android-arm64 + apk + CJK;Mideast;Rare;West;Other; + Xamarin.Android.Net.AndroidMessageHandler + + true true + manifestmerger.jar - - osu.licenseheader - + - - - - - - - - - - - - - - + + + + true + diff --git a/osu.Android/AndroidImportTask.cs b/osu.Android/AndroidImportTask.cs new file mode 100644 index 0000000000..7273a6da5c --- /dev/null +++ b/osu.Android/AndroidImportTask.cs @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using System.Threading.Tasks; +using Android.Content; +using Android.Net; +using Android.Provider; +using osu.Game.Database; + +namespace osu.Android +{ + public class AndroidImportTask : ImportTask + { + private readonly ContentResolver contentResolver; + + private readonly Uri uri; + + private AndroidImportTask(Stream stream, string filename, ContentResolver contentResolver, Uri uri) + : base(stream, filename) + { + this.contentResolver = contentResolver; + this.uri = uri; + } + + public override void DeleteFile() + { + contentResolver.Delete(uri, null, null); + } + + public static async Task Create(ContentResolver contentResolver, Uri uri) + { + // there are more performant overloads of this method, but this one is the most backwards-compatible + // (dates back to API 1). + + var cursor = contentResolver.Query(uri, null, null, null, null); + + if (cursor == null) + return null; + + if (!cursor.MoveToFirst()) + return null; + + int filenameColumn = cursor.GetColumnIndex(IOpenableColumns.DisplayName); + string filename = cursor.GetString(filenameColumn) ?? uri.Path ?? string.Empty; + + // SharpCompress requires archive streams to be seekable, which the stream opened by + // OpenInputStream() seems to not necessarily be. + // copy to an arbitrary-access memory stream to be able to proceed with the import. + var copy = new MemoryStream(); + + using (var stream = contentResolver.OpenInputStream(uri)) + { + if (stream == null) + return null; + + await stream.CopyToAsync(copy).ConfigureAwait(false); + } + + return new AndroidImportTask(copy, filename, contentResolver, uri); + } + } +} diff --git a/osu.Android/AndroidJoystickSettings.cs b/osu.Android/AndroidJoystickSettings.cs new file mode 100644 index 0000000000..bf69461f0d --- /dev/null +++ b/osu.Android/AndroidJoystickSettings.cs @@ -0,0 +1,76 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Android.Input; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Localisation; +using osu.Game.Overlays.Settings; + +namespace osu.Android +{ + public partial class AndroidJoystickSettings : SettingsSubsection + { + protected override LocalisableString Header => JoystickSettingsStrings.JoystickGamepad; + + private readonly AndroidJoystickHandler joystickHandler; + + private readonly Bindable enabled = new BindableBool(true); + + private SettingsSlider deadzoneSlider = null!; + + private Bindable handlerDeadzone = null!; + + private Bindable localDeadzone = null!; + + public AndroidJoystickSettings(AndroidJoystickHandler joystickHandler) + { + this.joystickHandler = joystickHandler; + } + + [BackgroundDependencyLoader] + private void load() + { + // use local bindable to avoid changing enabled state of game host's bindable. + handlerDeadzone = joystickHandler.DeadzoneThreshold.GetBoundCopy(); + localDeadzone = handlerDeadzone.GetUnboundCopy(); + + Children = new Drawable[] + { + new SettingsCheckbox + { + LabelText = CommonStrings.Enabled, + Current = enabled + }, + deadzoneSlider = new SettingsSlider + { + LabelText = JoystickSettingsStrings.DeadzoneThreshold, + KeyboardStep = 0.01f, + DisplayAsPercentage = true, + Current = localDeadzone, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + enabled.BindTo(joystickHandler.Enabled); + enabled.BindValueChanged(e => deadzoneSlider.Current.Disabled = !e.NewValue, true); + + handlerDeadzone.BindValueChanged(val => + { + bool disabled = localDeadzone.Disabled; + + localDeadzone.Disabled = false; + localDeadzone.Value = val.NewValue; + localDeadzone.Disabled = disabled; + }, true); + + localDeadzone.BindValueChanged(val => handlerDeadzone.Value = val.NewValue); + } + } +} diff --git a/osu.Android/Properties/AndroidManifest.xml b/osu.Android/AndroidManifest.xml similarity index 68% rename from osu.Android/Properties/AndroidManifest.xml rename to osu.Android/AndroidManifest.xml index 165a64a424..bc2f49b1a9 100644 --- a/osu.Android/Properties/AndroidManifest.xml +++ b/osu.Android/AndroidManifest.xml @@ -1,5 +1,5 @@  - - + + \ No newline at end of file diff --git a/osu.Android/AndroidMouseSettings.cs b/osu.Android/AndroidMouseSettings.cs new file mode 100644 index 0000000000..d6d7750448 --- /dev/null +++ b/osu.Android/AndroidMouseSettings.cs @@ -0,0 +1,97 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Android.OS; +using osu.Framework.Allocation; +using osu.Framework.Android.Input; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Localisation; +using osu.Game.Overlays.Settings; +using osu.Game.Overlays.Settings.Sections.Input; + +namespace osu.Android +{ + public partial class AndroidMouseSettings : SettingsSubsection + { + private readonly AndroidMouseHandler mouseHandler; + + protected override LocalisableString Header => MouseSettingsStrings.Mouse; + + private Bindable handlerSensitivity = null!; + + private Bindable localSensitivity = null!; + + private Bindable relativeMode = null!; + + public AndroidMouseSettings(AndroidMouseHandler mouseHandler) + { + this.mouseHandler = mouseHandler; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager osuConfig) + { + // use local bindable to avoid changing enabled state of game host's bindable. + handlerSensitivity = mouseHandler.Sensitivity.GetBoundCopy(); + localSensitivity = handlerSensitivity.GetUnboundCopy(); + + relativeMode = mouseHandler.UseRelativeMode.GetBoundCopy(); + + // High precision/pointer capture is only available on Android 8.0 and up + if (Build.VERSION.SdkInt >= BuildVersionCodes.O) + { + AddRange(new Drawable[] + { + new SettingsCheckbox + { + LabelText = MouseSettingsStrings.HighPrecisionMouse, + TooltipText = MouseSettingsStrings.HighPrecisionMouseTooltip, + Current = relativeMode, + Keywords = new[] { @"raw", @"input", @"relative", @"cursor", @"captured", @"pointer" }, + }, + new MouseSettings.SensitivitySetting + { + LabelText = MouseSettingsStrings.CursorSensitivity, + Current = localSensitivity, + }, + }); + } + + AddRange(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), + }, + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + relativeMode.BindValueChanged(relative => localSensitivity.Disabled = !relative.NewValue, true); + + handlerSensitivity.BindValueChanged(val => + { + bool disabled = localSensitivity.Disabled; + + localSensitivity.Disabled = false; + localSensitivity.Value = val.NewValue; + localSensitivity.Disabled = disabled; + }, true); + + localSensitivity.BindValueChanged(val => handlerSensitivity.Value = val.NewValue); + } + } +} diff --git a/osu.Android/GameplayScreenRotationLocker.cs b/osu.Android/GameplayScreenRotationLocker.cs index 2e83f784d3..3c39a820cc 100644 --- a/osu.Android/GameplayScreenRotationLocker.cs +++ b/osu.Android/GameplayScreenRotationLocker.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Android.Content.PM; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -9,7 +11,7 @@ using osu.Game; namespace osu.Android { - public class GameplayScreenRotationLocker : Component + public partial class GameplayScreenRotationLocker : Component { private Bindable localUserPlaying; diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index eebd079f68..f0a6e4733c 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -1,17 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; -using System.IO; using System.Linq; +using System.Reflection; using System.Threading.Tasks; using Android.App; using Android.Content; using Android.Content.PM; using Android.Graphics; using Android.OS; -using Android.Provider; using Android.Views; using osu.Framework.Android; using osu.Game.Database; @@ -72,11 +73,23 @@ namespace osu.Android Debug.Assert(Resources?.DisplayMetrics != null); Point displaySize = new Point(); +#pragma warning disable 618 // GetSize is deprecated WindowManager.DefaultDisplay.GetSize(displaySize); +#pragma warning restore 618 float smallestWidthDp = Math.Min(displaySize.X, displaySize.Y) / Resources.DisplayMetrics.Density; bool isTablet = smallestWidthDp >= 600f; RequestedOrientation = DefaultOrientation = isTablet ? ScreenOrientation.FullUser : ScreenOrientation.SensorLandscape; + + // Currently (SDK 6.0.200), BundleAssemblies is not runnable for net6-android. + // The assembly files are not available as files either after native AOT. + // Manually load them so that they can be loaded by RulesetStore.loadFromAppDomain. + // REMEMBER to fully uninstall previous version every time when investigating this! + // Don't forget osu.Game.Tests.Android too. + Assembly.Load("osu.Game.Rulesets.Osu"); + Assembly.Load("osu.Game.Rulesets.Taiko"); + Assembly.Load("osu.Game.Rulesets.Catch"); + Assembly.Load("osu.Game.Rulesets.Mania"); } protected override void OnNewIntent(Intent intent) => handleIntent(intent); @@ -116,28 +129,14 @@ namespace osu.Android await Task.WhenAll(uris.Select(async uri => { - // there are more performant overloads of this method, but this one is the most backwards-compatible - // (dates back to API 1). - var cursor = ContentResolver?.Query(uri, null, null, null, null); + var task = await AndroidImportTask.Create(ContentResolver!, uri).ConfigureAwait(false); - if (cursor == null) - return; - - cursor.MoveToFirst(); - - int filenameColumn = cursor.GetColumnIndex(OpenableColumns.DisplayName); - string filename = cursor.GetString(filenameColumn); - - // SharpCompress requires archive streams to be seekable, which the stream opened by - // OpenInputStream() seems to not necessarily be. - // copy to an arbitrary-access memory stream to be able to proceed with the import. - var copy = new MemoryStream(); - using (var stream = ContentResolver.OpenInputStream(uri)) - await stream.CopyToAsync(copy).ConfigureAwait(false); - - lock (tasks) + if (task != null) { - tasks.Add(new ImportTask(copy, filename)); + lock (tasks) + { + tasks.Add(task); + } } })).ConfigureAwait(false); diff --git a/osu.Android/OsuGameAndroid.cs b/osu.Android/OsuGameAndroid.cs index 050bf2b787..0227d2aec2 100644 --- a/osu.Android/OsuGameAndroid.cs +++ b/osu.Android/OsuGameAndroid.cs @@ -1,18 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using Android.App; -using Android.OS; +using Microsoft.Maui.Devices; using osu.Framework.Allocation; +using osu.Framework.Android.Input; +using osu.Framework.Input.Handlers; +using osu.Framework.Platform; using osu.Game; +using osu.Game.Overlays.Settings; using osu.Game.Updater; using osu.Game.Utils; -using Xamarin.Essentials; namespace osu.Android { - public class OsuGameAndroid : OsuGame + public partial class OsuGameAndroid : OsuGame { [Cached] private readonly OsuGameActivity gameActivity; @@ -42,7 +47,7 @@ namespace osu.Android // https://stackoverflow.com/questions/52977079/android-sdk-28-versioncode-in-packageinfo-has-been-deprecated string versionName = string.Empty; - if (Build.VERSION.SdkInt >= BuildVersionCodes.P) + if (OperatingSystem.IsAndroidVersionAtLeast(28)) { versionName = packageInfo.LongVersionCode.ToString(); // ensure we only read the trailing portion of long (the part we are interested in). @@ -73,15 +78,36 @@ namespace osu.Android LoadComponentAsync(new GameplayScreenRotationLocker(), Add); } + public override void SetHost(GameHost host) + { + base.SetHost(host); + host.Window.CursorState |= CursorState.Hidden; + } + protected override UpdateManager CreateUpdateManager() => new SimpleUpdateManager(); protected override BatteryInfo CreateBatteryInfo() => new AndroidBatteryInfo(); + public override SettingsSubsection CreateSettingsSubsectionFor(InputHandler handler) + { + switch (handler) + { + case AndroidMouseHandler mh: + return new AndroidMouseSettings(mh); + + case AndroidJoystickHandler jh: + return new AndroidJoystickSettings(jh); + + default: + return base.CreateSettingsSubsectionFor(handler); + } + } + private class AndroidBatteryInfo : BatteryInfo { - public override double ChargeLevel => Battery.ChargeLevel; + public override double? ChargeLevel => Battery.ChargeLevel; - public override bool IsCharging => Battery.PowerSource != BatteryPowerSource.Battery; + public override bool OnBattery => Battery.PowerSource == BatteryPowerSource.Battery; } } } diff --git a/osu.Android/Properties/AndroidManifestOverlay.xml b/osu.Android/Properties/AndroidManifestOverlay.xml new file mode 100644 index 0000000000..815f935383 --- /dev/null +++ b/osu.Android/Properties/AndroidManifestOverlay.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/osu.Android/Properties/AssemblyInfo.cs b/osu.Android/Properties/AssemblyInfo.cs index c0ba324d6e..f65b1b239f 100644 --- a/osu.Android/Properties/AssemblyInfo.cs +++ b/osu.Android/Properties/AssemblyInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Android; using Android.App; diff --git a/osu.Android/osu.Android.csproj b/osu.Android/osu.Android.csproj index fc50ca9fa1..1507bfaa29 100644 --- a/osu.Android/osu.Android.csproj +++ b/osu.Android/osu.Android.csproj @@ -1,71 +1,22 @@ - - + - Debug - AnyCPU - 8.0.30703 - 2.0 - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D} - {EFBA0AD7-5A72-4C68-AF49-83D382785DCF};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - {122416d6-6b49-4ee2-a1e8-b825f31c79fe} + net6.0-android + Exe osu.Android osu.Android - Properties\AndroidManifest.xml - armeabi-v7a;x86;arm64-v8a - false - - - cjk;mideast;other;rare;west - d8 - r8 - - - None - cjk;mideast;other;rare;west - true + true + + false + 0.0.0 + 1 + $(Version) - - - - + + + + + - - - - - - {58f6c80c-1253-4a0e-a465-b8c85ebeadf3} - osu.Game.Rulesets.Catch - - - {48f4582b-7687-4621-9cbe-5c24197cb536} - osu.Game.Rulesets.Mania - - - {c92a607b-1fdd-4954-9f92-03ff547d9080} - osu.Game.Rulesets.Osu - - - {f167e17a-7de6-4af5-b920-a5112296c695} - osu.Game.Rulesets.Taiko - - - {2a66dd92-adb1-4994-89e2-c94e04acda0d} - osu.Game - - - - - - - - 5.0.0 - - - - - - - \ No newline at end of file + diff --git a/osu.Desktop/DiscordRichPresence.cs b/osu.Desktop/DiscordRichPresence.cs index d87b25a4c7..fe3e08537e 100644 --- a/osu.Desktop/DiscordRichPresence.cs +++ b/osu.Desktop/DiscordRichPresence.cs @@ -9,6 +9,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Logging; +using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Extensions; using osu.Game.Online.API; @@ -19,16 +20,19 @@ using LogLevel = osu.Framework.Logging.LogLevel; namespace osu.Desktop { - internal class DiscordRichPresence : Component + internal partial class DiscordRichPresence : Component { private const string client_id = "367827983903490050"; - private DiscordRpcClient client; + private DiscordRpcClient client = null!; [Resolved] - private IBindable ruleset { get; set; } + private IBindable ruleset { get; set; } = null!; - private IBindable user; + private IBindable user = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; private readonly IBindable status = new Bindable(); private readonly IBindable activity = new Bindable(); @@ -41,7 +45,7 @@ namespace osu.Desktop }; [BackgroundDependencyLoader] - private void load(IAPIProvider provider, OsuConfigManager config) + private void load(OsuConfigManager config) { client = new DiscordRpcClient(client_id) { @@ -51,13 +55,14 @@ namespace osu.Desktop client.OnReady += onReady; // safety measure for now, until we performance test / improve backoff for failed connections. - client.OnConnectionFailed += (_, __) => client.Deinitialize(); + client.OnConnectionFailed += (_, _) => client.Deinitialize(); client.OnError += (_, e) => Logger.Log($"An error occurred with Discord RPC Client: {e.Code} {e.Message}", LoggingTarget.Network); config.BindWith(OsuSetting.DiscordRichPresence, privacyMode); - (user = provider.LocalUser.GetBoundCopy()).BindValueChanged(u => + user = api.LocalUser.GetBoundCopy(); + user.BindValueChanged(u => { status.UnbindBindings(); status.BindTo(u.NewValue.Status); @@ -93,8 +98,24 @@ namespace osu.Desktop if (status.Value is UserStatusOnline && activity.Value != null) { - presence.State = truncate(activity.Value.Status); + presence.State = truncate(activity.Value.GetStatus(privacyMode.Value == DiscordRichPresenceMode.Limited)); presence.Details = truncate(getDetails(activity.Value)); + + if (getBeatmap(activity.Value) is IBeatmapInfo beatmap && beatmap.OnlineID > 0) + { + presence.Buttons = new[] + { + new Button + { + Label = "View beatmap", + Url = $@"{api.WebsiteRootUrl}/beatmapsets/{beatmap.BeatmapSet?.OnlineID}#{ruleset.Value.ShortName}/{beatmap.OnlineID}" + } + }; + } + else + { + presence.Buttons = null; + } } else { @@ -106,7 +127,12 @@ namespace osu.Desktop if (privacyMode.Value == DiscordRichPresenceMode.Limited) presence.Assets.LargeImageText = string.Empty; else - presence.Assets.LargeImageText = $"{user.Value.Username}" + (user.Value.Statistics?.GlobalRank > 0 ? $" (rank #{user.Value.Statistics.GlobalRank:N0})" : string.Empty); + { + if (user.Value.RulesetsStatistics != null && user.Value.RulesetsStatistics.TryGetValue(ruleset.Value.ShortName, out UserStatistics? statistics)) + presence.Assets.LargeImageText = $"{user.Value.Username}" + (statistics.GlobalRank > 0 ? $" (rank #{statistics.GlobalRank:N0})" : string.Empty); + else + presence.Assets.LargeImageText = $"{user.Value.Username}" + (user.Value.Statistics?.GlobalRank > 0 ? $" (rank #{user.Value.Statistics.GlobalRank:N0})" : string.Empty); + } // update ruleset presence.Assets.SmallImageKey = ruleset.Value.IsLegacyRuleset() ? $"mode_{ruleset.Value.OnlineID}" : "mode_custom"; @@ -136,15 +162,32 @@ namespace osu.Desktop }); } + private IBeatmapInfo? getBeatmap(UserActivity activity) + { + switch (activity) + { + case UserActivity.InGame game: + return game.BeatmapInfo; + + case UserActivity.EditingBeatmap edit: + return edit.BeatmapInfo; + } + + return null; + } + private string getDetails(UserActivity activity) { switch (activity) { case UserActivity.InGame game: - return game.BeatmapInfo.ToString(); + return game.BeatmapInfo.ToString() ?? string.Empty; - case UserActivity.Editing edit: - return edit.BeatmapInfo.ToString(); + case UserActivity.EditingBeatmap edit: + return edit.BeatmapInfo.ToString() ?? string.Empty; + + case UserActivity.WatchingReplay watching: + return watching.BeatmapInfo.ToString(); case UserActivity.InLobby lobby: return privacyMode.Value == DiscordRichPresenceMode.Limited ? string.Empty : lobby.Room.Name.Value; diff --git a/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationRequest.cs b/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationRequest.cs index d6ef390a8f..0ad68919a2 100644 --- a/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationRequest.cs +++ b/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationRequest.cs @@ -11,7 +11,7 @@ namespace osu.Desktop.LegacyIpc /// public class LegacyIpcDifficultyCalculationRequest { - public string BeatmapFile { get; set; } + public string BeatmapFile { get; set; } = string.Empty; public int RulesetId { get; set; } public int Mods { get; set; } } diff --git a/osu.Desktop/LegacyIpc/LegacyIpcMessage.cs b/osu.Desktop/LegacyIpc/LegacyIpcMessage.cs index 0fa60e2068..8d0add32d1 100644 --- a/osu.Desktop/LegacyIpc/LegacyIpcMessage.cs +++ b/osu.Desktop/LegacyIpc/LegacyIpcMessage.cs @@ -37,17 +37,20 @@ namespace osu.Desktop.LegacyIpc public new object Value { get => base.Value; - set => base.Value = new Data - { - MessageType = value.GetType().Name, - MessageData = value - }; + set => base.Value = new Data(value.GetType().Name, value); } public class Data { - public string MessageType { get; set; } - public object MessageData { get; set; } + public string MessageType { get; } + + public object MessageData { get; } + + public Data(string messageType, object messageData) + { + MessageType = messageType; + MessageData = messageData; + } } } } diff --git a/osu.Desktop/LegacyIpc/LegacyTcpIpcProvider.cs b/osu.Desktop/LegacyIpc/LegacyTcpIpcProvider.cs index 10761bc315..5d950eef55 100644 --- a/osu.Desktop/LegacyIpc/LegacyTcpIpcProvider.cs +++ b/osu.Desktop/LegacyIpc/LegacyTcpIpcProvider.cs @@ -15,8 +15,6 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Taiko; -#nullable enable - namespace osu.Desktop.LegacyIpc { /// diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index be8159a7cc..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; @@ -20,23 +19,29 @@ using osu.Game.Updater; using osu.Desktop.Windows; using osu.Framework.Threading; using osu.Game.IO; +using osu.Game.IPC; +using osu.Game.Utils; +using SDL2; namespace osu.Desktop { - internal class OsuGameDesktop : OsuGame + internal partial class OsuGameDesktop : OsuGame { - public OsuGameDesktop(string[] args = null) + private OsuSchemeLinkIPCChannel? osuSchemeLinkIPCChannel; + private ArchiveImportIPCChannel? archiveImportIPCChannel; + + public OsuGameDesktop(string[]? args = null) : base(args) { } - public override StableStorage GetStorageForStableInstall() + public override StableStorage? GetStorageForStableInstall() { try { if (Host is DesktopGameHost desktopHost) { - string stablePath = getStableInstallPath(); + string? stablePath = getStableInstallPath(); if (!string.IsNullOrEmpty(stablePath)) return new StableStorage(stablePath, desktopHost); } @@ -49,11 +54,11 @@ namespace osu.Desktop return null; } - private string getStableInstallPath() + private string? getStableInstallPath() { static bool checkExists(string p) => Directory.Exists(Path.Combine(p, "Songs")) || File.Exists(Path.Combine(p, "osu!.cfg")); - string stableInstallPath; + string? stableInstallPath; if (OperatingSystem.IsWindows()) { @@ -81,15 +86,15 @@ namespace osu.Desktop } [SupportedOSPlatform("windows")] - private string getStableInstallPathFromRegistry() + private string? getStableInstallPathFromRegistry() { - using (RegistryKey key = Registry.ClassesRoot.OpenSubKey("osu")) + using (RegistryKey? key = Registry.ClassesRoot.OpenSubKey("osu")) return key?.OpenSubKey(@"shell\open\command")?.GetValue(string.Empty)?.ToString()?.Split('"')[1].Replace("osu!.exe", ""); } protected override UpdateManager CreateUpdateManager() { - string packageManaged = Environment.GetEnvironmentVariable("OSU_EXTERNAL_UPDATE_PROVIDER"); + string? packageManaged = Environment.GetEnvironmentVariable("OSU_EXTERNAL_UPDATE_PROVIDER"); if (!string.IsNullOrEmpty(packageManaged)) return new NoActionUpdateManager(); @@ -116,33 +121,45 @@ namespace osu.Desktop LoadComponentAsync(new GameplayWinKeyBlocker(), Add); LoadComponentAsync(new ElevatedPrivilegesChecker(), Add); + + osuSchemeLinkIPCChannel = new OsuSchemeLinkIPCChannel(Host, this); + archiveImportIPCChannel = new ArchiveImportIPCChannel(Host, this); } public override void SetHost(GameHost host) { base.SetHost(host); - var iconStream = Assembly.GetExecutingAssembly().GetManifestResourceStream(GetType(), "lazer.ico"); - var desktopWindow = (SDL2DesktopWindow)host.Window; + var iconStream = Assembly.GetExecutingAssembly().GetManifestResourceStream(GetType(), "lazer.ico"); + if (iconStream != null) + desktopWindow.SetIconFromStream(iconStream); + desktopWindow.CursorState |= CursorState.Hidden; - desktopWindow.SetIconFromStream(iconStream); 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(); + private readonly List importableFiles = new List(); - private ScheduledDelegate importSchedule; + private ScheduledDelegate? importSchedule; private void fileDrop(string[] filePaths) { 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"); @@ -166,5 +183,30 @@ namespace osu.Desktop Task.Factory.StartNew(() => Import(paths), TaskCreationOptions.LongRunning); } } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + osuSchemeLinkIPCChannel?.Dispose(); + archiveImportIPCChannel?.Dispose(); + } + + private class SDL2BatteryInfo : BatteryInfo + { + public override double? ChargeLevel + { + get + { + SDL.SDL_GetPowerInfo(out _, out int percentage); + + if (percentage == -1) + return null; + + return percentage / 100.0; + } + } + + public override bool OnBattery => SDL.SDL_GetPowerInfo(out _, out _) == SDL.SDL_PowerState.SDL_POWERSTATE_ON_BATTERY; + } } } diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index 405f0a8006..5a1373e040 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.cs @@ -9,24 +9,50 @@ using osu.Framework; using osu.Framework.Development; using osu.Framework.Logging; using osu.Framework.Platform; +using osu.Game; using osu.Game.IPC; using osu.Game.Tournament; +using SDL2; using Squirrel; namespace osu.Desktop { public static class Program { +#if DEBUG + private const string base_game_name = @"osu-development"; +#else private const string base_game_name = @"osu"; +#endif - private static LegacyTcpIpcProvider legacyIpc; + private static LegacyTcpIpcProvider? legacyIpc; [STAThread] public static void Main(string[] args) { // run Squirrel first, as the app may exit after these run if (OperatingSystem.IsWindows()) + { + var windowsVersion = Environment.OSVersion.Version; + + // While .NET 6 still supports Windows 7 and above, we are limited by realm currently, as they choose to only support 8.1 and higher. + // See https://www.mongodb.com/docs/realm/sdk/dotnet/#supported-platforms + if (windowsVersion.Major < 6 || (windowsVersion.Major == 6 && windowsVersion.Minor <= 2)) + { + // If users running in compatibility mode becomes more of a common thing, we may want to provide better guidance or even consider + // disabling it ourselves. + // We could also better detect compatibility mode if required: + // https://stackoverflow.com/questions/10744651/how-i-can-detect-if-my-application-is-running-under-compatibility-mode#comment58183249_10744730 + SDL.SDL_ShowSimpleMessageBox(SDL.SDL_MessageBoxFlags.SDL_MESSAGEBOX_ERROR, + "Your operating system is too old to run osu!", + "This version of osu! requires at least Windows 8.1 to run.\n" + + "Please upgrade your operating system or consider using an older version of osu!.\n\n" + + "If you are running a newer version of windows, please check you don't have \"Compatibility mode\" turned on for osu!", IntPtr.Zero); + return; + } + setupSquirrel(); + } // Back up the cwd before DesktopGameHost changes it string cwd = Environment.CurrentDirectory; @@ -63,19 +89,8 @@ namespace osu.Desktop { if (!host.IsPrimaryInstance) { - if (args.Length > 0 && args[0].Contains('.')) // easy way to check for a file import in args - { - var importer = new ArchiveImportIPCChannel(host); - - foreach (string file in args) - { - Console.WriteLine(@"Importing {0}", file); - if (!importer.ImportAsync(Path.GetFullPath(file, cwd)).Wait(3000)) - throw new TimeoutException(@"IPC took too long to send"); - } - + if (trySendIPCMessage(host, cwd, args)) return; - } // we want to allow multiple instances to be started when in debug. if (!DebugUtils.IsDebugBuild) @@ -106,18 +121,49 @@ namespace osu.Desktop } } + private static bool trySendIPCMessage(IIpcHost host, string cwd, string[] args) + { + if (args.Length == 1 && args[0].StartsWith(OsuGameBase.OSU_PROTOCOL, StringComparison.Ordinal)) + { + var osuSchemeLinkHandler = new OsuSchemeLinkIPCChannel(host); + if (!osuSchemeLinkHandler.HandleLinkAsync(args[0]).Wait(3000)) + throw new IPCTimeoutException(osuSchemeLinkHandler.GetType()); + + return true; + } + + if (args.Length > 0 && args[0].Contains('.')) // easy way to check for a file import in args + { + var importer = new ArchiveImportIPCChannel(host); + + foreach (string file in args) + { + Console.WriteLine(@"Importing {0}", file); + if (!importer.ImportAsync(Path.GetFullPath(file, cwd)).Wait(3000)) + throw new IPCTimeoutException(importer.GetType()); + } + + return true; + } + + return false; + } + [SupportedOSPlatform("windows")] private static void setupSquirrel() { - SquirrelAwareApp.HandleEvents(onInitialInstall: (version, tools) => + SquirrelAwareApp.HandleEvents(onInitialInstall: (_, tools) => { tools.CreateShortcutForThisExe(); tools.CreateUninstallerRegistryEntry(); - }, onAppUninstall: (version, tools) => + }, onAppUpdate: (_, tools) => + { + tools.CreateUninstallerRegistryEntry(); + }, onAppUninstall: (_, tools) => { tools.RemoveShortcutForThisExe(); tools.RemoveUninstallerRegistryEntry(); - }, onEveryRun: (version, tools, firstRun) => + }, onEveryRun: (_, _, _) => { // While setting the `ProcessAppUserModelId` fixes duplicate icons/shortcuts on the taskbar, it currently // causes the right-click context menu to function incorrectly. diff --git a/osu.Desktop/Security/ElevatedPrivilegesChecker.cs b/osu.Desktop/Security/ElevatedPrivilegesChecker.cs index ba37a14442..6665733656 100644 --- a/osu.Desktop/Security/ElevatedPrivilegesChecker.cs +++ b/osu.Desktop/Security/ElevatedPrivilegesChecker.cs @@ -16,10 +16,10 @@ namespace osu.Desktop.Security /// /// Checks if the game is running with elevated privileges (as admin in Windows, root in Unix) and displays a warning notification if so. /// - public class ElevatedPrivilegesChecker : Component + public partial class ElevatedPrivilegesChecker : Component { [Resolved] - private INotificationOverlay notifications { get; set; } + private INotificationOverlay notifications { get; set; } = null!; private bool elevated; @@ -63,7 +63,7 @@ namespace osu.Desktop.Security return false; } - private class ElevatedPrivilegesNotification : SimpleNotification + private partial class ElevatedPrivilegesNotification : SimpleNotification { public override bool IsImportant => true; @@ -76,7 +76,7 @@ namespace osu.Desktop.Security private void load(OsuColour colours) { Icon = FontAwesome.Solid.ShieldAlt; - IconBackground.Colour = colours.YellowDark; + IconContent.Colour = colours.YellowDark; } } } diff --git a/osu.Desktop/Updater/SquirrelUpdateManager.cs b/osu.Desktop/Updater/SquirrelUpdateManager.cs index c09cce1235..3d4db88471 100644 --- a/osu.Desktop/Updater/SquirrelUpdateManager.cs +++ b/osu.Desktop/Updater/SquirrelUpdateManager.cs @@ -5,29 +5,24 @@ using System; using System.Runtime.Versioning; using System.Threading.Tasks; using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; using osu.Framework.Logging; using osu.Game; -using osu.Game.Graphics; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; -using osuTK; -using osuTK.Graphics; using Squirrel; using Squirrel.SimpleSplat; +using LogLevel = Squirrel.SimpleSplat.LogLevel; +using UpdateManager = osu.Game.Updater.UpdateManager; namespace osu.Desktop.Updater { [SupportedOSPlatform("windows")] - public class SquirrelUpdateManager : osu.Game.Updater.UpdateManager + public partial class SquirrelUpdateManager : UpdateManager { - private UpdateManager updateManager; - private INotificationOverlay notificationOverlay; + private Squirrel.UpdateManager? updateManager; + private INotificationOverlay notificationOverlay = null!; - public Task PrepareUpdateAsync() => UpdateManager.RestartAppWhenExited(); + public Task PrepareUpdateAsync() => Squirrel.UpdateManager.RestartAppWhenExited(); private static readonly Logger logger = Logger.GetLogger("updater"); @@ -38,6 +33,9 @@ namespace osu.Desktop.Updater private readonly SquirrelLogger squirrelLogger = new SquirrelLogger(); + [Resolved] + private OsuGameBase game { get; set; } = null!; + [BackgroundDependencyLoader] private void load(INotificationOverlay notifications) { @@ -48,12 +46,12 @@ namespace osu.Desktop.Updater protected override async Task PerformUpdateCheck() => await checkForUpdateAsync().ConfigureAwait(false); - private async Task checkForUpdateAsync(bool useDeltaPatching = true, UpdateProgressNotification notification = null) + private async Task checkForUpdateAsync(bool useDeltaPatching = true, UpdateProgressNotification? notification = null) { // should we schedule a retry on completion of this check? bool scheduleRecheck = true; - const string github_token = null; // TODO: populate. + const string? github_token = null; // TODO: populate. try { @@ -66,7 +64,14 @@ namespace osu.Desktop.Updater if (updatePending) { // the user may have dismissed the completion notice, so show it again. - notificationOverlay.Post(new UpdateCompleteNotification(this)); + notificationOverlay.Post(new UpdateApplicationCompleteNotification + { + Activated = () => + { + restartToApplyUpdate(); + return true; + }, + }); return true; } @@ -78,19 +83,21 @@ namespace osu.Desktop.Updater if (notification == null) { - notification = new UpdateProgressNotification(this) { State = ProgressNotificationState.Active }; + notification = new UpdateProgressNotification + { + CompletionClickAction = restartToApplyUpdate, + }; + Schedule(() => notificationOverlay.Post(notification)); } - notification.Progress = 0; - notification.Text = @"Downloading update..."; + notification.StartDownload(); try { await updateManager.DownloadReleases(info.ReleasesToApply, p => notification.Progress = p / 100f).ConfigureAwait(false); - notification.Progress = 0; - notification.Text = @"Installing update..."; + notification.StartInstall(); await updateManager.ApplyReleases(info, p => notification.Progress = p / 100f).ConfigureAwait(false); @@ -110,9 +117,7 @@ namespace osu.Desktop.Updater else { // In the case of an error, a separate notification will be displayed. - notification.State = ProgressNotificationState.Cancelled; - notification.Close(); - + notification.FailDownload(); Logger.Error(e, @"update failed!"); } } @@ -134,84 +139,24 @@ namespace osu.Desktop.Updater return true; } + private bool restartToApplyUpdate() + { + PrepareUpdateAsync() + .ContinueWith(_ => Schedule(() => game.AttemptExit())); + return true; + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); updateManager?.Dispose(); } - private class UpdateCompleteNotification : ProgressCompletionNotification - { - [Resolved] - private OsuGame game { get; set; } - - public UpdateCompleteNotification(SquirrelUpdateManager updateManager) - { - Text = @"Update ready to install. Click to restart!"; - - Activated = () => - { - updateManager.PrepareUpdateAsync() - .ContinueWith(_ => updateManager.Schedule(() => game?.GracefullyExit())); - return true; - }; - } - } - - private class UpdateProgressNotification : ProgressNotification - { - private readonly SquirrelUpdateManager updateManager; - - public UpdateProgressNotification(SquirrelUpdateManager updateManager) - { - this.updateManager = updateManager; - } - - protected override Notification CreateCompletionNotification() - { - return new UpdateCompleteNotification(updateManager); - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - IconContent.AddRange(new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(colours.YellowDark, colours.Yellow) - }, - new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Icon = FontAwesome.Solid.Upload, - Colour = Color4.White, - Size = new Vector2(20), - } - }); - } - - public override void Close() - { - // cancelling updates is not currently supported by the underlying updater. - // only allow dismissing for now. - - switch (State) - { - case ProgressNotificationState.Cancelled: - base.Close(); - break; - } - } - } - private class SquirrelLogger : ILogger, IDisposable { - public Squirrel.SimpleSplat.LogLevel Level { get; set; } = Squirrel.SimpleSplat.LogLevel.Info; + public LogLevel Level { get; set; } = LogLevel.Info; - public void Write(string message, Squirrel.SimpleSplat.LogLevel logLevel) + public void Write(string message, LogLevel logLevel) { if (logLevel < Level) return; diff --git a/osu.Desktop/Windows/GameplayWinKeyBlocker.cs b/osu.Desktop/Windows/GameplayWinKeyBlocker.cs index 4acaf61cea..560f6fdd7f 100644 --- a/osu.Desktop/Windows/GameplayWinKeyBlocker.cs +++ b/osu.Desktop/Windows/GameplayWinKeyBlocker.cs @@ -10,14 +10,14 @@ using osu.Game.Screens.Play; namespace osu.Desktop.Windows { - public class GameplayWinKeyBlocker : Component + public partial class GameplayWinKeyBlocker : Component { - private Bindable disableWinKey; - private IBindable localUserPlaying; - private IBindable isActive; + private Bindable disableWinKey = null!; + private IBindable localUserPlaying = null!; + private IBindable isActive = null!; [Resolved] - private GameHost host { get; set; } + private GameHost host { get; set; } = null!; [BackgroundDependencyLoader] private void load(ILocalUserPlayInfo localUserInfo, OsuConfigManager config) diff --git a/osu.Desktop/Windows/WindowsKey.cs b/osu.Desktop/Windows/WindowsKey.cs index fdca2028d3..1051e61f2f 100644 --- a/osu.Desktop/Windows/WindowsKey.cs +++ b/osu.Desktop/Windows/WindowsKey.cs @@ -19,7 +19,7 @@ namespace osu.Desktop.Windows private const int wm_syskeyup = 261; //Resharper disable once NotAccessedField.Local - private static LowLevelKeyboardProcDelegate keyboardHookDelegate; // keeping a reference alive for the GC + private static LowLevelKeyboardProcDelegate? keyboardHookDelegate; // keeping a reference alive for the GC private static IntPtr keyHook; [StructLayout(LayoutKind.Explicit)] diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj index a4f9e2671b..f1b9c92429 100644 --- a/osu.Desktop/osu.Desktop.csproj +++ b/osu.Desktop/osu.Desktop.csproj @@ -24,15 +24,10 @@ - + - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/osu.Game.Benchmarks/BenchmarkCarouselFilter.cs b/osu.Game.Benchmarks/BenchmarkCarouselFilter.cs new file mode 100644 index 0000000000..8f7027da17 --- /dev/null +++ b/osu.Game.Benchmarks/BenchmarkCarouselFilter.cs @@ -0,0 +1,123 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using BenchmarkDotNet.Attributes; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Carousel; + +namespace osu.Game.Benchmarks +{ + public class BenchmarkCarouselFilter : BenchmarkTest + { + private BeatmapInfo getExampleBeatmap() => new BeatmapInfo + { + Ruleset = new RulesetInfo + { + ShortName = "osu", + OnlineID = 0 + }, + StarRating = 4.0d, + Difficulty = new BeatmapDifficulty + { + ApproachRate = 5.0f, + DrainRate = 3.0f, + CircleSize = 2.0f, + }, + Metadata = new BeatmapMetadata + { + Artist = "The Artist", + ArtistUnicode = "check unicode too", + Title = "Title goes here", + TitleUnicode = "Title goes here", + Author = { Username = "The Author" }, + Source = "unit tests", + Tags = "look for tags too", + }, + DifficultyName = "version as well", + Length = 2500, + BPM = 160, + BeatDivisor = 12, + Status = BeatmapOnlineStatus.Loved + }; + + private CarouselBeatmap carouselBeatmap = null!; + private FilterCriteria criteria1 = null!; + private FilterCriteria criteria2 = null!; + private FilterCriteria criteria3 = null!; + private FilterCriteria criteria4 = null!; + private FilterCriteria criteria5 = null!; + private FilterCriteria criteria6 = null!; + + public override void SetUp() + { + var beatmap = getExampleBeatmap(); + beatmap.OnlineID = 20201010; + beatmap.BeatmapSet = new BeatmapSetInfo { OnlineID = 1535 }; + carouselBeatmap = new CarouselBeatmap(beatmap); + criteria1 = new FilterCriteria(); + criteria2 = new FilterCriteria + { + Ruleset = new RulesetInfo { ShortName = "catch" } + }; + criteria3 = new FilterCriteria + { + Ruleset = new RulesetInfo { OnlineID = 6 }, + AllowConvertedBeatmaps = true, + BPM = new FilterCriteria.OptionalRange + { + IsUpperInclusive = false, + Max = 160d + } + }; + criteria4 = new FilterCriteria + { + Ruleset = new RulesetInfo { OnlineID = 6 }, + AllowConvertedBeatmaps = true, + SearchText = "an artist" + }; + criteria5 = new FilterCriteria + { + Creator = new FilterCriteria.OptionalTextFilter { SearchTerm = "the author AND then something else" } + }; + criteria6 = new FilterCriteria { SearchText = "20201010" }; + } + + [Benchmark] + public void CarouselBeatmapFilter() + { + carouselBeatmap.Filter(criteria1); + } + + [Benchmark] + public void CriteriaMatchingSpecificRuleset() + { + carouselBeatmap.Filter(criteria2); + } + + [Benchmark] + public void CriteriaMatchingRangeMax() + { + carouselBeatmap.Filter(criteria3); + } + + [Benchmark] + public void CriteriaMatchingTerms() + { + carouselBeatmap.Filter(criteria4); + } + + [Benchmark] + public void CriteriaMatchingCreator() + { + carouselBeatmap.Filter(criteria5); + } + + [Benchmark] + public void CriteriaMatchingBeatmapIDs() + { + carouselBeatmap.Filter(criteria6); + } + } +} diff --git a/osu.Game.Benchmarks/BenchmarkHitObject.cs b/osu.Game.Benchmarks/BenchmarkHitObject.cs new file mode 100644 index 0000000000..65c78e39b3 --- /dev/null +++ b/osu.Game.Benchmarks/BenchmarkHitObject.cs @@ -0,0 +1,166 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using BenchmarkDotNet.Attributes; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Benchmarks +{ + public class BenchmarkHitObject : BenchmarkTest + { + [Params(1, 100, 1000)] + public int Count { get; set; } + + [Params(false, true)] + public bool WithBindableAccess { get; set; } + + [Benchmark] + public HitCircle[] OsuCircle() + { + var circles = new HitCircle[Count]; + + for (int i = 0; i < Count; i++) + { + circles[i] = new HitCircle(); + + if (WithBindableAccess) + { + _ = circles[i].PositionBindable; + _ = circles[i].ScaleBindable; + _ = circles[i].ComboIndexBindable; + _ = circles[i].ComboOffsetBindable; + _ = circles[i].StackHeightBindable; + _ = circles[i].LastInComboBindable; + _ = circles[i].ComboIndexWithOffsetsBindable; + _ = circles[i].IndexInCurrentComboBindable; + _ = circles[i].SamplesBindable; + _ = circles[i].StartTimeBindable; + } + else + { + _ = circles[i].Position; + _ = circles[i].Scale; + _ = circles[i].ComboIndex; + _ = circles[i].ComboOffset; + _ = circles[i].StackHeight; + _ = circles[i].LastInCombo; + _ = circles[i].ComboIndexWithOffsets; + _ = circles[i].IndexInCurrentCombo; + _ = circles[i].Samples; + _ = circles[i].StartTime; + _ = circles[i].Position; + _ = circles[i].Scale; + _ = circles[i].ComboIndex; + _ = circles[i].ComboOffset; + _ = circles[i].StackHeight; + _ = circles[i].LastInCombo; + _ = circles[i].ComboIndexWithOffsets; + _ = circles[i].IndexInCurrentCombo; + _ = circles[i].Samples; + _ = circles[i].StartTime; + } + } + + return circles; + } + + [Benchmark] + public Hit[] TaikoHit() + { + var hits = new Hit[Count]; + + for (int i = 0; i < Count; i++) + { + hits[i] = new Hit(); + + if (WithBindableAccess) + { + _ = hits[i].TypeBindable; + _ = hits[i].IsStrongBindable; + _ = hits[i].SamplesBindable; + _ = hits[i].StartTimeBindable; + } + else + { + _ = hits[i].Type; + _ = hits[i].IsStrong; + _ = hits[i].Samples; + _ = hits[i].StartTime; + } + } + + return hits; + } + + [Benchmark] + public Fruit[] CatchFruit() + { + var fruit = new Fruit[Count]; + + for (int i = 0; i < Count; i++) + { + fruit[i] = new Fruit(); + + if (WithBindableAccess) + { + _ = fruit[i].OriginalXBindable; + _ = fruit[i].XOffsetBindable; + _ = fruit[i].ScaleBindable; + _ = fruit[i].ComboIndexBindable; + _ = fruit[i].HyperDashBindable; + _ = fruit[i].LastInComboBindable; + _ = fruit[i].ComboIndexWithOffsetsBindable; + _ = fruit[i].IndexInCurrentComboBindable; + _ = fruit[i].IndexInBeatmapBindable; + _ = fruit[i].SamplesBindable; + _ = fruit[i].StartTimeBindable; + } + else + { + _ = fruit[i].OriginalX; + _ = fruit[i].XOffset; + _ = fruit[i].Scale; + _ = fruit[i].ComboIndex; + _ = fruit[i].HyperDash; + _ = fruit[i].LastInCombo; + _ = fruit[i].ComboIndexWithOffsets; + _ = fruit[i].IndexInCurrentCombo; + _ = fruit[i].IndexInBeatmap; + _ = fruit[i].Samples; + _ = fruit[i].StartTime; + } + } + + return fruit; + } + + [Benchmark] + public Note[] ManiaNote() + { + var notes = new Note[Count]; + + for (int i = 0; i < Count; i++) + { + notes[i] = new Note(); + + if (WithBindableAccess) + { + _ = notes[i].ColumnBindable; + _ = notes[i].SamplesBindable; + _ = notes[i].StartTimeBindable; + } + else + { + _ = notes[i].Column; + _ = notes[i].Samples; + _ = notes[i].StartTime; + } + } + + return notes; + } + } +} diff --git a/osu.Game.Benchmarks/BenchmarkMod.cs b/osu.Game.Benchmarks/BenchmarkMod.cs index c5375e9f09..994300df36 100644 --- a/osu.Game.Benchmarks/BenchmarkMod.cs +++ b/osu.Game.Benchmarks/BenchmarkMod.cs @@ -9,7 +9,7 @@ namespace osu.Game.Benchmarks { public class BenchmarkMod : BenchmarkTest { - private OsuModDoubleTime mod; + private OsuModDoubleTime mod = null!; [Params(1, 10, 100)] public int Times { get; set; } diff --git a/osu.Game.Benchmarks/BenchmarkRealmReads.cs b/osu.Game.Benchmarks/BenchmarkRealmReads.cs index 615e2e964d..1df77320d2 100644 --- a/osu.Game.Benchmarks/BenchmarkRealmReads.cs +++ b/osu.Game.Benchmarks/BenchmarkRealmReads.cs @@ -15,9 +15,9 @@ namespace osu.Game.Benchmarks { public class BenchmarkRealmReads : BenchmarkTest { - private TemporaryNativeStorage storage; - private RealmAccess realm; - private UpdateThread updateThread; + private TemporaryNativeStorage storage = null!; + private RealmAccess realm = null!; + private UpdateThread updateThread = null!; [Params(1, 100, 1000)] public int ReadsPerFetch { get; set; } @@ -29,7 +29,7 @@ namespace osu.Game.Benchmarks realm = new RealmAccess(storage, OsuGameBase.CLIENT_DATABASE_FILENAME); - realm.Run(r => + realm.Run(_ => { realm.Write(c => c.Add(TestResources.CreateTestBeatmapSetInfo(rulesets: new[] { new OsuRuleset().RulesetInfo }))); }); @@ -74,7 +74,7 @@ namespace osu.Game.Benchmarks } }); - done.Wait(); + done.Wait(60000); } [Benchmark] @@ -113,7 +113,7 @@ namespace osu.Game.Benchmarks } }); - done.Wait(); + done.Wait(60000); } [Benchmark] @@ -133,9 +133,9 @@ namespace osu.Game.Benchmarks [GlobalCleanup] public void Cleanup() { - realm?.Dispose(); - storage?.Dispose(); - updateThread?.Exit(); + realm.Dispose(); + storage.Dispose(); + updateThread.Exit(); } } } diff --git a/osu.Game.Benchmarks/BenchmarkRuleset.cs b/osu.Game.Benchmarks/BenchmarkRuleset.cs index 2835ec9499..7d318e043b 100644 --- a/osu.Game.Benchmarks/BenchmarkRuleset.cs +++ b/osu.Game.Benchmarks/BenchmarkRuleset.cs @@ -11,9 +11,9 @@ namespace osu.Game.Benchmarks { public class BenchmarkRuleset : BenchmarkTest { - private OsuRuleset ruleset; - private APIMod apiModDoubleTime; - private APIMod apiModDifficultyAdjust; + private OsuRuleset ruleset = null!; + private APIMod apiModDoubleTime = null!; + private APIMod apiModDifficultyAdjust = null!; public override void SetUp() { diff --git a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj index 36ffd3b5b6..4719d54138 100644 --- a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj +++ b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj @@ -7,9 +7,9 @@ - + - + diff --git a/osu.Game.Rulesets.Catch.Tests.Android/Properties/AndroidManifest.xml b/osu.Game.Rulesets.Catch.Tests.Android/AndroidManifest.xml similarity index 96% rename from osu.Game.Rulesets.Catch.Tests.Android/Properties/AndroidManifest.xml rename to osu.Game.Rulesets.Catch.Tests.Android/AndroidManifest.xml index f8c3fcd894..bf7c0bfeca 100644 --- a/osu.Game.Rulesets.Catch.Tests.Android/Properties/AndroidManifest.xml +++ b/osu.Game.Rulesets.Catch.Tests.Android/AndroidManifest.xml @@ -1,6 +1,6 @@  - + \ No newline at end of file diff --git a/osu.Game.Rulesets.Catch.Tests.Android/MainActivity.cs b/osu.Game.Rulesets.Catch.Tests.Android/MainActivity.cs index d8b729576d..64c71c9ecd 100644 --- a/osu.Game.Rulesets.Catch.Tests.Android/MainActivity.cs +++ b/osu.Game.Rulesets.Catch.Tests.Android/MainActivity.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Android.App; using osu.Framework.Android; using osu.Game.Tests; diff --git a/osu.Game.Rulesets.Catch.Tests.Android/osu.Game.Rulesets.Catch.Tests.Android.csproj b/osu.Game.Rulesets.Catch.Tests.Android/osu.Game.Rulesets.Catch.Tests.Android.csproj index 94fdba4a3e..4ee3219442 100644 --- a/osu.Game.Rulesets.Catch.Tests.Android/osu.Game.Rulesets.Catch.Tests.Android.csproj +++ b/osu.Game.Rulesets.Catch.Tests.Android/osu.Game.Rulesets.Catch.Tests.Android.csproj @@ -1,49 +1,24 @@ - - + - Debug - AnyCPU - 8.0.30703 - 2.0 - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D} - {EFBA0AD7-5A72-4C68-AF49-83D382785DCF};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - {122416d6-6b49-4ee2-a1e8-b825f31c79fe} + net6.0-android + Exe osu.Game.Rulesets.Catch.Tests osu.Game.Rulesets.Catch.Tests.Android - Properties\AndroidManifest.xml - armeabi-v7a;x86;arm64-v8a - - - None - cjk;mideast;other;rare;west - true - - - - - - - + %(RecursiveDir)%(Filename)%(Extension) + + + %(RecursiveDir)%(Filename)%(Extension) + Android\%(RecursiveDir)%(Filename)%(Extension) + - - {58f6c80c-1253-4a0e-a465-b8c85ebeadf3} - osu.Game.Rulesets.Catch - - - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D} - osu.Game - + + - - - 5.0.0 - - - \ No newline at end of file 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 39fe3dac25..0000000000 --- a/osu.Game.Rulesets.Catch.Tests.iOS/AppDelegate.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using 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 e44868e133..d097c6a698 100644 --- a/osu.Game.Rulesets.Catch.Tests.iOS/Application.cs +++ b/osu.Game.Rulesets.Catch.Tests.iOS/Application.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.iOS; -using UIKit; +using osu.Game.Tests; namespace osu.Game.Rulesets.Catch.Tests.iOS { @@ -10,7 +10,7 @@ namespace osu.Game.Rulesets.Catch.Tests.iOS { public static void Main(string[] args) { - UIApplication.Main(args, typeof(GameUIApplication), typeof(AppDelegate)); + GameApplication.Main(new OsuTestBrowser()); } } } diff --git a/osu.Game.Rulesets.Catch.Tests.iOS/Info.plist b/osu.Game.Rulesets.Catch.Tests.iOS/Info.plist index 16a2b99997..5ace6c07f5 100644 --- a/osu.Game.Rulesets.Catch.Tests.iOS/Info.plist +++ b/osu.Game.Rulesets.Catch.Tests.iOS/Info.plist @@ -13,7 +13,7 @@ LSRequiresIPhoneOS MinimumOSVersion - 10.0 + 13.4 UIDeviceFamily 1 diff --git a/osu.Game.Rulesets.Catch.Tests.iOS/osu.Game.Rulesets.Catch.Tests.iOS.csproj b/osu.Game.Rulesets.Catch.Tests.iOS/osu.Game.Rulesets.Catch.Tests.iOS.csproj index be6044bbd0..acf12bb0ac 100644 --- a/osu.Game.Rulesets.Catch.Tests.iOS/osu.Game.Rulesets.Catch.Tests.iOS.csproj +++ b/osu.Game.Rulesets.Catch.Tests.iOS/osu.Game.Rulesets.Catch.Tests.iOS.csproj @@ -1,35 +1,19 @@ - - + - Debug - iPhoneSimulator - {4004C7B7-1A62-43F1-9DF2-52450FA67E70} Exe + net6.0-ios + 13.4 osu.Game.Rulesets.Catch.Tests osu.Game.Rulesets.Catch.Tests.iOS - - - - Linker.xml - - - %(RecursiveDir)%(Filename)%(Extension) - - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D} - osu.Game - - - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3} - osu.Game.Rulesets.Catch - + + - - \ No newline at end of file + diff --git a/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs index baca8166d1..b6cb351c1e 100644 --- a/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using Newtonsoft.Json; diff --git a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs index 48d46636df..cf030f6e13 100644 --- a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Difficulty; diff --git a/osu.Game.Rulesets.Catch.Tests/CatchLegacyModConversionTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchLegacyModConversionTest.cs index eae07daa3d..b9d6f28228 100644 --- a/osu.Game.Rulesets.Catch.Tests/CatchLegacyModConversionTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/CatchLegacyModConversionTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Game.Beatmaps.Legacy; @@ -22,21 +24,24 @@ namespace osu.Game.Rulesets.Catch.Tests new object[] { LegacyMods.DoubleTime, new[] { typeof(CatchModDoubleTime) } }, new object[] { LegacyMods.Relax, new[] { typeof(CatchModRelax) } }, new object[] { LegacyMods.HalfTime, new[] { typeof(CatchModHalfTime) } }, - new object[] { LegacyMods.Nightcore, new[] { typeof(CatchModNightcore) } }, new object[] { LegacyMods.Flashlight, new[] { typeof(CatchModFlashlight) } }, new object[] { LegacyMods.Autoplay, new[] { typeof(CatchModAutoplay) } }, - new object[] { LegacyMods.Perfect, new[] { typeof(CatchModPerfect) } }, - new object[] { LegacyMods.Cinema, new[] { typeof(CatchModCinema) } }, new object[] { LegacyMods.HardRock | LegacyMods.DoubleTime, new[] { typeof(CatchModHardRock), typeof(CatchModDoubleTime) } } }; + [TestCaseSource(nameof(catch_mod_mapping))] + [TestCase(LegacyMods.Cinema, new[] { typeof(CatchModCinema) })] + [TestCase(LegacyMods.Cinema | LegacyMods.Autoplay, new[] { typeof(CatchModCinema) })] + [TestCase(LegacyMods.Nightcore, new[] { typeof(CatchModNightcore) })] + [TestCase(LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(CatchModNightcore) })] + [TestCase(LegacyMods.Perfect, new[] { typeof(CatchModPerfect) })] + [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath, new[] { typeof(CatchModPerfect) })] + public new void TestFromLegacy(LegacyMods legacyMods, Type[] expectedMods) => base.TestFromLegacy(legacyMods, expectedMods); + [TestCaseSource(nameof(catch_mod_mapping))] [TestCase(LegacyMods.Cinema | LegacyMods.Autoplay, new[] { typeof(CatchModCinema) })] [TestCase(LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(CatchModNightcore) })] [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath, new[] { typeof(CatchModPerfect) })] - public new void TestFromLegacy(LegacyMods legacyMods, Type[] expectedMods) => base.TestFromLegacy(legacyMods, expectedMods); - - [TestCaseSource(nameof(catch_mod_mapping))] public new void TestToLegacy(LegacyMods legacyMods, Type[] givenMods) => base.TestToLegacy(legacyMods, givenMods); protected override Ruleset CreateRuleset() => new CatchRuleset(); diff --git a/osu.Game.Rulesets.Catch.Tests/CatchSkinColourDecodingTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchSkinColourDecodingTest.cs index bb3a724b91..f30b216d8d 100644 --- a/osu.Game.Rulesets.Catch.Tests/CatchSkinColourDecodingTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/CatchSkinColourDecodingTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.IO.Stores; using osu.Game.Rulesets.Catch.Skinning; diff --git a/osu.Game.Rulesets.Catch.Tests/CatchSkinnableTestScene.cs b/osu.Game.Rulesets.Catch.Tests/CatchSkinnableTestScene.cs index 378772fea3..2af851a561 100644 --- a/osu.Game.Rulesets.Catch.Tests/CatchSkinnableTestScene.cs +++ b/osu.Game.Rulesets.Catch.Tests/CatchSkinnableTestScene.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Catch.Tests { - public abstract class CatchSkinnableTestScene : SkinnableTestScene + public abstract partial class CatchSkinnableTestScene : SkinnableTestScene { protected override Ruleset CreateRulesetForSkinProvider() => new CatchRuleset(); } diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/CatchEditorTestSceneContainer.cs b/osu.Game.Rulesets.Catch.Tests/Editor/CatchEditorTestSceneContainer.cs index 158c8edba5..39508359a4 100644 --- a/osu.Game.Rulesets.Catch.Tests/Editor/CatchEditorTestSceneContainer.cs +++ b/osu.Game.Rulesets.Catch.Tests/Editor/CatchEditorTestSceneContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -14,7 +16,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Catch.Tests.Editor { - public class CatchEditorTestSceneContainer : Container + public partial class CatchEditorTestSceneContainer : Container { [Cached(typeof(Playfield))] public readonly ScrollingPlayfield Playfield; @@ -55,7 +57,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor }; } - private class TestCatchPlayfield : CatchEditorPlayfield + private partial class TestCatchPlayfield : CatchEditorPlayfield { public TestCatchPlayfield() : base(new BeatmapDifficulty { CircleSize = 0 }) diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/CatchPlacementBlueprintTestScene.cs b/osu.Game.Rulesets.Catch.Tests/Editor/CatchPlacementBlueprintTestScene.cs index 1d30ae34cd..aae759d934 100644 --- a/osu.Game.Rulesets.Catch.Tests/Editor/CatchPlacementBlueprintTestScene.cs +++ b/osu.Game.Rulesets.Catch.Tests/Editor/CatchPlacementBlueprintTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using NUnit.Framework; @@ -19,7 +21,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Catch.Tests.Editor { - public abstract class CatchPlacementBlueprintTestScene : PlacementBlueprintTestScene + public abstract partial class CatchPlacementBlueprintTestScene : PlacementBlueprintTestScene { protected const double TIME_SNAP = 100; diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/CatchSelectionBlueprintTestScene.cs b/osu.Game.Rulesets.Catch.Tests/Editor/CatchSelectionBlueprintTestScene.cs index 88fd3b36ba..033dca587e 100644 --- a/osu.Game.Rulesets.Catch.Tests/Editor/CatchSelectionBlueprintTestScene.cs +++ b/osu.Game.Rulesets.Catch.Tests/Editor/CatchSelectionBlueprintTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -15,7 +17,7 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Tests.Editor { - public abstract class CatchSelectionBlueprintTestScene : SelectionBlueprintTestScene + public abstract partial class CatchSelectionBlueprintTestScene : SelectionBlueprintTestScene { protected ScrollingHitObjectContainer HitObjectContainer => contentContainer.Playfield.HitObjectContainer; @@ -60,7 +62,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor InputManager.MoveMouseTo(pos); }); - private class EditorBeatmapDependencyContainer : Container + private partial class EditorBeatmapDependencyContainer : Container { [Cached] private readonly EditorClock editorClock; @@ -68,10 +70,17 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor [Cached] private readonly BindableBeatDivisor beatDivisor; + protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; + public EditorBeatmapDependencyContainer(IBeatmap beatmap, BindableBeatDivisor beatDivisor) { - editorClock = new EditorClock(beatmap, beatDivisor); this.beatDivisor = beatDivisor; + + InternalChildren = new Drawable[] + { + editorClock = new EditorClock(beatmap, beatDivisor), + Content, + }; } } } diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/Checks/TestCheckBananaShowerGap.cs b/osu.Game.Rulesets.Catch.Tests/Editor/Checks/TestCheckBananaShowerGap.cs index 055c8429d7..ef34a5d664 100644 --- a/osu.Game.Rulesets.Catch.Tests/Editor/Checks/TestCheckBananaShowerGap.cs +++ b/osu.Game.Rulesets.Catch.Tests/Editor/Checks/TestCheckBananaShowerGap.cs @@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor.Checks [TestFixture] public class TestCheckBananaShowerGap { - private CheckBananaShowerGap check; + private CheckBananaShowerGap check = null!; [SetUp] public void Setup() diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneBananaShowerPlacementBlueprint.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneBananaShowerPlacementBlueprint.cs index fec253924f..2db4102513 100644 --- a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneBananaShowerPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneBananaShowerPlacementBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Testing; @@ -18,7 +20,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Catch.Tests.Editor { - public class TestSceneBananaShowerPlacementBlueprint : CatchPlacementBlueprintTestScene + public partial class TestSceneBananaShowerPlacementBlueprint : CatchPlacementBlueprintTestScene { protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableBananaShower((BananaShower)hitObject); diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneCatchDistanceSnapGrid.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneCatchDistanceSnapGrid.cs index 2be0b7e9b2..1e057cf3fb 100644 --- a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneCatchDistanceSnapGrid.cs +++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneCatchDistanceSnapGrid.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -18,7 +20,7 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Tests.Editor { - public class TestSceneCatchDistanceSnapGrid : OsuManualInputManagerTestScene + public partial class TestSceneCatchDistanceSnapGrid : OsuManualInputManagerTestScene { private readonly ManualClock manualClock = new ManualClock(); diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneEditor.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneEditor.cs index 161c685043..5593f3d319 100644 --- a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneEditor.cs +++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneEditor.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Catch.Tests.Editor { [TestFixture] - public class TestSceneEditor : EditorTestScene + public partial class TestSceneEditor : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new CatchRuleset(); } diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneFruitPlacementBlueprint.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneFruitPlacementBlueprint.cs index 4b1c45ae2f..93b24d92fb 100644 --- a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneFruitPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneFruitPlacementBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Utils; @@ -15,7 +17,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Catch.Tests.Editor { - public class TestSceneFruitPlacementBlueprint : CatchPlacementBlueprintTestScene + public partial class TestSceneFruitPlacementBlueprint : CatchPlacementBlueprintTestScene { protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableFruit((Fruit)hitObject); diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamPlacementBlueprint.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamPlacementBlueprint.cs index b1adc4901c..18d3d29bdc 100644 --- a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamPlacementBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -17,7 +19,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Catch.Tests.Editor { - public class TestSceneJuiceStreamPlacementBlueprint : CatchPlacementBlueprintTestScene + public partial class TestSceneJuiceStreamPlacementBlueprint : CatchPlacementBlueprintTestScene { private const double velocity_factor = 0.5; diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs index 22a839d847..f25b66c360 100644 --- a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -17,7 +19,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Catch.Tests.Editor { - public class TestSceneJuiceStreamSelectionBlueprint : CatchSelectionBlueprintTestScene + public partial class TestSceneJuiceStreamSelectionBlueprint : CatchSelectionBlueprintTestScene { private JuiceStream hitObject; diff --git a/osu.Game.Rulesets.Catch.Tests/JuiceStreamPathTest.cs b/osu.Game.Rulesets.Catch.Tests/JuiceStreamPathTest.cs index 5248d5a96a..0de992c1df 100644 --- a/osu.Game.Rulesets.Catch.Tests/JuiceStreamPathTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/JuiceStreamPathTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -90,7 +92,7 @@ namespace osu.Game.Rulesets.Catch.Tests new JuiceStreamPathVertex(20, -5) })); - removeCount = path.RemoveVertices((_, i) => true); + removeCount = path.RemoveVertices((_, _) => true); Assert.That(removeCount, Is.EqualTo(1)); Assert.That(path.Vertices, Is.EqualTo(new[] { diff --git a/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModFlashlight.cs b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModFlashlight.cs new file mode 100644 index 0000000000..f89ed5ce8e --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModFlashlight.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Catch.Mods; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Catch.Tests.Mods +{ + public partial class TestSceneCatchModFlashlight : ModTestScene + { + protected override Ruleset CreatePlayerRuleset() => new CatchRuleset(); + + [TestCase(1f)] + [TestCase(0.5f)] + [TestCase(1.25f)] + [TestCase(1.5f)] + public void TestSizeMultiplier(float sizeMultiplier) => CreateModTest(new ModTestData { Mod = new CatchModFlashlight { SizeMultiplier = { Value = sizeMultiplier } }, PassCondition = () => true }); + + [Test] + public void TestComboBasedSize([Values] bool comboBasedSize) => CreateModTest(new ModTestData { Mod = new CatchModFlashlight { ComboBasedSize = { Value = comboBasedSize } }, PassCondition = () => true }); + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModNoScope.cs b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModNoScope.cs index bbe543e73e..c48bf7adc9 100644 --- a/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModNoScope.cs +++ b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModNoScope.cs @@ -14,10 +14,40 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Catch.Tests.Mods { - public class TestSceneCatchModNoScope : ModTestScene + public partial class TestSceneCatchModNoScope : ModTestScene { protected override Ruleset CreatePlayerRuleset() => new CatchRuleset(); + [Test] + public void TestAlwaysHidden() + { + CreateModTest(new ModTestData + { + Mod = new CatchModNoScope + { + HiddenComboCount = { Value = 0 }, + }, + Autoplay = true, + PassCondition = () => Player.ScoreProcessor.Combo.Value == 2, + Beatmap = new Beatmap + { + HitObjects = new List + { + new Fruit + { + X = CatchPlayfield.CENTER_X * 0.5f, + StartTime = 1000, + }, + new Fruit + { + X = CatchPlayfield.CENTER_X * 1.5f, + StartTime = 2000, + } + } + } + }); + } + [Test] public void TestVisibleDuringBreak() { diff --git a/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModPerfect.cs b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModPerfect.cs index 3e06e78dba..71df523951 100644 --- a/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModPerfect.cs +++ b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModPerfect.cs @@ -11,7 +11,7 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Tests.Mods { - public class TestSceneCatchModPerfect : ModPerfectTestScene + public partial class TestSceneCatchModPerfect : ModPerfectTestScene { protected override Ruleset CreatePlayerRuleset() => new CatchRuleset(); diff --git a/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModRelax.cs b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModRelax.cs index c01aff0aa0..5835ccaf78 100644 --- a/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModRelax.cs +++ b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModRelax.cs @@ -4,8 +4,10 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Graphics.Cursor; using osu.Game.Rulesets.Catch.Mods; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.UI; @@ -16,7 +18,7 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Tests.Mods { - public class TestSceneCatchModRelax : ModTestScene + public partial class TestSceneCatchModRelax : ModTestScene { protected override Ruleset CreatePlayerRuleset() => new CatchRuleset(); @@ -55,6 +57,21 @@ namespace osu.Game.Rulesets.Catch.Tests.Mods } }); + [Test] + public void TestGameCursorHidden() + { + CreateModTest(new ModTestData + { + Mod = new CatchModRelax(), + Autoplay = false, + PassCondition = () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + return this.ChildrenOfType().Single().State.Value == Visibility.Hidden; + } + }); + } + private bool passCondition() { var playfield = this.ChildrenOfType().Single(); diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs index 1014158fc1..40dc7d2403 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Game.Audio; @@ -14,7 +16,7 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Tests { - public class TestSceneAutoJuiceStream : TestSceneCatchPlayer + public partial class TestSceneAutoJuiceStream : TestSceneCatchPlayer { protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) { diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneBananaShower.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneBananaShower.cs index 96ac5c4bf2..402f8f548d 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneBananaShower.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneBananaShower.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; @@ -8,7 +10,7 @@ using osu.Game.Rulesets.Catch.Objects; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] - public class TestSceneBananaShower : TestSceneCatchPlayer + public partial class TestSceneBananaShower : TestSceneCatchPlayer { [Test] public void TestBananaShower() diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs index 09362929d2..05d3361dc3 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -17,7 +19,7 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Tests { - public class TestSceneCatchModHidden : ModTestScene + public partial class TestSceneCatchModHidden : ModTestScene { [Test] public void TestJuiceStream() diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayer.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayer.cs index 31d0831fae..01cce88d9d 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayer.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayer.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] - public class TestSceneCatchPlayer : PlayerTestScene + public partial class TestSceneCatchPlayer : PlayerTestScene { protected override Ruleset CreatePlayerRuleset() => new CatchRuleset(); } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayerLegacySkin.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayerLegacySkin.cs index b7cd6737b1..4c1ba33aa2 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayerLegacySkin.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayerLegacySkin.cs @@ -1,13 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics.Containers; using osu.Framework.Screens; using osu.Framework.Testing; -using osu.Game.Screens.Play.HUD; using osu.Game.Skinning; using osu.Game.Tests.Visual; using osuTK; @@ -15,7 +16,7 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] - public class TestSceneCatchPlayerLegacySkin : LegacySkinPlayerTestScene + public partial class TestSceneCatchPlayerLegacySkin : LegacySkinPlayerTestScene { protected override Ruleset CreatePlayerRuleset() => new CatchRuleset(); @@ -25,9 +26,9 @@ namespace osu.Game.Rulesets.Catch.Tests if (withModifiedSkin) { AddStep("change component scale", () => Player.ChildrenOfType().First().Scale = new Vector2(2f)); - AddStep("update target", () => Player.ChildrenOfType().ForEach(LegacySkin.UpdateDrawableTarget)); + AddStep("update target", () => Player.ChildrenOfType().ForEach(LegacySkin.UpdateDrawableTarget)); AddStep("exit player", () => Player.Exit()); - CreateTest(null); + CreateTest(); } AddAssert("legacy HUD combo counter hidden", () => diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchReplay.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchReplay.cs index a10371b0f7..cbf900ebc0 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchReplay.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchReplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -9,7 +11,7 @@ using osu.Game.Rulesets.Catch.UI; namespace osu.Game.Rulesets.Catch.Tests { - public class TestSceneCatchReplay : TestSceneCatchPlayer + public partial class TestSceneCatchReplay : TestSceneCatchPlayer { protected override bool Autoplay => true; diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchSkinConfiguration.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchSkinConfiguration.cs index 8ae2bcca0e..75ab4ad9d2 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchSkinConfiguration.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchSkinConfiguration.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Bindables; @@ -21,7 +23,7 @@ using Direction = osu.Game.Rulesets.Catch.UI.Direction; namespace osu.Game.Rulesets.Catch.Tests { - public class TestSceneCatchSkinConfiguration : OsuTestScene + public partial class TestSceneCatchSkinConfiguration : OsuTestScene { private Catcher catcher; @@ -85,12 +87,12 @@ namespace osu.Game.Rulesets.Catch.Tests }); } - private class TestSkin : DefaultSkin + private class TestSkin : TrianglesSkin { public bool FlipCatcherPlate { get; set; } public TestSkin() - : base(null) + : base(null!) { } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchStacker.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchStacker.cs index 0a4ef49e19..c8979381fe 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchStacker.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchStacker.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; @@ -9,7 +11,7 @@ using osu.Game.Rulesets.Catch.UI; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] - public class TestSceneCatchStacker : TestSceneCatchPlayer + public partial class TestSceneCatchStacker : TestSceneCatchPlayer { protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) { diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchTouchInput.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchTouchInput.cs new file mode 100644 index 0000000000..836adcc912 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchTouchInput.cs @@ -0,0 +1,66 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Rulesets.Catch.Beatmaps; +using osu.Game.Rulesets.Catch.Mods; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Mods; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Catch.Tests +{ + [TestFixture] + public partial class TestSceneCatchTouchInput : OsuTestScene + { + [Test] + public void TestBasic() + { + CatchTouchInputMapper catchTouchInputMapper = null!; + + AddStep("create input overlay", () => + { + Child = new CatchInputManager(new CatchRuleset().RulesetInfo) + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + catchTouchInputMapper = new CatchTouchInputMapper + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + } + } + }; + }); + + AddStep("show overlay", () => catchTouchInputMapper.Show()); + } + + [Test] + public void TestWithoutRelax() + { + AddStep("create drawable ruleset without relax mod", () => + { + Child = new DrawableCatchRuleset(new CatchRuleset(), new CatchBeatmap(), new List()); + }); + AddUntilStep("wait for load", () => Child.IsLoaded); + AddAssert("check touch input is shown", () => this.ChildrenOfType().Any()); + } + + [Test] + public void TestWithRelax() + { + AddStep("create drawable ruleset with relax mod", () => + { + Child = new DrawableCatchRuleset(new CatchRuleset(), new CatchBeatmap(), new List { new CatchModRelax() }); + }); + AddUntilStep("wait for load", () => Child.IsLoaded); + AddAssert("check touch input is not shown", () => !this.ChildrenOfType().Any()); + } + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs index 4f527e9a0f..f60ae29f77 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -19,14 +21,13 @@ using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; -using osu.Game.Skinning; using osu.Game.Tests.Visual; using osuTK; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] - public class TestSceneCatcher : OsuTestScene + public partial class TestSceneCatcher : OsuTestScene { [Resolved] private OsuConfigManager config { get; set; } @@ -59,26 +60,24 @@ namespace osu.Game.Rulesets.Catch.Tests [Test] public void TestCatcherHyperStateReverted() { - DrawableCatchHitObject drawableObject1 = null; - DrawableCatchHitObject drawableObject2 = null; JudgementResult result1 = null; JudgementResult result2 = null; AddStep("catch hyper fruit", () => { - attemptCatch(new Fruit { HyperDashTarget = new Fruit { X = 100 } }, out drawableObject1, out result1); + result1 = attemptCatch(new Fruit { HyperDashTarget = new Fruit { X = 100 } }); }); AddStep("catch normal fruit", () => { - attemptCatch(new Fruit(), out drawableObject2, out result2); + result2 = attemptCatch(new Fruit()); }); AddStep("revert second result", () => { - catcher.OnRevertResult(drawableObject2, result2); + catcher.OnRevertResult(result2); }); checkHyperDash(true); AddStep("revert first result", () => { - catcher.OnRevertResult(drawableObject1, result1); + catcher.OnRevertResult(result1); }); checkHyperDash(false); } @@ -86,16 +85,15 @@ namespace osu.Game.Rulesets.Catch.Tests [Test] public void TestCatcherAnimationStateReverted() { - DrawableCatchHitObject drawableObject = null; JudgementResult result = null; AddStep("catch kiai fruit", () => { - attemptCatch(new TestKiaiFruit(), out drawableObject, out result); + result = attemptCatch(new TestKiaiFruit()); }); checkState(CatcherAnimationState.Kiai); AddStep("revert result", () => { - catcher.OnRevertResult(drawableObject, result); + catcher.OnRevertResult(result); }); checkState(CatcherAnimationState.Idle); } @@ -104,20 +102,37 @@ namespace osu.Game.Rulesets.Catch.Tests public void TestCatcherCatchWidth() { float halfWidth = Catcher.CalculateCatchWidth(new BeatmapDifficulty { CircleSize = 0 }) / 2; + + AddStep("move catcher to center", () => catcher.X = CatchPlayfield.CENTER_X); + + float leftPlateBounds = CatchPlayfield.CENTER_X - halfWidth; + float rightPlateBounds = CatchPlayfield.CENTER_X + halfWidth; + AddStep("catch fruit", () => { - attemptCatch(new Fruit { X = -halfWidth + 1 }); - attemptCatch(new Fruit { X = halfWidth - 1 }); + attemptCatch(new Fruit { X = leftPlateBounds + 1 }); + attemptCatch(new Fruit { X = rightPlateBounds - 1 }); }); checkPlate(2); + AddStep("miss fruit", () => { - attemptCatch(new Fruit { X = -halfWidth - 1 }); - attemptCatch(new Fruit { X = halfWidth + 1 }); + attemptCatch(new Fruit { X = leftPlateBounds - 1 }); + attemptCatch(new Fruit { X = rightPlateBounds + 1 }); }); checkPlate(2); } + [Test] + public void TestFruitClampedToCatchableRegion() + { + AddStep("catch fruit left", () => attemptCatch(new Fruit { X = -CatchPlayfield.WIDTH })); + checkPlate(1); + AddStep("move catcher to right", () => catcher.X = CatchPlayfield.WIDTH); + AddStep("catch fruit right", () => attemptCatch(new Fruit { X = CatchPlayfield.WIDTH * 2 })); + checkPlate(2); + } + [Test] public void TestFruitChangesCatcherState() { @@ -231,11 +246,9 @@ namespace osu.Game.Rulesets.Catch.Tests [Test] public void TestHitLightingColour() { - var fruitColour = SkinConfiguration.DefaultComboColours[1]; AddStep("enable hit lighting", () => config.SetValue(OsuSetting.HitLighting, true)); AddStep("catch fruit", () => attemptCatch(new Fruit())); - AddAssert("correct hit lighting colour", () => - catcher.ChildrenOfType().First()?.Entry?.ObjectColour == fruitColour); + AddAssert("correct hit lighting colour", () => catcher.ChildrenOfType().First()?.Entry?.ObjectColour == this.ChildrenOfType().First().AccentColour.Value); } [Test] @@ -252,23 +265,19 @@ namespace osu.Game.Rulesets.Catch.Tests private void checkHyperDash(bool state) => AddAssert($"catcher is {(state ? "" : "not ")}hyper dashing", () => catcher.HyperDashing == state); - private void attemptCatch(CatchHitObject hitObject) - { - attemptCatch(() => hitObject, 1); - } - private void attemptCatch(Func hitObject, int count) { for (int i = 0; i < count; i++) - attemptCatch(hitObject(), out _, out _); + attemptCatch(hitObject()); } - private void attemptCatch(CatchHitObject hitObject, out DrawableCatchHitObject drawableObject, out JudgementResult result) + private JudgementResult attemptCatch(CatchHitObject hitObject) { hitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); - drawableObject = createDrawableObject(hitObject); - result = createResult(hitObject); + var drawableObject = createDrawableObject(hitObject); + var result = createResult(hitObject); applyResult(drawableObject, result); + return result; } private void applyResult(DrawableCatchHitObject drawableObject, JudgementResult result) @@ -308,7 +317,7 @@ namespace osu.Game.Rulesets.Catch.Tests } } - public class TestCatcher : Catcher + public partial class TestCatcher : Catcher { public IEnumerable CaughtObjects => this.ChildrenOfType(); diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs index 7cae9b18b9..99c2fc570d 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -21,7 +23,7 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] - public class TestSceneCatcherArea : CatchSkinnableTestScene + public partial class TestSceneCatcherArea : CatchSkinnableTestScene { private RulesetInfo catchRuleset; @@ -118,7 +120,7 @@ namespace osu.Game.Rulesets.Catch.Tests catchRuleset = rulesets.GetRuleset(2); } - private class TestCatcherArea : CatcherArea + private partial class TestCatcherArea : CatcherArea { public TestCatcherArea(IBeatmapDifficultyInfo beatmapDifficulty) { diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs index b720ab1e97..391297a252 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs @@ -3,6 +3,8 @@ using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Utils; using osu.Game.Rulesets.Catch.Objects; @@ -10,22 +12,37 @@ using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Tests.Visual; using osuTK; using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.Tests { - public class TestSceneComboCounter : CatchSkinnableTestScene + public partial class TestSceneComboCounter : CatchSkinnableTestScene { - private ScoreProcessor scoreProcessor; + private ScoreProcessor scoreProcessor = null!; private Color4 judgedObjectColour = Color4.White; + private readonly Bindable showHud = new Bindable(true); + + [BackgroundDependencyLoader] + private void load() + { + Dependencies.CacheAs(new TestPlayer + { + ShowingOverlayComponents = { BindTarget = showHud }, + }); + } + [SetUp] public void SetUp() => Schedule(() => { scoreProcessor = new ScoreProcessor(new CatchRuleset()); + showHud.Value = true; + SetContents(_ => new CatchComboDisplay { Anchor = Anchor.Centre, @@ -49,9 +66,15 @@ namespace osu.Game.Rulesets.Catch.Tests 1f ); }); + + AddStep("set hud to never show", () => showHud.Value = false); + AddRepeatStep("perform hit", () => performJudgement(HitResult.Great), 5); + + AddStep("set hud to show", () => showHud.Value = true); + AddRepeatStep("perform hit", () => performJudgement(HitResult.Great), 5); } - private void performJudgement(HitResult type, Judgement judgement = null) + private void performJudgement(HitResult type, Judgement? judgement = null) { var judgedObject = new DrawableFruit(new Fruit()) { AccentColour = { Value = judgedObjectColour } }; diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs index 4b8fede369..11d6419507 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -19,7 +21,7 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Tests { - public class TestSceneDrawableHitObjects : OsuTestScene + public partial class TestSceneDrawableHitObjects : OsuTestScene { private DrawableCatchRuleset drawableRuleset; private double playfieldTime => drawableRuleset.Playfield.Time.Current; diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjectsHidden.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjectsHidden.cs index 62fe5dca2c..007f309f3f 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjectsHidden.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjectsHidden.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Rulesets.Catch.Mods; namespace osu.Game.Rulesets.Catch.Tests { - public class TestSceneDrawableHitObjectsHidden : TestSceneDrawableHitObjects + public partial class TestSceneDrawableHitObjectsHidden : TestSceneDrawableHitObjects { [SetUp] public void SetUp() => Schedule(() => diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs index 12b98dc93c..223c4e57fc 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -13,7 +15,7 @@ using osu.Game.Rulesets.Catch.Objects.Drawables; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] - public class TestSceneFruitObjects : CatchSkinnableTestScene + public partial class TestSceneFruitObjects : CatchSkinnableTestScene { protected override void LoadComplete() { @@ -56,7 +58,7 @@ namespace osu.Game.Rulesets.Catch.Tests private Drawable createDrawableTinyDroplet() => new TestDrawableCatchHitObjectSpecimen(new DrawableTinyDroplet(new TinyDroplet())); } - public class TestDrawableCatchHitObjectSpecimen : CompositeDrawable + public partial class TestDrawableCatchHitObjectSpecimen : CompositeDrawable { public readonly ManualClock ManualClock; diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitRandomness.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitRandomness.cs index c888dc0a65..995daaceb1 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitRandomness.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitRandomness.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects.Drawables; @@ -10,7 +12,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.Tests { - public class TestSceneFruitRandomness : OsuTestScene + public partial class TestSceneFruitRandomness : OsuTestScene { private readonly DrawableFruit drawableFruit; private readonly DrawableBanana drawableBanana; diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitVisualChange.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitVisualChange.cs index 9446e864a1..4b2873e0a8 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitVisualChange.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitVisualChange.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects.Drawables; namespace osu.Game.Rulesets.Catch.Tests { - public class TestSceneFruitVisualChange : TestSceneFruitObjects + public partial class TestSceneFruitVisualChange : TestSceneFruitObjects { private readonly Bindable indexInBeatmap = new Bindable(); private readonly Bindable hyperDash = new Bindable(); diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs index a5b44dc605..f8c43a221e 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -15,7 +17,7 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] - public class TestSceneHyperDash : TestSceneCatchPlayer + public partial class TestSceneHyperDash : TestSceneCatchPlayer { protected override bool Autoplay => true; @@ -31,7 +33,7 @@ namespace osu.Game.Rulesets.Catch.Tests hyperDashCount = 0; // this needs to be done within the frame stable context due to how quickly hyperdash state changes occur. - Player.DrawableRuleset.FrameStableComponents.OnUpdate += d => + Player.DrawableRuleset.FrameStableComponents.OnUpdate += _ => { var catcher = Player.ChildrenOfType().FirstOrDefault(); diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs index 14a4d02396..b343174e6b 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -22,7 +24,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.Tests { - public class TestSceneHyperDashColouring : OsuTestScene + public partial class TestSceneHyperDashColouring : OsuTestScene { [Resolved] private SkinManager skins { get; set; } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneJuiceStream.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneJuiceStream.cs index 4601234669..c91f07891c 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneJuiceStream.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneJuiceStream.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using NUnit.Framework; using osu.Game.Beatmaps; @@ -12,7 +14,7 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Tests { - public class TestSceneJuiceStream : TestSceneCatchPlayer + public partial class TestSceneJuiceStream : TestSceneCatchPlayer { [Test] public void TestJuiceStreamEndingCombo() diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneLegacyBeatmapSkin.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneLegacyBeatmapSkin.cs index bc3daca16f..aa66fc8741 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneLegacyBeatmapSkin.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneLegacyBeatmapSkin.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -16,7 +18,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.Tests { - public class TestSceneLegacyBeatmapSkin : LegacyBeatmapSkinColourTest + public partial class TestSceneLegacyBeatmapSkin : LegacyBeatmapSkinColourTest { [Resolved] private AudioManager audio { get; set; } @@ -103,7 +105,7 @@ namespace osu.Game.Rulesets.Catch.Tests protected override ExposedPlayer CreateTestPlayer(bool userHasCustomColours) => new CatchExposedPlayer(userHasCustomColours); - private class CatchExposedPlayer : ExposedPlayer + private partial class CatchExposedPlayer : ExposedPlayer { public CatchExposedPlayer(bool userHasCustomColours) : base(userHasCustomColours) diff --git a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj index b957ade952..01922b2a96 100644 --- a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj +++ b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj @@ -1,11 +1,9 @@  - - + - - + WinExe diff --git a/osu.Game.Rulesets.Catch/CatchInputManager.cs b/osu.Game.Rulesets.Catch/CatchInputManager.cs index 021d7a7efe..1ef9e55d2d 100644 --- a/osu.Game.Rulesets.Catch/CatchInputManager.cs +++ b/osu.Game.Rulesets.Catch/CatchInputManager.cs @@ -2,12 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using System.ComponentModel; +using osu.Framework.Allocation; using osu.Framework.Input.Bindings; using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Catch { - public class CatchInputManager : RulesetInputManager + [Cached] + public partial class CatchInputManager : RulesetInputManager { public CatchInputManager(RulesetInfo ruleset) : base(ruleset, 0, SimultaneousBindingMode.Unique) diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs index 80b9436b2c..8a0b8250d5 100644 --- a/osu.Game.Rulesets.Catch/CatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs @@ -1,36 +1,38 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Rulesets.Catch.Mods; -using osu.Game.Rulesets.Catch.UI; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.UI; +using System; using System.Collections.Generic; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; -using osu.Game.Rulesets.Catch.Replays; -using osu.Game.Rulesets.Replays.Types; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; using osu.Game.Beatmaps.Legacy; +using osu.Game.Graphics; using osu.Game.Rulesets.Catch.Beatmaps; using osu.Game.Rulesets.Catch.Difficulty; -using osu.Game.Rulesets.Catch.Scoring; -using osu.Game.Rulesets.Difficulty; -using osu.Game.Rulesets.Scoring; -using System; -using osu.Framework.Extensions.EnumExtensions; using osu.Game.Rulesets.Catch.Edit; +using osu.Game.Rulesets.Catch.Mods; +using osu.Game.Rulesets.Catch.Replays; +using osu.Game.Rulesets.Catch.Scoring; +using osu.Game.Rulesets.Catch.Skinning.Argon; using osu.Game.Rulesets.Catch.Skinning.Legacy; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Replays.Types; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; using osu.Game.Skinning; namespace osu.Game.Rulesets.Catch { public class CatchRuleset : Ruleset, ILegacyRuleset { - public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableCatchRuleset(this, beatmap, mods); + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList? mods = null) => new DrawableCatchRuleset(this, beatmap, mods); public override ScoreProcessor CreateScoreProcessor() => new CatchScoreProcessor(); @@ -40,6 +42,8 @@ namespace osu.Game.Rulesets.Catch public const string SHORT_NAME = "fruits"; + public override string RulesetAPIVersionSupported => CURRENT_RULESET_API_VERSION; + public override IEnumerable GetDefaultKeyBindings(int variant = 0) => new[] { new KeyBinding(InputKey.Z, CatchAction.MoveLeft), @@ -109,6 +113,7 @@ namespace osu.Game.Rulesets.Catch new MultiMod(new CatchModDoubleTime(), new CatchModNightcore()), new CatchModHidden(), new CatchModFlashlight(), + new ModAccuracyChallenge(), }; case ModType.Conversion: @@ -160,7 +165,7 @@ namespace osu.Game.Rulesets.Catch }; } - public override string GetDisplayNameForHitResult(HitResult result) + public override LocalisableString GetDisplayNameForHitResult(HitResult result) { switch (result) { @@ -179,7 +184,19 @@ namespace osu.Game.Rulesets.Catch public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new CatchDifficultyCalculator(RulesetInfo, beatmap); - public override ISkin CreateLegacySkinProvider(ISkin skin, IBeatmap beatmap) => new CatchLegacySkinTransformer(skin); + public override ISkin? CreateSkinTransformer(ISkin skin, IBeatmap beatmap) + { + switch (skin) + { + case LegacySkin: + return new CatchLegacySkinTransformer(skin); + + case ArgonSkin: + return new CatchArgonSkinTransformer(skin); + } + + return null; + } public override PerformanceCalculator CreatePerformanceCalculator() => new CatchPerformanceCalculator(); diff --git a/osu.Game.Rulesets.Catch/CatchSkinComponent.cs b/osu.Game.Rulesets.Catch/CatchSkinComponentLookup.cs similarity index 69% rename from osu.Game.Rulesets.Catch/CatchSkinComponent.cs rename to osu.Game.Rulesets.Catch/CatchSkinComponentLookup.cs index 8bf53e53e3..149aae1cb4 100644 --- a/osu.Game.Rulesets.Catch/CatchSkinComponent.cs +++ b/osu.Game.Rulesets.Catch/CatchSkinComponentLookup.cs @@ -5,15 +5,15 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Catch { - public class CatchSkinComponent : GameplaySkinComponent + public class CatchSkinComponentLookup : GameplaySkinComponentLookup { - public CatchSkinComponent(CatchSkinComponents component) + public CatchSkinComponentLookup(CatchSkinComponents component) : base(component) { } protected override string RulesetPrefix => "catch"; // todo: use CatchRuleset.SHORT_NAME; - protected override string ComponentName => Component.ToString().ToLower(); + protected override string ComponentName => Component.ToString().ToLowerInvariant(); } } diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs index 8e069d7d16..2d01153f98 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using Newtonsoft.Json; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; namespace osu.Game.Rulesets.Catch.Difficulty @@ -29,9 +30,9 @@ namespace osu.Game.Rulesets.Catch.Difficulty yield return (ATTRIB_ID_MAX_COMBO, MaxCombo); } - public override void FromDatabaseAttributes(IReadOnlyDictionary values) + public override void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo) { - base.FromDatabaseAttributes(values); + base.FromDatabaseAttributes(values, onlineInfo); StarRating = values[ATTRIB_ID_AIM]; ApproachRate = values[ATTRIB_ID_APPROACH_RATE]; diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs index 0054047573..42cfde268e 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs @@ -23,6 +23,8 @@ namespace osu.Game.Rulesets.Catch.Difficulty private float halfCatcherWidth; + public override int Version => 20220701; + public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) { @@ -47,7 +49,9 @@ namespace osu.Game.Rulesets.Catch.Difficulty protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) { - CatchHitObject lastObject = null; + CatchHitObject? lastObject = null; + + List objects = new List(); // In 2B beatmaps, it is possible that a normal Fruit is placed in the middle of a JuiceStream. foreach (var hitObject in beatmap.HitObjects @@ -60,10 +64,12 @@ namespace osu.Game.Rulesets.Catch.Difficulty continue; if (lastObject != null) - yield return new CatchDifficultyHitObject(hitObject, lastObject, clockRate, halfCatcherWidth); + objects.Add(new CatchDifficultyHitObject(hitObject, lastObject, clockRate, halfCatcherWidth, objects, objects.Count)); lastObject = hitObject; } + + return objects; } protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) diff --git a/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs index b22ec93b43..3bcfce3a56 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Objects; @@ -24,8 +25,8 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Preprocessing /// public readonly double StrainTime; - public CatchDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, float halfCatcherWidth) - : base(hitObject, lastObject, clockRate) + public CatchDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, float halfCatcherWidth, List objects, int index) + : base(hitObject, lastObject, clockRate, objects, index) { // We will scale everything by this factor, so we can assume a uniform CircleSize among beatmaps. float scalingFactor = normalized_hitobject_radius / halfCatcherWidth; diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.cs index 039008f901..5f22ef5c12 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.cs @@ -10,7 +10,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Catch.Edit.Blueprints { - public class BananaShowerPlacementBlueprint : CatchPlacementBlueprint + public partial class BananaShowerPlacementBlueprint : CatchPlacementBlueprint { private readonly TimeSpanOutline outline; diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerSelectionBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerSelectionBlueprint.cs index 9132b1a9e8..f6dd67889e 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerSelectionBlueprint.cs @@ -5,7 +5,7 @@ using osu.Game.Rulesets.Catch.Objects; namespace osu.Game.Rulesets.Catch.Edit.Blueprints { - public class BananaShowerSelectionBlueprint : CatchSelectionBlueprint + public partial class BananaShowerSelectionBlueprint : CatchSelectionBlueprint { public BananaShowerSelectionBlueprint(BananaShower hitObject) : base(hitObject) diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchPlacementBlueprint.cs index 5a32d241ad..d2d605a6fe 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchPlacementBlueprint.cs @@ -10,7 +10,7 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Edit.Blueprints { - public class CatchPlacementBlueprint : PlacementBlueprint + public partial class CatchPlacementBlueprint : PlacementBlueprint where THitObject : CatchHitObject, new() { protected new THitObject HitObject => (THitObject)base.HitObject; @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints protected ScrollingHitObjectContainer HitObjectContainer => (ScrollingHitObjectContainer)playfield.HitObjectContainer; [Resolved] - private Playfield playfield { get; set; } + private Playfield playfield { get; set; } = null!; public CatchPlacementBlueprint() : base(new THitObject()) diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchSelectionBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchSelectionBlueprint.cs index 7e566c810c..8220fb88b4 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchSelectionBlueprint.cs @@ -10,7 +10,7 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Edit.Blueprints { - public abstract class CatchSelectionBlueprint : HitObjectSelectionBlueprint + public abstract partial class CatchSelectionBlueprint : HitObjectSelectionBlueprint where THitObject : CatchHitObject { protected override bool AlwaysShowWhenSelected => true; @@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints protected ScrollingHitObjectContainer HitObjectContainer => (ScrollingHitObjectContainer)playfield.HitObjectContainer; [Resolved] - private Playfield playfield { get; set; } + private Playfield playfield { get; set; } = null!; protected CatchSelectionBlueprint(THitObject hitObject) : base(hitObject) diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs index e038562b4b..74d6565600 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -18,7 +17,7 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components { - public abstract class EditablePath : CompositeDrawable + public abstract partial class EditablePath : CompositeDrawable { public int PathId => path.InvalidationID; @@ -40,9 +39,8 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components private readonly List previousVertexStates = new List(); - [Resolved(CanBeNull = true)] - [CanBeNull] - private IBeatSnapProvider beatSnapProvider { get; set; } + [Resolved] + private IBeatSnapProvider? beatSnapProvider { get; set; } protected EditablePath(Func positionToTime) { @@ -54,7 +52,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components public void UpdateFrom(ScrollingHitObjectContainer hitObjectContainer, JuiceStream hitObject) { while (path.Vertices.Count < InternalChildren.Count) - RemoveInternal(InternalChildren[^1]); + RemoveInternal(InternalChildren[^1], true); while (InternalChildren.Count < path.Vertices.Count) AddInternal(new VertexPiece()); diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/FruitOutline.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/FruitOutline.cs index 0c03068e26..c7805544ea 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/FruitOutline.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/FruitOutline.cs @@ -11,7 +11,7 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components { - public class FruitOutline : CompositeDrawable + public partial class FruitOutline : CompositeDrawable { public FruitOutline() { diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/NestedOutlineContainer.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/NestedOutlineContainer.cs index cf916b27a4..c1f46539fa 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/NestedOutlineContainer.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/NestedOutlineContainer.cs @@ -11,7 +11,7 @@ using osu.Game.Rulesets.UI.Scrolling; namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components { - public class NestedOutlineContainer : CompositeDrawable + public partial class NestedOutlineContainer : CompositeDrawable { private readonly List nestedHitObjects = new List(); @@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components .Where(h => !(h is TinyDroplet))); while (nestedHitObjects.Count < InternalChildren.Count) - RemoveInternal(InternalChildren[^1]); + RemoveInternal(InternalChildren[^1], true); while (InternalChildren.Count < nestedHitObjects.Count) AddInternal(new FruitOutline()); diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/PlacementEditablePath.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/PlacementEditablePath.cs index 511aec5e5d..3a7d6d87f2 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/PlacementEditablePath.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/PlacementEditablePath.cs @@ -7,7 +7,7 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components { - public class PlacementEditablePath : EditablePath + public partial class PlacementEditablePath : EditablePath { /// /// The original position of the last added vertex. diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/ScrollingPath.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/ScrollingPath.cs index cfaca2f9a4..a22abcb76d 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/ScrollingPath.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/ScrollingPath.cs @@ -13,7 +13,7 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components { - public class ScrollingPath : CompositeDrawable + public partial class ScrollingPath : CompositeDrawable { private readonly Path drawablePath; diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/SelectionEditablePath.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/SelectionEditablePath.cs index b4c353313c..c7a26ca15a 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/SelectionEditablePath.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/SelectionEditablePath.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; @@ -16,16 +15,15 @@ using osuTK.Input; namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components { - public class SelectionEditablePath : EditablePath, IHasContextMenu + public partial class SelectionEditablePath : EditablePath, IHasContextMenu { public MenuItem[] ContextMenuItems => getContextMenuItems().ToArray(); // To handle when the editor is scrolled while dragging. private Vector2 dragStartPosition; - [Resolved(CanBeNull = true)] - [CanBeNull] - private IEditorChangeHandler changeHandler { get; set; } + [Resolved] + private IEditorChangeHandler? changeHandler { get; set; } public SelectionEditablePath(Func positionToTime) : base(positionToTime) diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/TimeSpanOutline.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/TimeSpanOutline.cs index 65dfce0493..9d450cd355 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/TimeSpanOutline.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/TimeSpanOutline.cs @@ -13,7 +13,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components { - public class TimeSpanOutline : CompositeDrawable + public partial class TimeSpanOutline : CompositeDrawable { private const float border_width = 4; diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/VertexPiece.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/VertexPiece.cs index 5ef86b6074..07d7c72698 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/VertexPiece.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/VertexPiece.cs @@ -10,10 +10,10 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components { - public class VertexPiece : Circle + public partial class VertexPiece : Circle { [Resolved] - private OsuColour osuColour { get; set; } + private OsuColour osuColour { get; set; } = null!; public VertexPiece() { diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/VertexState.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/VertexState.cs index 3f240c7944..1ddffc10a5 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/VertexState.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/VertexState.cs @@ -3,8 +3,6 @@ using osu.Game.Rulesets.Catch.Objects; -#nullable enable - namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components { /// diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitPlacementBlueprint.cs index e169e3b75c..72592891fb 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitPlacementBlueprint.cs @@ -9,7 +9,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Catch.Edit.Blueprints { - public class FruitPlacementBlueprint : CatchPlacementBlueprint + public partial class FruitPlacementBlueprint : CatchPlacementBlueprint { private readonly FruitOutline outline; diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitSelectionBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitSelectionBlueprint.cs index 150297badb..2737b283ef 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitSelectionBlueprint.cs @@ -6,7 +6,7 @@ using osu.Game.Rulesets.Catch.Objects; namespace osu.Game.Rulesets.Catch.Edit.Blueprints { - public class FruitSelectionBlueprint : CatchSelectionBlueprint + public partial class FruitSelectionBlueprint : CatchSelectionBlueprint { private readonly FruitOutline outline; diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs index b5dcb62543..03ec674abb 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs @@ -12,7 +12,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Catch.Edit.Blueprints { - public class JuiceStreamPlacementBlueprint : CatchPlacementBlueprint + public partial class JuiceStreamPlacementBlueprint : CatchPlacementBlueprint { private readonly ScrollingPath scrollingPath; @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints private int lastEditablePathId = -1; - private InputManager inputManager; + private InputManager inputManager = null!; public JuiceStreamPlacementBlueprint() { diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs index 12054a1d16..49d778ad08 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Caching; using osu.Framework.Graphics; @@ -20,7 +19,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Catch.Edit.Blueprints { - public class JuiceStreamSelectionBlueprint : CatchSelectionBlueprint + public partial class JuiceStreamSelectionBlueprint : CatchSelectionBlueprint { public override Quad SelectionQuad => HitObjectContainer.ToScreenSpace(getBoundingBox().Offset(new Vector2(0, HitObjectContainer.DrawHeight))); @@ -51,9 +50,8 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints private Vector2 rightMouseDownPosition; - [Resolved(CanBeNull = true)] - [CanBeNull] - private EditorBeatmap editorBeatmap { get; set; } + [Resolved] + private EditorBeatmap? editorBeatmap { get; set; } public JuiceStreamSelectionBlueprint(JuiceStream hitObject) : base(hitObject) diff --git a/osu.Game.Rulesets.Catch/Edit/CatchBlueprintContainer.cs b/osu.Game.Rulesets.Catch/Edit/CatchBlueprintContainer.cs index 7f2782a474..3979d30616 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchBlueprintContainer.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchBlueprintContainer.cs @@ -9,7 +9,7 @@ using osu.Game.Screens.Edit.Compose.Components; namespace osu.Game.Rulesets.Catch.Edit { - public class CatchBlueprintContainer : ComposeBlueprintContainer + public partial class CatchBlueprintContainer : ComposeBlueprintContainer { public CatchBlueprintContainer(CatchHitObjectComposer composer) : base(composer) @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Catch.Edit protected override SelectionHandler CreateSelectionHandler() => new CatchSelectionHandler(); - public override HitObjectSelectionBlueprint CreateHitObjectBlueprintFor(HitObject hitObject) + public override HitObjectSelectionBlueprint? CreateHitObjectBlueprintFor(HitObject hitObject) { switch (hitObject) { @@ -34,5 +34,7 @@ namespace osu.Game.Rulesets.Catch.Edit return base.CreateHitObjectBlueprintFor(hitObject); } + + protected sealed override DragBox CreateDragBox() => new ScrollingDragBox(Composer.Playfield); } } diff --git a/osu.Game.Rulesets.Catch/Edit/CatchDistanceSnapGrid.cs b/osu.Game.Rulesets.Catch/Edit/CatchDistanceSnapGrid.cs index 9a78c7ff86..cf6ddc66ed 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchDistanceSnapGrid.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchDistanceSnapGrid.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -22,7 +21,7 @@ namespace osu.Game.Rulesets.Catch.Edit /// The guide lines used in the osu!catch editor to compose patterns that can be caught with constant speed. /// Currently, only forward placement (an object is snapped based on the previous object, not the opposite) is supported. /// - public class CatchDistanceSnapGrid : CompositeDrawable + public partial class CatchDistanceSnapGrid : CompositeDrawable { public double StartTime { get; set; } @@ -37,7 +36,7 @@ namespace osu.Game.Rulesets.Catch.Edit private readonly List verticalLineVertices = new List(); [Resolved] - private Playfield playfield { get; set; } + private Playfield playfield { get; set; } = null!; private ScrollingHitObjectContainer hitObjectContainer => (ScrollingHitObjectContainer)playfield.HitObjectContainer; @@ -104,8 +103,7 @@ namespace osu.Game.Rulesets.Catch.Edit } } - [CanBeNull] - public SnapResult GetSnappedPosition(Vector2 screenSpacePosition) + public SnapResult? GetSnappedPosition(Vector2 screenSpacePosition) { double time = hitObjectContainer.TimeAtScreenSpacePosition(screenSpacePosition); @@ -119,9 +117,7 @@ namespace osu.Game.Rulesets.Catch.Edit return new SnapResult(originPosition, StartTime); } - return enumerateSnappingCandidates(time) - .OrderBy(pos => Vector2.DistanceSquared(screenSpacePosition, pos.ScreenSpacePosition)) - .FirstOrDefault(); + return enumerateSnappingCandidates(time).MinBy(pos => Vector2.DistanceSquared(screenSpacePosition, pos.ScreenSpacePosition)); } private IEnumerable enumerateSnappingCandidates(double time) diff --git a/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfield.cs b/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfield.cs index 046ba0ebce..c9481c2757 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfield.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfield.cs @@ -6,7 +6,7 @@ using osu.Game.Rulesets.Catch.UI; namespace osu.Game.Rulesets.Catch.Edit { - public class CatchEditorPlayfield : CatchPlayfield + public partial class CatchEditorPlayfield : CatchPlayfield { // TODO fixme: the size of the catcher is not changed when circle size is changed in setup screen. public CatchEditorPlayfield(IBeatmapDifficultyInfo difficulty) diff --git a/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfieldAdjustmentContainer.cs b/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfieldAdjustmentContainer.cs new file mode 100644 index 0000000000..0271005dd1 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfieldAdjustmentContainer.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit +{ + public partial class CatchEditorPlayfieldAdjustmentContainer : PlayfieldAdjustmentContainer + { + protected override Container Content => content; + private readonly Container content; + + public CatchEditorPlayfieldAdjustmentContainer() + { + Anchor = Anchor.TopCentre; + Origin = Anchor.TopCentre; + Size = new Vector2(0.8f, 0.9f); + + InternalChild = new ScalingContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Child = content = new Container { RelativeSizeAxes = Axes.Both }, + }; + } + + private partial class ScalingContainer : Container + { + public ScalingContainer() + { + RelativeSizeAxes = Axes.Y; + Width = CatchPlayfield.WIDTH; + } + + protected override void Update() + { + base.Update(); + + Scale = new Vector2(Math.Min(Parent.ChildSize.X / CatchPlayfield.WIDTH, Parent.ChildSize.Y / CatchPlayfield.HEIGHT)); + Height = 1 / Scale.Y; + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs index 6f59b3e543..cd8894753f 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs @@ -1,17 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; using osu.Framework.Input; +using osu.Framework.Input.Events; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Edit; @@ -19,21 +20,24 @@ using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; -using osu.Game.Screens.Edit.Components.TernaryButtons; using osu.Game.Screens.Edit.Compose.Components; using osuTK; namespace osu.Game.Rulesets.Catch.Edit { - public class CatchHitObjectComposer : DistancedHitObjectComposer + public partial class CatchHitObjectComposer : DistancedHitObjectComposer { private const float distance_snap_radius = 50; - private CatchDistanceSnapGrid distanceSnapGrid; + private CatchDistanceSnapGrid distanceSnapGrid = null!; - private readonly Bindable distanceSnapToggle = new Bindable(); + private InputManager inputManager = null!; - private InputManager inputManager; + private readonly BindableDouble timeRangeMultiplier = new BindableDouble(1) + { + MinValue = 1, + MaxValue = 10, + }; public CatchHitObjectComposer(CatchRuleset ruleset) : base(ruleset) @@ -44,12 +48,14 @@ 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 { - RelativeSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.X, + Height = CatchPlayfield.HEIGHT, PlayfieldBorderStyle = { Value = PlayfieldBorderStyle.Corners } }); @@ -68,6 +74,19 @@ namespace osu.Game.Rulesets.Catch.Edit inputManager = GetContainingInputManager(); } + protected override double ReadCurrentDistanceSnap(HitObject before, HitObject after) + { + // osu!catch's distance snap implementation is limited, in that a custom spacing cannot be specified. + // Therefore this functionality is not currently used. + // + // The implementation below is probably correct but should be checked if/when exposed via controls. + + float expectedDistance = DurationToDistance(before, after.StartTime - before.GetEndTime()); + float actualDistance = Math.Abs(((CatchHitObject)before).EffectiveX - ((CatchHitObject)after).EffectiveX); + + return actualDistance / expectedDistance; + } + protected override void Update() { base.Update(); @@ -75,8 +94,30 @@ namespace osu.Game.Rulesets.Catch.Edit updateDistanceSnapGrid(); } - protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) => - new DrawableCatchEditorRuleset(ruleset, beatmap, mods); + public override bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + // Note that right now these are hard to use as the default key bindings conflict with existing editor key bindings. + // In the future we will want to expose this via UI and potentially change the key bindings to be editor-specific. + // May be worth considering standardising "zoom" behaviour with what the timeline uses (ie. alt-wheel) but that may cause new conflicts. + case GlobalAction.IncreaseScrollSpeed: + this.TransformBindableTo(timeRangeMultiplier, timeRangeMultiplier.Value - 1, 200, Easing.OutQuint); + break; + + case GlobalAction.DecreaseScrollSpeed: + this.TransformBindableTo(timeRangeMultiplier, timeRangeMultiplier.Value + 1, 200, Easing.OutQuint); + break; + } + + return base.OnPressed(e); + } + + protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList? mods = null) => + new DrawableCatchEditorRuleset(ruleset, beatmap, mods) + { + TimeRangeMultiplier = { BindTarget = timeRangeMultiplier, } + }; protected override IReadOnlyList CompositionTools => new HitObjectCompositionTool[] { @@ -85,11 +126,6 @@ namespace osu.Game.Rulesets.Catch.Edit new BananaShowerCompositionTool() }; - protected override IEnumerable CreateTernaryButtons() => base.CreateTernaryButtons().Concat(new[] - { - new TernaryButton(distanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler }) - }); - public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) { var result = base.FindSnappedPositionAndTime(screenSpacePosition, snapType); @@ -110,8 +146,7 @@ namespace osu.Game.Rulesets.Catch.Edit protected override ComposeBlueprintContainer CreateBlueprintContainer() => new CatchBlueprintContainer(this); - [CanBeNull] - private PalpableCatchHitObject getLastSnappableHitObject(double time) + private PalpableCatchHitObject? getLastSnappableHitObject(double time) { var hitObject = EditorBeatmap.HitObjects.OfType().LastOrDefault(h => h.GetEndTime() < time && !(h is BananaShower)); @@ -128,27 +163,27 @@ namespace osu.Game.Rulesets.Catch.Edit } } - [CanBeNull] - private PalpableCatchHitObject getDistanceSnapGridSourceHitObject() + private PalpableCatchHitObject? getDistanceSnapGridSourceHitObject() { switch (BlueprintContainer.CurrentTool) { - case SelectTool _: + case SelectTool: if (EditorBeatmap.SelectedHitObjects.Count == 0) return null; double minTime = EditorBeatmap.SelectedHitObjects.Min(hitObject => hitObject.StartTime); return getLastSnappableHitObject(minTime); - case FruitCompositionTool _: - case JuiceStreamCompositionTool _: + case FruitCompositionTool: + case JuiceStreamCompositionTool: if (!CursorInPlacementArea) return null; if (EditorBeatmap.PlacementObject.Value is JuiceStream) { // Juice stream path is not subject to snapping. - return null; + if (BlueprintContainer.CurrentPlacement.PlacementActive is PlacementBlueprint.PlacementState.Active) + return null; } double timeAtCursor = ((CatchPlayfield)Playfield).TimeAtScreenSpacePosition(inputManager.CurrentState.Mouse.Position); @@ -161,7 +196,7 @@ namespace osu.Game.Rulesets.Catch.Edit private void updateDistanceSnapGrid() { - if (distanceSnapToggle.Value != TernaryState.True) + if (DistanceSnapToggle.Value != TernaryState.True) { distanceSnapGrid.Hide(); return; diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectUtils.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectUtils.cs index b059926668..bd33080109 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectUtils.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectUtils.cs @@ -40,10 +40,10 @@ namespace osu.Game.Rulesets.Catch.Edit case Droplet droplet: return droplet is TinyDroplet ? PositionRange.EMPTY : new PositionRange(droplet.OriginalX); - case JuiceStream _: + case JuiceStream: return GetPositionRange(hitObject.NestedHitObjects); - case BananaShower _: + case BananaShower: // A banana shower occupies the whole screen width. return new PositionRange(0, CatchPlayfield.WIDTH); diff --git a/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs b/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs index dd5835b4ed..418351e2f3 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs @@ -16,12 +16,12 @@ using Direction = osu.Framework.Graphics.Direction; namespace osu.Game.Rulesets.Catch.Edit { - public class CatchSelectionHandler : EditorSelectionHandler + public partial class CatchSelectionHandler : EditorSelectionHandler { protected ScrollingHitObjectContainer HitObjectContainer => (ScrollingHitObjectContainer)playfield.HitObjectContainer; [Resolved] - private Playfield playfield { get; set; } + private Playfield playfield { get; set; } = null!; public override bool HandleMovement(MoveSelectionEvent moveEvent) { @@ -129,7 +129,7 @@ namespace osu.Game.Rulesets.Catch.Edit { switch (hitObject) { - case BananaShower _: + case BananaShower: return false; case JuiceStream juiceStream: diff --git a/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.cs b/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.cs index 9a7528d90c..7ad2106ab9 100644 --- a/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.cs +++ b/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Mods; @@ -9,13 +10,26 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Catch.Edit { - public class DrawableCatchEditorRuleset : DrawableCatchRuleset + public partial class DrawableCatchEditorRuleset : DrawableCatchRuleset { - public DrawableCatchEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) + public readonly BindableDouble TimeRangeMultiplier = new BindableDouble(1); + + public DrawableCatchEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList? mods = null) : base(ruleset, beatmap, mods) { } + protected override void Update() + { + base.Update(); + + double gamePlayTimeRange = GetTimeRange(Beatmap.Difficulty.ApproachRate); + float playfieldStretch = Playfield.DrawHeight / CatchPlayfield.HEIGHT; + TimeRange.Value = gamePlayTimeRange * TimeRangeMultiplier.Value * playfieldStretch; + } + protected override Playfield CreatePlayfield() => new CatchEditorPlayfield(Beatmap.Difficulty); + + public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new CatchEditorPlayfieldAdjustmentContainer(); } } diff --git a/osu.Game.Rulesets.Catch/Edit/PositionRange.cs b/osu.Game.Rulesets.Catch/Edit/PositionRange.cs index e61603e5e6..482295a357 100644 --- a/osu.Game.Rulesets.Catch/Edit/PositionRange.cs +++ b/osu.Game.Rulesets.Catch/Edit/PositionRange.cs @@ -3,8 +3,6 @@ using System; -#nullable enable - namespace osu.Game.Rulesets.Catch.Edit { /// diff --git a/osu.Game.Rulesets.Catch/Judgements/CatchJudgementResult.cs b/osu.Game.Rulesets.Catch/Judgements/CatchJudgementResult.cs index c09355d59c..4cec61d016 100644 --- a/osu.Game.Rulesets.Catch/Judgements/CatchJudgementResult.cs +++ b/osu.Game.Rulesets.Catch/Judgements/CatchJudgementResult.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 JetBrains.Annotations; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; @@ -20,7 +19,7 @@ namespace osu.Game.Rulesets.Catch.Judgements /// public bool CatcherHyperDash; - public CatchJudgementResult([NotNull] HitObject hitObject, [NotNull] Judgement judgement) + public CatchJudgementResult(HitObject hitObject, Judgement judgement) : base(hitObject, judgement) { } 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 178909387f..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 => 1.06; } } diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs b/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs index 16ef56d845..cac5b9aa6a 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs @@ -1,12 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Catch.Mods { public class CatchModEasy : ModEasyWithExtraLives { - public override string Description => @"Larger fruits, more forgiving HP drain, less accuracy required, and three lives!"; + public override LocalisableString Description => @"Larger fruits, more forgiving HP drain, less accuracy required, and three lives!"; } } diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModFlashlight.cs b/osu.Game.Rulesets.Catch/Mods/CatchModFlashlight.cs index d576ea3df8..40450c6729 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModFlashlight.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModFlashlight.cs @@ -3,7 +3,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Game.Configuration; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Mods; @@ -12,32 +11,24 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Mods { - public class CatchModFlashlight : ModFlashlight + public partial class CatchModFlashlight : ModFlashlight { - public override double ScoreMultiplier => 1.12; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1; - [SettingSource("Flashlight size", "Multiplier applied to the default flashlight size.")] - public override BindableFloat SizeMultiplier { get; } = new BindableFloat + public override BindableFloat SizeMultiplier { get; } = new BindableFloat(1) { MinValue = 0.5f, MaxValue = 1.5f, - Default = 1f, - Value = 1f, Precision = 0.1f }; - [SettingSource("Change size based on combo", "Decrease the flashlight size as combo increases.")] - public override BindableBool ComboBasedSize { get; } = new BindableBool - { - Default = true, - Value = true - }; + public override BindableBool ComboBasedSize { get; } = new BindableBool(true); - public override float DefaultFlashlightSize => 350; + public override float DefaultFlashlightSize => 325; protected override Flashlight CreateFlashlight() => new CatchFlashlight(this, playfield); - private CatchPlayfield playfield; + private CatchPlayfield playfield = null!; public override void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { @@ -45,7 +36,7 @@ namespace osu.Game.Rulesets.Catch.Mods base.ApplyToDrawableRuleset(drawableRuleset); } - private class CatchFlashlight : Flashlight + private partial class CatchFlashlight : Flashlight { private readonly CatchPlayfield playfield; @@ -53,7 +44,19 @@ namespace osu.Game.Rulesets.Catch.Mods : base(modFlashlight) { this.playfield = playfield; - FlashlightSize = new Vector2(0, GetSizeFor(0)); + + FlashlightSize = new Vector2(0, GetSize()); + FlashlightSmoothness = 1.4f; + } + + protected override float GetComboScaleFor(int combo) + { + if (combo >= 200) + return 0.770f; + if (combo >= 100) + return 0.885f; + + return 1.0f; } protected override void Update() @@ -63,9 +66,9 @@ namespace osu.Game.Rulesets.Catch.Mods FlashlightPosition = playfield.CatcherArea.ToSpaceOfOtherDrawable(playfield.Catcher.DrawPosition, this); } - protected override void OnComboChange(ValueChangedEvent e) + protected override void UpdateFlashlightSize(float size) { - this.TransformTo(nameof(FlashlightSize), new Vector2(0, GetSizeFor(e.NewValue)), FLASHLIGHT_FADE_DURATION); + this.TransformTo(nameof(FlashlightSize), new Vector2(0, size), FLASHLIGHT_FADE_DURATION); } protected override string FragmentShader => "CircularFlashlight"; diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModFloatingFruits.cs b/osu.Game.Rulesets.Catch/Mods/CatchModFloatingFruits.cs index 63203dd57c..e12181d051 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModFloatingFruits.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModFloatingFruits.cs @@ -3,6 +3,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; @@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Catch.Mods { public override string Name => "Floating Fruits"; public override string Acronym => "FF"; - public override string Description => "The fruits are... floating?"; + public override LocalisableString Description => "The fruits are... floating?"; public override double ScoreMultiplier => 1; public override IconUsage? Icon => FontAwesome.Solid.Cloud; 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/CatchModHardRock.cs b/osu.Game.Rulesets.Catch/Mods/CatchModHardRock.cs index 68b6ce96a3..93eadcc13e 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModHardRock.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModHardRock.cs @@ -9,7 +9,7 @@ namespace osu.Game.Rulesets.Catch.Mods { public class CatchModHardRock : ModHardRock, IApplicableToBeatmapProcessor { - public override double ScoreMultiplier => 1.12; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1; public void ApplyToBeatmapProcessor(IBeatmapProcessor beatmapProcessor) { diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs b/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs index 4b6f79df88..d68430b64f 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs @@ -3,6 +3,7 @@ using System.Linq; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.UI; @@ -14,8 +15,8 @@ namespace osu.Game.Rulesets.Catch.Mods { public class CatchModHidden : ModHidden, IApplicableToDrawableRuleset { - public override string Description => @"Play with fading fruits."; - public override double ScoreMultiplier => 1.06; + public override LocalisableString Description => @"Play with fading fruits."; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1; private const double fade_out_offset_multiplier = 0.6; private const double fade_out_duration_multiplier = 0.44; diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModMirror.cs b/osu.Game.Rulesets.Catch/Mods/CatchModMirror.cs index a97e940a64..4cd2efdc2f 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModMirror.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModMirror.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Linq; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Beatmaps; using osu.Game.Rulesets.Catch.Objects; @@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Catch.Mods { public class CatchModMirror : ModMirror, IApplicableToBeatmap { - public override string Description => "Fruits are flipped horizontally."; + public override LocalisableString Description => "Fruits are flipped horizontally."; /// /// is used instead of , diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModNightcore.cs b/osu.Game.Rulesets.Catch/Mods/CatchModNightcore.cs index c07087efaf..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 => 1.06; } } diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModNoScope.cs b/osu.Game.Rulesets.Catch/Mods/CatchModNoScope.cs index a24a6227fe..ddeea51ecb 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModNoScope.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModNoScope.cs @@ -3,10 +3,9 @@ using System; using osu.Framework.Bindables; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; using osu.Framework.Utils; -using osu.Game.Configuration; -using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.UI; @@ -14,17 +13,10 @@ namespace osu.Game.Rulesets.Catch.Mods { public class CatchModNoScope : ModNoScope, IUpdatableByPlayfield { - public override string Description => "Where's the catcher?"; + public override LocalisableString Description => "Where's the catcher?"; - [SettingSource( - "Hidden at combo", - "The combo count at which the catcher becomes completely hidden", - SettingControlType = typeof(SettingsSlider) - )] - public override BindableInt HiddenComboCount { get; } = new BindableInt + public override BindableInt HiddenComboCount { get; } = new BindableInt(10) { - Default = 10, - Value = 10, MinValue = 0, MaxValue = 50, }; @@ -34,6 +26,9 @@ namespace osu.Game.Rulesets.Catch.Mods var catchPlayfield = (CatchPlayfield)playfield; bool shouldAlwaysShowCatcher = IsBreakTime.Value; float targetAlpha = shouldAlwaysShowCatcher ? 1 : ComboBasedAlpha; + + // AlwaysPresent required for catcher to still act on input when fully hidden. + catchPlayfield.CatcherArea.AlwaysPresent = true; catchPlayfield.CatcherArea.Alpha = (float)Interpolation.Lerp(catchPlayfield.CatcherArea.Alpha, targetAlpha, Math.Clamp(catchPlayfield.Time.Elapsed / TRANSITION_DURATION, 0, 1)); } } diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs b/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs index d0a94767d1..83ad96d5b4 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs @@ -5,6 +5,7 @@ using osu.Framework.Graphics; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Mods; @@ -14,32 +15,36 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Mods { - public class CatchModRelax : ModRelax, IApplicableToDrawableRuleset, IApplicableToPlayer + public partial class CatchModRelax : ModRelax, IApplicableToDrawableRuleset, IApplicableToPlayer { - public override string Description => @"Use the mouse to control the catcher."; + public override LocalisableString Description => @"Use the mouse to control the catcher."; - private DrawableRuleset drawableRuleset; + private DrawableCatchRuleset drawableRuleset = null!; public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { - this.drawableRuleset = drawableRuleset; + this.drawableRuleset = (DrawableCatchRuleset)drawableRuleset; } public void ApplyToPlayer(Player player) { if (!drawableRuleset.HasReplayLoaded.Value) - drawableRuleset.Cursor.Add(new MouseInputHelper((CatchPlayfield)drawableRuleset.Playfield)); + { + var catchPlayfield = (CatchPlayfield)drawableRuleset.Playfield; + catchPlayfield.CatcherArea.Add(new MouseInputHelper(catchPlayfield.CatcherArea)); + } } - private class MouseInputHelper : Drawable, IKeyBindingHandler, IRequireHighFrequencyMousePosition + private partial class MouseInputHelper : Drawable, IKeyBindingHandler, IRequireHighFrequencyMousePosition { private readonly CatcherArea catcherArea; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; - public MouseInputHelper(CatchPlayfield playfield) + public MouseInputHelper(CatcherArea catcherArea) { - catcherArea = playfield.CatcherArea; + this.catcherArea = catcherArea; + RelativeSizeAxes = Axes.Both; } diff --git a/osu.Game.Rulesets.Catch/Objects/Banana.cs b/osu.Game.Rulesets.Catch/Objects/Banana.cs index e5a36d08db..af03c9acab 100644 --- a/osu.Game.Rulesets.Catch/Objects/Banana.cs +++ b/osu.Game.Rulesets.Catch/Objects/Banana.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 enable - using System; using System.Collections.Generic; using osu.Game.Audio; diff --git a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs index ee10cf9711..f4bd515995 100644 --- a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Game.Beatmaps; @@ -17,7 +18,9 @@ namespace osu.Game.Rulesets.Catch.Objects { public const float OBJECT_RADIUS = 64; - public readonly Bindable OriginalXBindable = new Bindable(); + private HitObjectProperty originalX; + + public Bindable OriginalXBindable => originalX.Bindable; /// /// The horizontal position of the hit object between 0 and . @@ -29,18 +32,20 @@ namespace osu.Game.Rulesets.Catch.Objects [JsonIgnore] public float X { - set => OriginalXBindable.Value = value; + set => originalX.Value = value; } - public readonly Bindable XOffsetBindable = new Bindable(); + private HitObjectProperty xOffset; + + public Bindable XOffsetBindable => xOffset.Bindable; /// /// A random offset applied to the horizontal position, set by the beatmap processing. /// public float XOffset { - get => XOffsetBindable.Value; - set => XOffsetBindable.Value = value; + get => xOffset.Value; + set => xOffset.Value = value; } /// @@ -52,8 +57,8 @@ namespace osu.Game.Rulesets.Catch.Objects /// public float OriginalX { - get => OriginalXBindable.Value; - set => OriginalXBindable.Value = value; + get => originalX.Value; + set => originalX.Value = value; } /// @@ -63,63 +68,75 @@ namespace osu.Game.Rulesets.Catch.Objects /// This value is the original value plus the offset applied by the beatmap processing. /// Use if a value not affected by the offset is desired. /// - public float EffectiveX => OriginalX + XOffset; + public float EffectiveX => Math.Clamp(OriginalX + XOffset, 0, CatchPlayfield.WIDTH); public double TimePreempt { get; set; } = 1000; - public readonly Bindable IndexInBeatmapBindable = new Bindable(); + private HitObjectProperty indexInBeatmap; + + public Bindable IndexInBeatmapBindable => indexInBeatmap.Bindable; public int IndexInBeatmap { - get => IndexInBeatmapBindable.Value; - set => IndexInBeatmapBindable.Value = value; + get => indexInBeatmap.Value; + set => indexInBeatmap.Value = value; } public virtual bool NewCombo { get; set; } public int ComboOffset { get; set; } - public Bindable IndexInCurrentComboBindable { get; } = new Bindable(); + private HitObjectProperty indexInCurrentCombo; + + public Bindable IndexInCurrentComboBindable => indexInCurrentCombo.Bindable; public int IndexInCurrentCombo { - get => IndexInCurrentComboBindable.Value; - set => IndexInCurrentComboBindable.Value = value; + get => indexInCurrentCombo.Value; + set => indexInCurrentCombo.Value = value; } - public Bindable ComboIndexBindable { get; } = new Bindable(); + private HitObjectProperty comboIndex; + + public Bindable ComboIndexBindable => comboIndex.Bindable; public int ComboIndex { - get => ComboIndexBindable.Value; - set => ComboIndexBindable.Value = value; + get => comboIndex.Value; + set => comboIndex.Value = value; } - public Bindable ComboIndexWithOffsetsBindable { get; } = new Bindable(); + private HitObjectProperty comboIndexWithOffsets; + + public Bindable ComboIndexWithOffsetsBindable => comboIndexWithOffsets.Bindable; public int ComboIndexWithOffsets { - get => ComboIndexWithOffsetsBindable.Value; - set => ComboIndexWithOffsetsBindable.Value = value; + get => comboIndexWithOffsets.Value; + set => comboIndexWithOffsets.Value = value; } - public Bindable LastInComboBindable { get; } = new Bindable(); + private HitObjectProperty lastInCombo; + + public Bindable LastInComboBindable => lastInCombo.Bindable; /// /// The next fruit starts a new combo. Used for explodey. /// public virtual bool LastInCombo { - get => LastInComboBindable.Value; - set => LastInComboBindable.Value = value; + get => lastInCombo.Value; + set => lastInCombo.Value = value; } - public readonly Bindable ScaleBindable = new Bindable(1); + private HitObjectProperty scale = new HitObjectProperty(1); + + public Bindable ScaleBindable => scale.Bindable; public float Scale { - get => ScaleBindable.Value; - set => ScaleBindable.Value = value; + get => scale.Value; + set => scale.Value = value; } /// diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtBanana.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtBanana.cs index 8a91f82437..bfeb37b1b7 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtBanana.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtBanana.cs @@ -8,7 +8,7 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables /// /// Represents a caught by the catcher. /// - public class CaughtBanana : CaughtObject + public partial class CaughtBanana : CaughtObject { public CaughtBanana() : base(CatchSkinComponents.Banana, _ => new BananaPiece()) diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtDroplet.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtDroplet.cs index 4a3397feff..d228c629c0 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtDroplet.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtDroplet.cs @@ -8,7 +8,7 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables /// /// Represents a caught by the catcher. /// - public class CaughtDroplet : CaughtObject + public partial class CaughtDroplet : CaughtObject { public override bool StaysOnPlate => false; diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtFruit.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtFruit.cs index 7c88090a20..99dcac5268 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtFruit.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtFruit.cs @@ -8,7 +8,7 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables /// /// Represents a caught by the catcher. /// - public class CaughtFruit : CaughtObject + public partial class CaughtFruit : CaughtObject { public CaughtFruit() : base(CatchSkinComponents.Fruit, _ => new FruitPiece()) diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtObject.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtObject.cs index d8bce9bb6d..0c26c52171 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtObject.cs @@ -15,9 +15,9 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables /// Represents a caught by the catcher. /// [Cached(typeof(IHasCatchObjectState))] - public abstract class CaughtObject : SkinnableDrawable, IHasCatchObjectState + public abstract partial class CaughtObject : SkinnableDrawable, IHasCatchObjectState { - public PalpableCatchHitObject HitObject { get; private set; } + public PalpableCatchHitObject HitObject { get; private set; } = null!; public Bindable AccentColour { get; } = new Bindable(); public Bindable HyperDash { get; } = new Bindable(); public Bindable IndexInBeatmap { get; } = new Bindable(); @@ -26,6 +26,8 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables public float DisplayRotation => Rotation; + public double DisplayStartTime => HitObject.StartTime; + /// /// Whether this hit object should stay on the catcher plate when the object is caught by the catcher. /// @@ -33,8 +35,8 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables public override bool RemoveWhenNotAlive => true; - protected CaughtObject(CatchSkinComponents skinComponent, Func defaultImplementation) - : base(new CatchSkinComponent(skinComponent), defaultImplementation) + protected CaughtObject(CatchSkinComponents skinComponent, Func defaultImplementation) + : base(new CatchSkinComponentLookup(skinComponent), defaultImplementation) { Origin = Anchor.Centre; diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBanana.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBanana.cs index c1b41a7afc..26e304cf3f 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBanana.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBanana.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 JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Rulesets.Catch.Skinning.Default; @@ -9,14 +8,14 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Catch.Objects.Drawables { - public class DrawableBanana : DrawablePalpableCatchHitObject + public partial class DrawableBanana : DrawablePalpableCatchHitObject { public DrawableBanana() : this(null) { } - public DrawableBanana([CanBeNull] Banana h) + public DrawableBanana(Banana? h) : base(h) { } @@ -25,7 +24,7 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables private void load() { ScalingContainer.Child = new SkinnableDrawable( - new CatchSkinComponent(CatchSkinComponents.Banana), + new CatchSkinComponentLookup(CatchSkinComponents.Banana), _ => new BananaPiece()); } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBananaShower.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBananaShower.cs index 9b2f95e221..03adbce885 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBananaShower.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBananaShower.cs @@ -1,14 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using JetBrains.Annotations; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Rulesets.Catch.Objects.Drawables { - public class DrawableBananaShower : DrawableCatchHitObject + public partial class DrawableBananaShower : DrawableCatchHitObject { private readonly Container bananaContainer; @@ -17,7 +16,7 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables { } - public DrawableBananaShower([CanBeNull] BananaShower s) + public DrawableBananaShower(BananaShower? s) : base(s) { RelativeSizeAxes = Axes.X; diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs index 0c065948ef..7f8c17861d 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using JetBrains.Annotations; using osu.Framework.Bindables; @@ -13,7 +15,7 @@ using osu.Game.Utils; namespace osu.Game.Rulesets.Catch.Objects.Drawables { - public abstract class DrawableCatchHitObject : DrawableHitObject + public abstract partial class DrawableCatchHitObject : DrawableHitObject { public readonly Bindable OriginalXBindable = new Bindable(); public readonly Bindable XOffsetBindable = new Bindable(); @@ -51,6 +53,7 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables XOffsetBindable.UnbindFrom(HitObject.XOffsetBindable); } + [CanBeNull] public Func CheckPosition; protected override JudgementResult CreateResult(Judgement judgement) => new CatchJudgementResult(HitObject, judgement); diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableDroplet.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableDroplet.cs index 2dce9507a5..8f32cdcc31 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableDroplet.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableDroplet.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 JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Rulesets.Catch.Skinning.Default; @@ -9,14 +8,14 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Catch.Objects.Drawables { - public class DrawableDroplet : DrawablePalpableCatchHitObject + public partial class DrawableDroplet : DrawablePalpableCatchHitObject { public DrawableDroplet() : this(null) { } - public DrawableDroplet([CanBeNull] CatchHitObject h) + public DrawableDroplet(CatchHitObject? h) : base(h) { } @@ -25,7 +24,7 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables private void load() { ScalingContainer.Child = new SkinnableDrawable( - new CatchSkinComponent(CatchSkinComponents.Droplet), + new CatchSkinComponentLookup(CatchSkinComponents.Droplet), _ => new DropletPiece()); } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs index 0af7ee6c30..52c53523e6 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.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 JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Rulesets.Catch.Skinning.Default; @@ -9,14 +8,14 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Catch.Objects.Drawables { - public class DrawableFruit : DrawablePalpableCatchHitObject + public partial class DrawableFruit : DrawablePalpableCatchHitObject { public DrawableFruit() : this(null) { } - public DrawableFruit([CanBeNull] Fruit h) + public DrawableFruit(Fruit? h) : base(h) { } @@ -25,7 +24,7 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables private void load() { ScalingContainer.Child = new SkinnableDrawable( - new CatchSkinComponent(CatchSkinComponents.Fruit), + new CatchSkinComponentLookup(CatchSkinComponents.Fruit), _ => new FruitPiece()); } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableJuiceStream.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableJuiceStream.cs index a496a35842..41ecf59276 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableJuiceStream.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableJuiceStream.cs @@ -1,14 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using JetBrains.Annotations; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Rulesets.Catch.Objects.Drawables { - public class DrawableJuiceStream : DrawableCatchHitObject + public partial class DrawableJuiceStream : DrawableCatchHitObject { private readonly Container dropletContainer; @@ -17,7 +16,7 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables { } - public DrawableJuiceStream([CanBeNull] JuiceStream s) + public DrawableJuiceStream(JuiceStream? s) : base(s) { RelativeSizeAxes = Axes.X; diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawablePalpableCatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawablePalpableCatchHitObject.cs index 27cd7ed2bc..4a9661f108 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawablePalpableCatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawablePalpableCatchHitObject.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 JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -12,10 +11,12 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.Objects.Drawables { [Cached(typeof(IHasCatchObjectState))] - public abstract class DrawablePalpableCatchHitObject : DrawableCatchHitObject, IHasCatchObjectState + public abstract partial class DrawablePalpableCatchHitObject : DrawableCatchHitObject, IHasCatchObjectState { public new PalpableCatchHitObject HitObject => (PalpableCatchHitObject)base.HitObject; + public double DisplayStartTime => LifetimeStart; + Bindable IHasCatchObjectState.AccentColour => AccentColour; public Bindable HyperDash { get; } = new Bindable(); @@ -38,7 +39,7 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables public float DisplayRotation => ScalingContainer.Rotation; - protected DrawablePalpableCatchHitObject([CanBeNull] CatchHitObject h) + protected DrawablePalpableCatchHitObject(CatchHitObject? h) : base(h) { Origin = Anchor.Centre; diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableTinyDroplet.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableTinyDroplet.cs index 8f5a04dfda..f820ccdc62 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableTinyDroplet.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableTinyDroplet.cs @@ -1,11 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using JetBrains.Annotations; - namespace osu.Game.Rulesets.Catch.Objects.Drawables { - public class DrawableTinyDroplet : DrawableDroplet + public partial class DrawableTinyDroplet : DrawableDroplet { protected override float ScaleFactor => base.ScaleFactor / 2; @@ -14,7 +12,7 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables { } - public DrawableTinyDroplet([CanBeNull] TinyDroplet h) + public DrawableTinyDroplet(TinyDroplet? h) : base(h) { } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/IHasCatchObjectState.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/IHasCatchObjectState.cs index be0ee2821e..18fc0db6e3 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/IHasCatchObjectState.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/IHasCatchObjectState.cs @@ -14,6 +14,8 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables { PalpableCatchHitObject HitObject { get; } + double DisplayStartTime { get; } + Bindable AccentColour { get; } Bindable HyperDash { get; } diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs index d34452cdbb..96e2d5c4e5 100644 --- a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs +++ b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs @@ -9,6 +9,7 @@ using Newtonsoft.Json; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; @@ -82,8 +83,8 @@ namespace osu.Game.Rulesets.Catch.Objects AddNested(new TinyDroplet { StartTime = t + lastEvent.Value.Time, - X = OriginalX + Path.PositionAt( - lastEvent.Value.PathProgress + (t / sinceLastTick) * (e.PathProgress - lastEvent.Value.PathProgress)).X, + X = ClampToPlayfield(EffectiveX + Path.PositionAt( + lastEvent.Value.PathProgress + (t / sinceLastTick) * (e.PathProgress - lastEvent.Value.PathProgress)).X), }); } } @@ -100,7 +101,7 @@ namespace osu.Game.Rulesets.Catch.Objects { Samples = dropletSamples, StartTime = e.Time, - X = OriginalX + Path.PositionAt(e.PathProgress).X, + X = ClampToPlayfield(EffectiveX + Path.PositionAt(e.PathProgress).X), }); break; @@ -111,14 +112,16 @@ namespace osu.Game.Rulesets.Catch.Objects { Samples = this.GetNodeSamples(nodeIndex++), StartTime = e.Time, - X = OriginalX + Path.PositionAt(e.PathProgress).X, + X = ClampToPlayfield(EffectiveX + Path.PositionAt(e.PathProgress).X), }); break; } } } - public float EndX => OriginalX + this.CurvePositionAt(1).X; + public float EndX => ClampToPlayfield(EffectiveX + this.CurvePositionAt(1).X); + + public float ClampToPlayfield(float value) => Math.Clamp(value, 0, CatchPlayfield.WIDTH); [JsonIgnore] public double Duration @@ -137,13 +140,8 @@ namespace osu.Game.Rulesets.Catch.Objects set { path.ControlPoints.Clear(); - path.ExpectedDistance.Value = null; - - if (value != null) - { - path.ControlPoints.AddRange(value.ControlPoints.Select(c => new PathControlPoint(c.Position, c.Type))); - path.ExpectedDistance.Value = value.ExpectedDistance.Value; - } + path.ControlPoints.AddRange(value.ControlPoints.Select(c => new PathControlPoint(c.Position, c.Type))); + path.ExpectedDistance.Value = value.ExpectedDistance.Value; } } diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStreamPath.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStreamPath.cs index 61f4c580ae..0633151ddd 100644 --- a/osu.Game.Rulesets.Catch/Objects/JuiceStreamPath.cs +++ b/osu.Game.Rulesets.Catch/Objects/JuiceStreamPath.cs @@ -9,8 +9,6 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osuTK; -#nullable enable - namespace osu.Game.Rulesets.Catch.Objects { /// diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStreamPathVertex.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStreamPathVertex.cs index afef2e637f..96ed82bca3 100644 --- a/osu.Game.Rulesets.Catch/Objects/JuiceStreamPathVertex.cs +++ b/osu.Game.Rulesets.Catch/Objects/JuiceStreamPathVertex.cs @@ -3,8 +3,6 @@ using System; -#nullable enable - namespace osu.Game.Rulesets.Catch.Objects { /// diff --git a/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs index 4001a4ea76..197029aeeb 100644 --- a/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs @@ -3,6 +3,7 @@ using Newtonsoft.Json; using osu.Framework.Bindables; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Skinning; using osuTK.Graphics; @@ -22,20 +23,22 @@ namespace osu.Game.Rulesets.Catch.Objects /// public float DistanceToHyperDash { get; set; } - public readonly Bindable HyperDashBindable = new Bindable(); + private HitObjectProperty hyperDash; + + public Bindable HyperDashBindable => hyperDash.Bindable; /// /// Whether this fruit can initiate a hyperdash. /// - public bool HyperDash => HyperDashBindable.Value; + public bool HyperDash => hyperDash.Value; - private CatchHitObject hyperDashTarget; + private CatchHitObject? hyperDashTarget; /// /// The target fruit if we are to initiate a hyperdash. /// [JsonIgnore] - public CatchHitObject HyperDashTarget + public CatchHitObject? HyperDashTarget { get => hyperDashTarget; set diff --git a/osu.Game.Rulesets.Catch/Replays/CatchReplayFrame.cs b/osu.Game.Rulesets.Catch/Replays/CatchReplayFrame.cs index 1a80adb584..e30e535e9b 100644 --- a/osu.Game.Rulesets.Catch/Replays/CatchReplayFrame.cs +++ b/osu.Game.Rulesets.Catch/Replays/CatchReplayFrame.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Catch.Replays { } - public CatchReplayFrame(double time, float? position = null, bool dashing = false, CatchReplayFrame lastFrame = null) + public CatchReplayFrame(double time, float? position = null, bool dashing = false, CatchReplayFrame? lastFrame = null) : base(time) { Position = position ?? -1; @@ -38,7 +38,7 @@ namespace osu.Game.Rulesets.Catch.Replays } } - public void FromLegacy(LegacyReplayFrame currentFrame, IBeatmap beatmap, ReplayFrame lastFrame = null) + public void FromLegacy(LegacyReplayFrame currentFrame, IBeatmap beatmap, ReplayFrame? lastFrame = null) { Position = currentFrame.Position.X; Dashing = currentFrame.ButtonState == ReplayButtonState.Left1; diff --git a/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs b/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs index 51b1ccaaba..b6a42407da 100644 --- a/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs +++ b/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs @@ -5,7 +5,7 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Catch.Scoring { - public class CatchScoreProcessor : ScoreProcessor + public partial class CatchScoreProcessor : ScoreProcessor { public CatchScoreProcessor() : base(new CatchRuleset()) diff --git a/osu.Game.Rulesets.Catch/Skinning/Argon/ArgonBananaPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Argon/ArgonBananaPiece.cs new file mode 100644 index 0000000000..8cdb490922 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Skinning/Argon/ArgonBananaPiece.cs @@ -0,0 +1,122 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Rulesets.Catch.Objects; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Skinning.Argon +{ + internal partial class ArgonBananaPiece : ArgonFruitPiece + { + private Container stabilisedPieceContainer = null!; + + private Drawable fadeContent = null!; + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(fadeContent = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + stabilisedPieceContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new Circle + { + Colour = Color4.White.Opacity(0.4f), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Blending = BlendingParameters.Additive, + Size = new Vector2(8), + Scale = new Vector2(25, 1), + }, + new Box + { + Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(0), Color4.White.Opacity(0.8f)), + RelativeSizeAxes = Axes.X, + Blending = BlendingParameters.Additive, + Anchor = Anchor.Centre, + Origin = Anchor.CentreRight, + Width = 1.6f, + Height = 2, + }, + new Circle + { + Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(0.8f), Color4.White.Opacity(0)), + RelativeSizeAxes = Axes.X, + Blending = BlendingParameters.Additive, + Anchor = Anchor.Centre, + Origin = Anchor.CentreLeft, + Width = 1.6f, + Height = 2, + }, + } + }, + new Circle + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(1.2f), + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Hollow = false, + Colour = Color4.White.Opacity(0.1f), + Radius = 50, + }, + Child = + { + Alpha = 0, + AlwaysPresent = true, + }, + BorderColour = Color4.White.Opacity(0.1f), + BorderThickness = 3, + }, + } + }); + } + + protected override void Update() + { + base.Update(); + + const float parent_scale_application = 0.4f; + + // relative to time on screen + const float lens_flare_start = 0.3f; + const float lens_flare_end = 0.8f; + + // Undo some of the parent scale being applied to make the lens flare feel a bit better.. + float scale = parent_scale_application + (1 - parent_scale_application) * (1 / (ObjectState.DisplaySize.X / (CatchHitObject.OBJECT_RADIUS * 2))); + + stabilisedPieceContainer.Rotation = -ObjectState.DisplayRotation; + stabilisedPieceContainer.Scale = new Vector2(scale, 1); + + double duration = ObjectState.HitObject.StartTime - ObjectState.DisplayStartTime; + + fadeContent.Alpha = MathHelper.Clamp( + Interpolation.ValueAt( + Time.Current, 1f, 0f, + ObjectState.DisplayStartTime + duration * lens_flare_start, + ObjectState.DisplayStartTime + duration * lens_flare_end, + Easing.OutQuint + ), 0, 1); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Skinning/Argon/ArgonCatcher.cs b/osu.Game.Rulesets.Catch/Skinning/Argon/ArgonCatcher.cs new file mode 100644 index 0000000000..82374085c8 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Skinning/Argon/ArgonCatcher.cs @@ -0,0 +1,85 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Catch.UI; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Skinning.Argon +{ + public partial class ArgonCatcher : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + Height = 10, + Children = new Drawable[] + { + new Circle + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = Color4.White, + Width = Catcher.ALLOWED_CATCH_RANGE, + }, + new Box + { + Name = "long line left", + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreRight, + Colour = Color4.White, + Alpha = 0.25f, + RelativeSizeAxes = Axes.X, + Width = 20, + Height = 1.8f, + }, + new Circle + { + Name = "bumper left", + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Colour = Color4.White, + RelativeSizeAxes = Axes.X, + Width = (1 - Catcher.ALLOWED_CATCH_RANGE) / 2, + Height = 4, + }, + new Box + { + Name = "long line right", + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreLeft, + Colour = Color4.White, + Alpha = 0.25f, + RelativeSizeAxes = Axes.X, + Width = 20, + Height = 1.8f, + }, + new Circle + { + Name = "bumper right", + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Colour = Color4.White, + RelativeSizeAxes = Axes.X, + Width = (1 - Catcher.ALLOWED_CATCH_RANGE) / 2, + Height = 4, + }, + } + }, + }; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Skinning/Argon/ArgonDropletPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Argon/ArgonDropletPiece.cs new file mode 100644 index 0000000000..38fe7916f5 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Skinning/Argon/ArgonDropletPiece.cs @@ -0,0 +1,121 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Utils; +using osu.Game.Rulesets.Catch.Skinning.Default; +using osu.Game.Rulesets.Catch.UI; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Skinning.Argon +{ + internal partial class ArgonDropletPiece : CatchHitObjectPiece + { + protected override Drawable HyperBorderPiece => hyperBorderPiece; + + private Drawable hyperBorderPiece = null!; + + private Container layers = null!; + + private float rotationRandomness; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + + const float droplet_scale_down = 0.7f; + + int largeBlobSeed = RNG.Next(); + + InternalChildren = new[] + { + new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(20), + }, + layers = new Container + { + Scale = new Vector2(droplet_scale_down), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new CircularBlob + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + InnerRadius = 0.5f, + Alpha = 0.15f, + Seed = largeBlobSeed + }, + new CircularBlob + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + InnerRadius = 0.4f, + Alpha = 0.5f, + Scale = new Vector2(0.7f), + Seed = RNG.Next() + }, + } + }, + hyperBorderPiece = new CircularBlob + { + Scale = new Vector2(droplet_scale_down), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR, + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + InnerRadius = 0.5f, + Alpha = 0.15f, + Seed = largeBlobSeed + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AccentColour.BindValueChanged(colour => + { + foreach (var sprite in layers) + sprite.Colour = colour.NewValue; + }, true); + + rotationRandomness = RNG.NextSingle(0.2f, 1); + } + + protected override void Update() + { + base.Update(); + + // Note that droplets are rotated at a higher level, so this is mostly just to create more + // random arrangements of the multiple layers than actually rotate. + // + // Because underlying rotation is always clockwise, we apply anti-clockwise resistance to avoid + // making things spin too fast. + for (int i = 0; i < layers.Count; i++) + { + layers[i].Rotation -= + (float)Clock.ElapsedFrameTime + * 0.4f * rotationRandomness + // Each layer should alternate rotation speed. + * (i % 2 == 1 ? 0.5f : 1); + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/Skinning/Argon/ArgonFruitPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Argon/ArgonFruitPiece.cs new file mode 100644 index 0000000000..4b5319e859 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Skinning/Argon/ArgonFruitPiece.cs @@ -0,0 +1,121 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Utils; +using osu.Game.Rulesets.Catch.Skinning.Default; +using osu.Game.Rulesets.Catch.UI; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Skinning.Argon +{ + internal partial class ArgonFruitPiece : CatchHitObjectPiece + { + protected override Drawable HyperBorderPiece => hyperBorderPiece; + + private Drawable hyperBorderPiece = null!; + + private Container layers = null!; + + private float rotationRandomness; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + + int largeBlobSeed = RNG.Next(); + + InternalChildren = new[] + { + new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(20), + }, + layers = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new CircularBlob + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Alpha = 0.15f, + InnerRadius = 0.5f, + Size = new Vector2(1.1f), + Seed = largeBlobSeed, + }, + new CircularBlob + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + InnerRadius = 0.2f, + Alpha = 0.5f, + Seed = RNG.Next(), + }, + new CircularBlob + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + InnerRadius = 0.05f, + Seed = RNG.Next(), + }, + } + }, + hyperBorderPiece = new CircularBlob + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR, + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + InnerRadius = 0.08f, + Size = new Vector2(1.15f), + Seed = largeBlobSeed + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AccentColour.BindValueChanged(colour => + { + foreach (var sprite in layers) + sprite.Colour = colour.NewValue; + }, true); + + rotationRandomness = RNG.NextSingle(0.2f, 1) * (RNG.NextBool() ? -1 : 1); + } + + protected override void Update() + { + base.Update(); + + for (int i = 0; i < layers.Count; i++) + { + layers[i].Rotation += + // Layers are ordered from largest to smallest. Smaller layers should rotate more. + (i * 2) + * (float)Clock.ElapsedFrameTime + * 0.02f * rotationRandomness + // Each layer should alternate rotation direction. + * (i % 2 == 1 ? 1 : -1); + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/Skinning/Argon/ArgonHitExplosion.cs b/osu.Game.Rulesets.Catch/Skinning/Argon/ArgonHitExplosion.cs new file mode 100644 index 0000000000..a3c0c8c108 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Skinning/Argon/ArgonHitExplosion.cs @@ -0,0 +1,112 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.UI; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Skinning.Argon +{ + public partial class ArgonHitExplosion : CompositeDrawable, IHitExplosion + { + public override bool RemoveWhenNotAlive => true; + + private Container tallExplosion = null!; + private Container largeFaint = null!; + + private readonly Bindable accentColour = new Bindable(); + + public ArgonHitExplosion() + { + Size = new Vector2(20); + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + tallExplosion = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + Width = 0.1f, + Child = new Box + { + AlwaysPresent = true, + Alpha = 0, + RelativeSizeAxes = Axes.Both, + }, + }, + largeFaint = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + Child = new Box + { + AlwaysPresent = true, + Alpha = 0, + RelativeSizeAxes = Axes.Both, + }, + }, + }; + + accentColour.BindValueChanged(colour => + { + tallExplosion.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = colour.NewValue, + Hollow = false, + Roundness = 15, + Radius = 15, + }; + + largeFaint.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.2f, colour.NewValue, Color4.White, 0, 1), + Hollow = false, + Radius = 50, + }; + }, true); + } + + public void Animate(HitExplosionEntry entry) + { + X = entry.Position; + Scale = new Vector2(entry.HitObject.Scale); + accentColour.Value = entry.ObjectColour; + + using (BeginAbsoluteSequence(entry.LifetimeStart)) + { + this.FadeOutFromOne(400); + + if (!(entry.HitObject is Droplet)) + { + float scale = Math.Clamp(entry.JudgementResult.ComboAtJudgement / 200f, 0.35f, 1.125f); + + tallExplosion + .ScaleTo(new Vector2(1.1f, 20 * scale), 200, Easing.OutQuint) + .Then() + .ScaleTo(new Vector2(1.1f, 1), 600, Easing.In); + } + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/Skinning/Argon/CatchArgonSkinTransformer.cs b/osu.Game.Rulesets.Catch/Skinning/Argon/CatchArgonSkinTransformer.cs new file mode 100644 index 0000000000..520c2de248 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Skinning/Argon/CatchArgonSkinTransformer.cs @@ -0,0 +1,46 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Catch.Skinning.Argon +{ + public class CatchArgonSkinTransformer : SkinTransformer + { + public CatchArgonSkinTransformer(ISkin skin) + : base(skin) + { + } + + public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) + { + switch (lookup) + { + case CatchSkinComponentLookup catchComponent: + // TODO: Once everything is finalised, consider throwing UnsupportedSkinComponentException on missing entries. + switch (catchComponent.Component) + { + case CatchSkinComponents.HitExplosion: + return new ArgonHitExplosion(); + + case CatchSkinComponents.Catcher: + return new ArgonCatcher(); + + case CatchSkinComponents.Fruit: + return new ArgonFruitPiece(); + + case CatchSkinComponents.Banana: + return new ArgonBananaPiece(); + + case CatchSkinComponents.Droplet: + return new ArgonDropletPiece(); + } + + break; + } + + return base.GetDrawableComponent(lookup); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/BananaPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Default/BananaPiece.cs index 8da18a668a..80ee9d5a0c 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Default/BananaPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Default/BananaPiece.cs @@ -5,15 +5,15 @@ using osu.Framework.Graphics; namespace osu.Game.Rulesets.Catch.Skinning.Default { - public class BananaPiece : CatchHitObjectPiece + public partial class BananaPiece : CatchHitObjectPiece { - protected override BorderPiece BorderPiece { get; } + protected override Drawable BorderPiece { get; } public BananaPiece() { RelativeSizeAxes = Axes.Both; - InternalChildren = new Drawable[] + InternalChildren = new[] { new BananaPulpFormation { diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/BananaPulpFormation.cs b/osu.Game.Rulesets.Catch/Skinning/Default/BananaPulpFormation.cs index ee1cc68f7d..26c8558e74 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Default/BananaPulpFormation.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Default/BananaPulpFormation.cs @@ -5,7 +5,7 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Skinning.Default { - public class BananaPulpFormation : PulpFormation + public partial class BananaPulpFormation : PulpFormation { public BananaPulpFormation() { diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/BorderPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Default/BorderPiece.cs index 8d8ee49af7..f0bdcfa1ba 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Default/BorderPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Default/BorderPiece.cs @@ -9,7 +9,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.Skinning.Default { - public class BorderPiece : Circle + public partial class BorderPiece : Circle { public BorderPiece() { diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/CatchHitObjectPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Default/CatchHitObjectPiece.cs index 2db3bae034..7cc425966d 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Default/CatchHitObjectPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Default/CatchHitObjectPiece.cs @@ -2,35 +2,33 @@ // See the LICENCE file in the repository root for full licence text. using System; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Catch.Objects.Drawables; using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.Skinning.Default { - public abstract class CatchHitObjectPiece : CompositeDrawable + public abstract partial class CatchHitObjectPiece : CompositeDrawable { public readonly Bindable AccentColour = new Bindable(); public readonly Bindable HyperDash = new Bindable(); public readonly Bindable IndexInBeatmap = new Bindable(); [Resolved] - protected IHasCatchObjectState ObjectState { get; private set; } + protected IHasCatchObjectState ObjectState { get; private set; } = null!; /// /// A part of this piece that will be faded out while falling in the playfield. /// - [CanBeNull] - protected virtual BorderPiece BorderPiece => null; + protected virtual Drawable? BorderPiece => null; /// /// A part of this piece that will be only visible when is true. /// - [CanBeNull] - protected virtual HyperBorderPiece HyperBorderPiece => null; + protected virtual Drawable? HyperBorderPiece => null; protected override void LoadComplete() { diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/DefaultCatcher.cs b/osu.Game.Rulesets.Catch/Skinning/Default/DefaultCatcher.cs index e423f21b98..72208b763b 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Default/DefaultCatcher.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Default/DefaultCatcher.cs @@ -12,7 +12,7 @@ using osu.Game.Rulesets.Catch.UI; namespace osu.Game.Rulesets.Catch.Skinning.Default { - public class DefaultCatcher : CompositeDrawable + public partial class DefaultCatcher : CompositeDrawable { public Bindable CurrentState { get; } = new Bindable(); diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/DefaultHitExplosion.cs b/osu.Game.Rulesets.Catch/Skinning/Default/DefaultHitExplosion.cs index f6b3c3d665..3af178fe4d 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Default/DefaultHitExplosion.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Default/DefaultHitExplosion.cs @@ -14,12 +14,12 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.Skinning.Default { - public class DefaultHitExplosion : CompositeDrawable, IHitExplosion + public partial class DefaultHitExplosion : CompositeDrawable, IHitExplosion { - private CircularContainer largeFaint; - private CircularContainer smallFaint; - private CircularContainer directionalGlow1; - private CircularContainer directionalGlow2; + private CircularContainer largeFaint = null!; + private CircularContainer smallFaint = null!; + private CircularContainer directionalGlow1 = null!; + private CircularContainer directionalGlow2 = null!; [BackgroundDependencyLoader] private void load() diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/DropletPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Default/DropletPiece.cs index 8b1052dfe2..f002457b61 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Default/DropletPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Default/DropletPiece.cs @@ -7,15 +7,15 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Skinning.Default { - public class DropletPiece : CatchHitObjectPiece + public partial class DropletPiece : CatchHitObjectPiece { - protected override HyperBorderPiece HyperBorderPiece { get; } + protected override Drawable HyperBorderPiece { get; } public DropletPiece() { Size = new Vector2(CatchHitObject.OBJECT_RADIUS / 2); - InternalChildren = new Drawable[] + InternalChildren = new[] { new Pulp { diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/FruitPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Default/FruitPiece.cs index cfe0df0c97..31e30d8073 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Default/FruitPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Default/FruitPiece.cs @@ -7,7 +7,7 @@ using osu.Game.Rulesets.Catch.Objects; namespace osu.Game.Rulesets.Catch.Skinning.Default { - internal class FruitPiece : CatchHitObjectPiece + internal partial class FruitPiece : CatchHitObjectPiece { /// /// Because we're adding a border around the fruit, we need to scale down some. @@ -16,14 +16,14 @@ namespace osu.Game.Rulesets.Catch.Skinning.Default public readonly Bindable VisualRepresentation = new Bindable(); - protected override BorderPiece BorderPiece { get; } - protected override HyperBorderPiece HyperBorderPiece { get; } + protected override Drawable BorderPiece { get; } + protected override Drawable HyperBorderPiece { get; } public FruitPiece() { RelativeSizeAxes = Axes.Both; - InternalChildren = new Drawable[] + InternalChildren = new[] { new FruitPulpFormation { diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/FruitPulpFormation.cs b/osu.Game.Rulesets.Catch/Skinning/Default/FruitPulpFormation.cs index f097361d2a..c031e50e34 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Default/FruitPulpFormation.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Default/FruitPulpFormation.cs @@ -7,7 +7,7 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Skinning.Default { - public class FruitPulpFormation : PulpFormation + public partial class FruitPulpFormation : PulpFormation { public readonly Bindable VisualRepresentation = new Bindable(); diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/HyperBorderPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Default/HyperBorderPiece.cs index c8895f32f4..c025c8e435 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Default/HyperBorderPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Default/HyperBorderPiece.cs @@ -6,7 +6,7 @@ using osu.Game.Rulesets.Catch.UI; namespace osu.Game.Rulesets.Catch.Skinning.Default { - public class HyperBorderPiece : BorderPiece + public partial class HyperBorderPiece : BorderPiece { public HyperBorderPiece() { diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/HyperDropletBorderPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Default/HyperDropletBorderPiece.cs index 53a487b97f..1462bc3515 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Default/HyperDropletBorderPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Default/HyperDropletBorderPiece.cs @@ -3,7 +3,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Default { - public class HyperDropletBorderPiece : HyperBorderPiece + public partial class HyperDropletBorderPiece : HyperBorderPiece { public HyperDropletBorderPiece() { diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/Pulp.cs b/osu.Game.Rulesets.Catch/Skinning/Default/Pulp.cs index 96c6233b41..4b4d6f3788 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Default/Pulp.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Default/Pulp.cs @@ -10,7 +10,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.Skinning.Default { - public class Pulp : Circle + public partial class Pulp : Circle { public readonly Bindable AccentColour = new Bindable(); diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/PulpFormation.cs b/osu.Game.Rulesets.Catch/Skinning/Default/PulpFormation.cs index 8753aa4077..21502e6fa0 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Default/PulpFormation.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Default/PulpFormation.cs @@ -10,7 +10,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.Skinning.Default { - public abstract class PulpFormation : CompositeDrawable + public abstract partial class PulpFormation : CompositeDrawable { public readonly Bindable AccentColour = new Bindable(); diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs index 04b522b404..fb8af9bdb6 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs @@ -4,7 +4,7 @@ using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Game.Screens.Play.HUD; +using osu.Framework.Graphics.Containers; using osu.Game.Skinning; using osuTK.Graphics; @@ -12,6 +12,10 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy { public class CatchLegacySkinTransformer : LegacySkinTransformer { + public override bool IsProvidingLegacyResources => base.IsProvidingLegacyResources || hasPear; + + private bool hasPear => GetTexture("fruit-pear") != null; + /// /// For simplicity, let's use legacy combo font texture existence as a way to identify legacy skins from default. /// @@ -22,14 +26,14 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy { } - public override Drawable GetDrawableComponent(ISkinComponent component) + public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) { - if (component is SkinnableTargetComponent targetComponent) + if (lookup is SkinComponentsContainerLookup containerLookup) { - switch (targetComponent.Target) + switch (containerLookup.Target) { - case SkinnableTarget.MainHUDComponents: - var components = base.GetDrawableComponent(component) as SkinnableTargetComponentsContainer; + case SkinComponentsContainerLookup.TargetArea.MainHUDComponents: + var components = base.GetDrawableComponent(lookup) as Container; if (providesComboCounter && components != null) { @@ -43,12 +47,12 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy } } - if (component is CatchSkinComponent catchSkinComponent) + if (lookup is CatchSkinComponentLookup catchSkinComponent) { switch (catchSkinComponent.Component) { case CatchSkinComponents.Fruit: - if (GetTexture("fruit-pear") != null) + if (hasPear) return new LegacyFruitPiece(); return null; @@ -92,11 +96,11 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy return null; default: - throw new UnsupportedSkinComponentException(component); + throw new UnsupportedSkinComponentException(lookup); } } - return base.GetDrawableComponent(component); + return base.GetDrawableComponent(lookup); } private bool hasOldStyleCatcherSprite() => @@ -107,12 +111,12 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy GetTexture(@"fruit-catcher-idle") != null || GetTexture(@"fruit-catcher-idle-0") != null; - public override IBindable GetConfig(TLookup lookup) + public override IBindable? GetConfig(TLookup lookup) { switch (lookup) { case CatchSkinColour colour: - var result = (Bindable)base.GetConfig(new SkinCustomColourLookup(colour)); + var result = (Bindable?)base.GetConfig(new SkinCustomColourLookup(colour)); if (result == null) return null; @@ -124,7 +128,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy { case CatchSkinConfiguration.FlipCatcherPlate: // Don't flip catcher plate contents if the catcher is provided by this legacy skin. - if (GetDrawableComponent(new CatchSkinComponent(CatchSkinComponents.Catcher)) != null) + if (GetDrawableComponent(new CatchSkinComponentLookup(CatchSkinComponents.Catcher)) != null) return (IBindable)new Bindable(); break; diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyBananaPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyBananaPiece.cs index 5bd5b0d4bb..26832b7271 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyBananaPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyBananaPiece.cs @@ -5,14 +5,14 @@ using osu.Framework.Graphics.Textures; namespace osu.Game.Rulesets.Catch.Skinning.Legacy { - public class LegacyBananaPiece : LegacyCatchHitObjectPiece + public partial class LegacyBananaPiece : LegacyCatchHitObjectPiece { protected override void LoadComplete() { base.LoadComplete(); - Texture texture = Skin.GetTexture("fruit-bananas"); - Texture overlayTexture = Skin.GetTexture("fruit-bananas-overlay"); + Texture? texture = Skin.GetTexture("fruit-bananas"); + Texture? overlayTexture = Skin.GetTexture("fruit-bananas-overlay"); SetTexture(texture, overlayTexture); } diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchComboCounter.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchComboCounter.cs index b4d29988d9..eba837a52d 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchComboCounter.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchComboCounter.cs @@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy /// /// A combo counter implementation that visually behaves almost similar to stable's osu!catch combo counter. /// - public class LegacyCatchComboCounter : CompositeDrawable, ICatchComboCounter + public partial class LegacyCatchComboCounter : CompositeDrawable, ICatchComboCounter { private readonly LegacyRollingCounter counter; diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchHitObjectPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchHitObjectPiece.cs index f78724615a..2184ecc363 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchHitObjectPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchHitObjectPiece.cs @@ -15,21 +15,22 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.Skinning.Legacy { - public abstract class LegacyCatchHitObjectPiece : PoolableDrawable + public abstract partial class LegacyCatchHitObjectPiece : PoolableDrawable { - public readonly Bindable AccentColour = new Bindable(); - public readonly Bindable HyperDash = new Bindable(); - public readonly Bindable IndexInBeatmap = new Bindable(); + protected readonly Bindable IndexInBeatmap = new Bindable(); + + private readonly Bindable accentColour = new Bindable(); + private readonly Bindable hyperDash = new Bindable(); private readonly Sprite colouredSprite; private readonly Sprite overlaySprite; private readonly Sprite hyperSprite; [Resolved] - protected ISkinSource Skin { get; private set; } + protected ISkinSource Skin { get; private set; } = null!; [Resolved] - protected IHasCatchObjectState ObjectState { get; private set; } + protected IHasCatchObjectState ObjectState { get; private set; } = null!; protected LegacyCatchHitObjectPiece() { @@ -63,26 +64,26 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy { base.LoadComplete(); - AccentColour.BindTo(ObjectState.AccentColour); - HyperDash.BindTo(ObjectState.HyperDash); + accentColour.BindTo(ObjectState.AccentColour); + hyperDash.BindTo(ObjectState.HyperDash); IndexInBeatmap.BindTo(ObjectState.IndexInBeatmap); hyperSprite.Colour = Skin.GetConfig(CatchSkinColour.HyperDashFruit)?.Value ?? Skin.GetConfig(CatchSkinColour.HyperDash)?.Value ?? Catcher.DEFAULT_HYPER_DASH_COLOUR; - AccentColour.BindValueChanged(colour => + accentColour.BindValueChanged(colour => { colouredSprite.Colour = LegacyColourCompatibility.DisallowZeroAlpha(colour.NewValue); }, true); - HyperDash.BindValueChanged(hyper => + hyperDash.BindValueChanged(hyper => { hyperSprite.Alpha = hyper.NewValue ? 0.7f : 0; }, true); } - protected void SetTexture(Texture texture, Texture overlayTexture) + protected void SetTexture(Texture? texture, Texture? overlayTexture) { colouredSprite.Texture = texture; overlaySprite.Texture = overlayTexture; diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatcherNew.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatcherNew.cs index 9df87c92ea..f6b2c52498 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatcherNew.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatcherNew.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -15,14 +14,14 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Skinning.Legacy { - public class LegacyCatcherNew : CompositeDrawable + public partial class LegacyCatcherNew : CompositeDrawable { [Resolved] - private Bindable currentState { get; set; } + private Bindable currentState { get; set; } = null!; private readonly Dictionary drawables = new Dictionary(); - private Drawable currentDrawable; + private Drawable currentDrawable = null!; public LegacyCatcherNew() { @@ -32,7 +31,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy [BackgroundDependencyLoader] private void load(ISkinSource skin) { - foreach (var state in Enum.GetValues(typeof(CatcherAnimationState)).Cast()) + foreach (var state in Enum.GetValues()) { AddInternal(drawables[state] = getDrawableFor(state).With(d => { @@ -49,7 +48,8 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy Drawable getDrawableFor(CatcherAnimationState state) => skin.GetAnimation(@$"fruit-catcher-{state.ToString().ToLowerInvariant()}", true, true, true) ?? - skin.GetAnimation(@"fruit-catcher-idle", true, true, true); + skin.GetAnimation(@"fruit-catcher-idle", true, true, true) ?? + Empty(); } protected override void LoadComplete() diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatcherOld.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatcherOld.cs index 3e679171b2..1e21d8eab1 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatcherOld.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatcherOld.cs @@ -9,7 +9,7 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Skinning.Legacy { - public class LegacyCatcherOld : CompositeDrawable + public partial class LegacyCatcherOld : CompositeDrawable { public LegacyCatcherOld() { @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy [BackgroundDependencyLoader] private void load(ISkinSource skin) { - InternalChild = skin.GetAnimation(@"fruit-ryuuta", true, true, true).With(d => + InternalChild = (skin.GetAnimation(@"fruit-ryuuta", true, true, true) ?? Empty()).With(d => { d.Anchor = Anchor.TopCentre; d.Origin = Anchor.TopCentre; diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyDropletPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyDropletPiece.cs index 2c5cbe1e41..7ffd682698 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyDropletPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyDropletPiece.cs @@ -6,7 +6,7 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Skinning.Legacy { - public class LegacyDropletPiece : LegacyCatchHitObjectPiece + public partial class LegacyDropletPiece : LegacyCatchHitObjectPiece { public LegacyDropletPiece() { @@ -17,8 +17,8 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy { base.LoadComplete(); - Texture texture = Skin.GetTexture("fruit-drop"); - Texture overlayTexture = Skin.GetTexture("fruit-drop-overlay"); + Texture? texture = Skin.GetTexture("fruit-drop"); + Texture? overlayTexture = Skin.GetTexture("fruit-drop-overlay"); SetTexture(texture, overlayTexture); } diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyFruitPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyFruitPiece.cs index f002bab219..85b60561dd 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyFruitPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyFruitPiece.cs @@ -5,7 +5,7 @@ using osu.Game.Rulesets.Catch.Objects; namespace osu.Game.Rulesets.Catch.Skinning.Legacy { - internal class LegacyFruitPiece : LegacyCatchHitObjectPiece + internal partial class LegacyFruitPiece : LegacyCatchHitObjectPiece { protected override void LoadComplete() { diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyHitExplosion.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyHitExplosion.cs index c262b0a4ac..47660503dc 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyHitExplosion.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyHitExplosion.cs @@ -13,10 +13,10 @@ using osuTK; namespace osu.Game.Rulesets.Catch.Skinning.Legacy { - public class LegacyHitExplosion : CompositeDrawable, IHitExplosion + public partial class LegacyHitExplosion : CompositeDrawable, IHitExplosion { [Resolved] - private Catcher catcher { get; set; } + private Catcher catcher { get; set; } = null!; private const float catch_margin = (1 - Catcher.ALLOWED_CATCH_RANGE) / 2; @@ -54,7 +54,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy [BackgroundDependencyLoader] private void load(SkinManager skins) { - var defaultLegacySkin = skins.DefaultLegacySkin; + var defaultLegacySkin = skins.DefaultClassicSkin; // sprite names intentionally swapped to match stable member naming / ease of cross-referencing explosion1.Texture = defaultLegacySkin.GetTexture("scoreboard-explosion-2"); diff --git a/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs b/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs index ad344ff2dd..3d0062d32f 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs @@ -1,10 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play; using osu.Game.Skinning; using osuTK.Graphics; @@ -13,18 +16,33 @@ namespace osu.Game.Rulesets.Catch.UI /// /// Represents a component that displays a skinned and handles combo judgement results for updating it accordingly. /// - public class CatchComboDisplay : SkinnableDrawable + public partial class CatchComboDisplay : SkinnableDrawable { private int currentCombo; - [CanBeNull] - public ICatchComboCounter ComboCounter => Drawable as ICatchComboCounter; + public ICatchComboCounter? ComboCounter => Drawable as ICatchComboCounter; + + private readonly IBindable showCombo = new BindableBool(true); public CatchComboDisplay() - : base(new CatchSkinComponent(CatchSkinComponents.CatchComboCounter), _ => Empty()) + : base(new CatchSkinComponentLookup(CatchSkinComponents.CatchComboCounter), _ => Empty()) { } + [Resolved] + private Player? player { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (player != null) + { + showCombo.BindTo(player.ShowingOverlayComponents); + showCombo.BindValueChanged(s => this.FadeTo(s.NewValue ? 1 : 0, HUDOverlay.FADE_DURATION, HUDOverlay.FADE_EASING), true); + } + } + protected override void SkinChanged(ISkinSource skin) { base.SkinChanged(skin); @@ -45,12 +63,12 @@ namespace osu.Game.Rulesets.Catch.UI updateCombo(result.ComboAtJudgement + 1, judgedObject.AccentColour.Value); } - public void OnRevertResult(DrawableCatchHitObject judgedObject, JudgementResult result) + public void OnRevertResult(JudgementResult result) { if (!result.Type.AffectsCombo() || !result.HasResult) return; - updateCombo(result.ComboAtJudgement, judgedObject.AccentColour.Value); + updateCombo(result.ComboAtJudgement, null); } private void updateCombo(int newCombo, Color4? hitObjectColour) diff --git a/osu.Game.Rulesets.Catch/UI/CatchCursorContainer.cs b/osu.Game.Rulesets.Catch/UI/CatchCursorContainer.cs new file mode 100644 index 0000000000..4ae61ef8c7 --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/CatchCursorContainer.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Catch.UI +{ + public partial class CatchCursorContainer : GameplayCursorContainer + { + // Just hide the cursor. + // The main goal here is to show that we have a cursor so the game never shows the global one. + protected override Drawable CreateCursor() => Empty(); + } +} diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs index df32d917ce..cf7337fd0d 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs @@ -8,12 +8,13 @@ using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osuTK; namespace osu.Game.Rulesets.Catch.UI { - public class CatchPlayfield : ScrollingPlayfield + public partial class CatchPlayfield : ScrollingPlayfield { /// /// The width of the playfield. @@ -21,6 +22,12 @@ namespace osu.Game.Rulesets.Catch.UI /// public const float WIDTH = 512; + /// + /// The height of the playfield. + /// This doesn't include the catcher area. + /// + public const float HEIGHT = 384; + /// /// The center position of the playfield. /// @@ -30,9 +37,9 @@ namespace osu.Game.Rulesets.Catch.UI // only check the X position; handle all vertical space. base.ReceivePositionalInputAt(new Vector2(screenSpacePos.X, ScreenSpaceDrawQuad.Centre.Y)); - internal Catcher Catcher { get; private set; } + internal Catcher Catcher { get; private set; } = null!; - internal CatcherArea CatcherArea { get; private set; } + internal CatcherArea CatcherArea { get; private set; } = null!; private readonly IBeatmapDifficultyInfo difficulty; @@ -41,6 +48,8 @@ namespace osu.Game.Rulesets.Catch.UI this.difficulty = difficulty; } + protected override GameplayCursorContainer CreateCursor() => new CatchCursorContainer(); + [BackgroundDependencyLoader] private void load() { @@ -94,7 +103,7 @@ namespace osu.Game.Rulesets.Catch.UI private void onNewResult(DrawableHitObject judgedObject, JudgementResult result) => CatcherArea.OnNewResult((DrawableCatchHitObject)judgedObject, result); - private void onRevertResult(DrawableHitObject judgedObject, JudgementResult result) - => CatcherArea.OnRevertResult((DrawableCatchHitObject)judgedObject, result); + private void onRevertResult(JudgementResult result) + => CatcherArea.OnRevertResult(result); } } diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfieldAdjustmentContainer.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfieldAdjustmentContainer.cs index efc1b24ed5..74cbc665c0 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchPlayfieldAdjustmentContainer.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfieldAdjustmentContainer.cs @@ -8,7 +8,7 @@ using osuTK; namespace osu.Game.Rulesets.Catch.UI { - public class CatchPlayfieldAdjustmentContainer : PlayfieldAdjustmentContainer + public partial class CatchPlayfieldAdjustmentContainer : PlayfieldAdjustmentContainer { private const float playfield_size_adjust = 0.8f; @@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Catch.UI /// /// A which scales its content relative to a target width. /// - private class ScalingContainer : Container + private partial class ScalingContainer : Container { protected override void Update() { diff --git a/osu.Game.Rulesets.Catch/UI/CatchReplayRecorder.cs b/osu.Game.Rulesets.Catch/UI/CatchReplayRecorder.cs index a7879846df..32ede8f205 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchReplayRecorder.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchReplayRecorder.cs @@ -10,7 +10,7 @@ using osuTK; namespace osu.Game.Rulesets.Catch.UI { - public class CatchReplayRecorder : ReplayRecorder + public partial class CatchReplayRecorder : ReplayRecorder { private readonly CatchPlayfield playfield; diff --git a/osu.Game.Rulesets.Catch/UI/CatchTouchInputMapper.cs b/osu.Game.Rulesets.Catch/UI/CatchTouchInputMapper.cs new file mode 100644 index 0000000000..10e43cf74a --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/CatchTouchInputMapper.cs @@ -0,0 +1,254 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osuTK; + +namespace osu.Game.Rulesets.Catch.UI +{ + public partial class CatchTouchInputMapper : VisibilityContainer + { + public override bool PropagatePositionalInputSubTree => true; + public override bool PropagateNonPositionalInputSubTree => true; + + private readonly Dictionary trackedActionSources = new Dictionary(); + + private KeyBindingContainer keyBindingContainer = null!; + + private Container mainContent = null!; + + private InputArea leftBox = null!; + private InputArea rightBox = null!; + private InputArea leftDashBox = null!; + private InputArea rightDashBox = null!; + + [BackgroundDependencyLoader] + private void load(CatchInputManager catchInputManager, OsuColour colours) + { + const float width = 0.15f; + // Ratio between normal move area height and total input height + const float normal_area_height_ratio = 0.45f; + + keyBindingContainer = catchInputManager.KeyBindingContainer; + + RelativeSizeAxes = Axes.Both; + + Children = new Drawable[] + { + mainContent = new Container + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Width = width, + Children = new Drawable[] + { + leftBox = new InputArea(TouchCatchAction.MoveLeft, trackedActionSources) + { + RelativeSizeAxes = Axes.Both, + Height = normal_area_height_ratio, + Colour = colours.Gray9, + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + }, + leftDashBox = new InputArea(TouchCatchAction.DashLeft, trackedActionSources) + { + RelativeSizeAxes = Axes.Both, + Height = 1 - normal_area_height_ratio, + }, + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Width = width, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Children = new Drawable[] + { + rightBox = new InputArea(TouchCatchAction.MoveRight, trackedActionSources) + { + RelativeSizeAxes = Axes.Both, + Height = normal_area_height_ratio, + Colour = colours.Gray9, + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + }, + rightDashBox = new InputArea(TouchCatchAction.DashRight, trackedActionSources) + { + RelativeSizeAxes = Axes.Both, + Height = 1 - normal_area_height_ratio, + }, + } + }, + }, + }, + }; + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + // Hide whenever the keyboard is used. + Hide(); + return false; + } + + protected override bool OnTouchDown(TouchDownEvent e) + { + return updateAction(e.Touch.Source, getTouchCatchActionFromInput(e.ScreenSpaceTouch.Position)); + } + + protected override void OnTouchMove(TouchMoveEvent e) + { + updateAction(e.Touch.Source, getTouchCatchActionFromInput(e.ScreenSpaceTouch.Position)); + base.OnTouchMove(e); + } + + protected override void OnTouchUp(TouchUpEvent e) + { + updateAction(e.Touch.Source, null); + base.OnTouchUp(e); + } + + private bool updateAction(object source, TouchCatchAction? newAction) + { + TouchCatchAction? actionBefore = null; + + if (trackedActionSources.TryGetValue(source, out TouchCatchAction found)) + actionBefore = found; + + if (actionBefore != newAction) + { + if (newAction != null) + trackedActionSources[source] = newAction.Value; + else + trackedActionSources.Remove(source); + + updatePressedActions(); + } + + return newAction != null; + } + + private void updatePressedActions() + { + Show(); + + if (trackedActionSources.ContainsValue(TouchCatchAction.DashLeft) || trackedActionSources.ContainsValue(TouchCatchAction.MoveLeft)) + keyBindingContainer.TriggerPressed(CatchAction.MoveLeft); + else + keyBindingContainer.TriggerReleased(CatchAction.MoveLeft); + + if (trackedActionSources.ContainsValue(TouchCatchAction.DashRight) || trackedActionSources.ContainsValue(TouchCatchAction.MoveRight)) + keyBindingContainer.TriggerPressed(CatchAction.MoveRight); + else + keyBindingContainer.TriggerReleased(CatchAction.MoveRight); + + if (trackedActionSources.ContainsValue(TouchCatchAction.DashLeft) || trackedActionSources.ContainsValue(TouchCatchAction.DashRight)) + keyBindingContainer.TriggerPressed(CatchAction.Dash); + else + keyBindingContainer.TriggerReleased(CatchAction.Dash); + } + + private TouchCatchAction? getTouchCatchActionFromInput(Vector2 screenSpaceInputPosition) + { + if (leftDashBox.Contains(screenSpaceInputPosition)) + return TouchCatchAction.DashLeft; + if (rightDashBox.Contains(screenSpaceInputPosition)) + return TouchCatchAction.DashRight; + if (leftBox.Contains(screenSpaceInputPosition)) + return TouchCatchAction.MoveLeft; + if (rightBox.Contains(screenSpaceInputPosition)) + return TouchCatchAction.MoveRight; + + return null; + } + + protected override void PopIn() => mainContent.FadeIn(300, Easing.OutQuint); + + protected override void PopOut() => mainContent.FadeOut(300, Easing.OutQuint); + + private partial class InputArea : CompositeDrawable, IKeyBindingHandler + { + private readonly TouchCatchAction handledAction; + + private readonly Box highlightOverlay; + + private readonly IEnumerable> trackedActions; + + private bool isHighlighted; + + public InputArea(TouchCatchAction handledAction, IEnumerable> trackedActions) + { + this.handledAction = handledAction; + this.trackedActions = trackedActions; + + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 10, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.15f, + }, + highlightOverlay = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Blending = BlendingParameters.Additive, + } + } + } + }; + } + + public bool OnPressed(KeyBindingPressEvent _) + { + updateHighlight(); + return false; + } + + public void OnReleased(KeyBindingReleaseEvent _) + { + updateHighlight(); + } + + private void updateHighlight() + { + bool isHandling = trackedActions.Any(a => a.Value == handledAction); + + if (isHandling == isHighlighted) + return; + + isHighlighted = isHandling; + highlightOverlay.FadeTo(isHighlighted ? 0.1f : 0, isHighlighted ? 80 : 400, Easing.OutQuint); + } + } + + public enum TouchCatchAction + { + MoveLeft, + MoveRight, + DashLeft, + DashRight, + } + } +} diff --git a/osu.Game.Rulesets.Catch/UI/Catcher.cs b/osu.Game.Rulesets.Catch/UI/Catcher.cs index 04708c8796..ab754e51f7 100644 --- a/osu.Game.Rulesets.Catch/UI/Catcher.cs +++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs @@ -2,8 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -24,7 +24,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.UI { [Cached] - public class Catcher : SkinReloadableDrawable + public partial class Catcher : SkinReloadableDrawable { /// /// The size of the catcher at 1x scale. @@ -121,7 +121,7 @@ namespace osu.Game.Rulesets.Catch.UI private double hyperDashModifier = 1; private int hyperDashDirection; private float hyperDashTargetPosition; - private Bindable hitLighting; + private Bindable hitLighting = null!; private readonly HitExplosionContainer hitExplosionContainer; @@ -129,13 +129,14 @@ namespace osu.Game.Rulesets.Catch.UI private readonly DrawablePool caughtBananaPool; private readonly DrawablePool caughtDropletPool; - public Catcher([NotNull] DroppedObjectContainer droppedObjectTarget, IBeatmapDifficultyInfo difficulty = null) + public Catcher(DroppedObjectContainer droppedObjectTarget, IBeatmapDifficultyInfo? difficulty = null) { this.droppedObjectTarget = droppedObjectTarget; Origin = Anchor.TopCentre; Size = new Vector2(BASE_SIZE); + if (difficulty != null) Scale = calculateScale(difficulty); @@ -229,9 +230,8 @@ namespace osu.Game.Rulesets.Catch.UI // droplet doesn't affect the catcher state if (hitObject is TinyDroplet) return; - if (result.IsHit && hitObject.HyperDash) + if (result.IsHit && hitObject.HyperDashTarget is CatchHitObject target) { - var target = hitObject.HyperDashTarget; double timeDifference = target.StartTime - hitObject.StartTime; double positionDifference = target.EffectiveX - X; double velocity = positionDifference / Math.Max(1.0, timeDifference - 1000.0 / 60.0); @@ -255,7 +255,7 @@ namespace osu.Game.Rulesets.Catch.UI } } - public void OnRevertResult(DrawableCatchHitObject drawableObject, JudgementResult result) + public void OnRevertResult(JudgementResult result) { var catchResult = (CatchJudgementResult)result; @@ -269,8 +269,8 @@ namespace osu.Game.Rulesets.Catch.UI SetHyperDashState(); } - caughtObjectContainer.RemoveAll(d => d.HitObject == drawableObject.HitObject); - droppedObjectTarget.RemoveAll(d => d.HitObject == drawableObject.HitObject); + caughtObjectContainer.RemoveAll(d => d.HitObject == result.HitObject, false); + droppedObjectTarget.RemoveAll(d => d.HitObject == result.HitObject, false); } /// @@ -334,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) || @@ -383,17 +386,17 @@ namespace osu.Game.Rulesets.Catch.UI private void addLighting(JudgementResult judgementResult, Color4 colour, float x) => hitExplosionContainer.Add(new HitExplosionEntry(Time.Current, judgementResult, colour, x)); - private CaughtObject getCaughtObject(PalpableCatchHitObject source) + private CaughtObject? getCaughtObject(PalpableCatchHitObject source) { switch (source) { - case Fruit _: + case Fruit: return caughtFruitPool.Get(); - case Banana _: + case Banana: return caughtBananaPool.Get(); - case Droplet _: + case Droplet: return caughtDropletPool.Get(); default: @@ -404,6 +407,7 @@ namespace osu.Game.Rulesets.Catch.UI private CaughtObject getDroppedObject(CaughtObject caughtObject) { var droppedObject = getCaughtObject(caughtObject.HitObject); + Debug.Assert(droppedObject != null); droppedObject.CopyStateFrom(caughtObject); droppedObject.Anchor = Anchor.TopLeft; @@ -428,7 +432,7 @@ namespace osu.Game.Rulesets.Catch.UI { var droppedObject = getDroppedObject(caughtObject); - caughtObjectContainer.Remove(caughtObject); + caughtObjectContainer.Remove(caughtObject, false); droppedObjectTarget.Add(droppedObject); diff --git a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs index 37002d1051..1b99270b65 100644 --- a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs +++ b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Catch.UI /// It holds a as a child and translates input to the catcher movement. /// It also holds a combo display that is above the catcher, and judgment results are translated to the catcher and the combo display. /// - public class CatcherArea : Container, IKeyBindingHandler + public partial class CatcherArea : Container, IKeyBindingHandler { public Catcher Catcher { @@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Catch.UI private readonly CatcherTrailDisplay catcherTrails; - private Catcher catcher; + private Catcher catcher = null!; /// /// -1 when only left button is pressed. @@ -73,10 +73,10 @@ namespace osu.Game.Rulesets.Catch.UI comboDisplay.OnNewResult(hitObject, result); } - public void OnRevertResult(DrawableCatchHitObject hitObject, JudgementResult result) + public void OnRevertResult(JudgementResult result) { - comboDisplay.OnRevertResult(hitObject, result); - Catcher.OnRevertResult(hitObject, result); + comboDisplay.OnRevertResult(result); + Catcher.OnRevertResult(result); } protected override void Update() @@ -118,10 +118,10 @@ namespace osu.Game.Rulesets.Catch.UI lastHyperDashState = Catcher.HyperDashing; } - public void SetCatcherPosition(float X) + public void SetCatcherPosition(float x) { float lastPosition = Catcher.X; - float newPosition = Math.Clamp(X, 0, CatchPlayfield.WIDTH); + float newPosition = Math.Clamp(x, 0, CatchPlayfield.WIDTH); Catcher.X = newPosition; diff --git a/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs index 6d2ac7e488..762f95828a 100644 --- a/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs +++ b/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Catch.UI /// A trail of the catcher. /// It also represents a hyper dash afterimage. /// - public class CatcherTrail : PoolableDrawableWithLifetime + public partial class CatcherTrail : PoolableDrawableWithLifetime { private readonly SkinnableCatcher body; diff --git a/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs index 0f2530e56a..e3e01c1b39 100644 --- a/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs +++ b/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; @@ -17,7 +18,7 @@ namespace osu.Game.Rulesets.Catch.UI /// Represents a component responsible for displaying /// the appropriate catcher trails when requested to. /// - public class CatcherTrailDisplay : PooledDrawableWithLifetimeContainer + public partial class CatcherTrailDisplay : PooledDrawableWithLifetimeContainer { /// /// The most recent time a dash trail was added to this container. @@ -39,7 +40,7 @@ namespace osu.Game.Rulesets.Catch.UI private readonly Container hyperDashAfterImages; [Resolved] - private ISkinSource skin { get; set; } + private ISkinSource skin { get; set; } = null!; public CatcherTrailDisplay() { @@ -91,15 +92,15 @@ namespace osu.Game.Rulesets.Catch.UI switch (entry.Animation) { case CatcherTrailAnimation.Dashing: - dashTrails.Remove(drawable); + dashTrails.Remove(drawable, false); break; case CatcherTrailAnimation.HyperDashing: - hyperDashTrails.Remove(drawable); + hyperDashTrails.Remove(drawable, false); break; case CatcherTrailAnimation.HyperDashAfterImage: - hyperDashAfterImages.Remove(drawable); + hyperDashAfterImages.Remove(drawable, false); break; } } @@ -128,7 +129,7 @@ namespace osu.Game.Rulesets.Catch.UI { base.Dispose(isDisposing); - if (skin != null) + if (skin.IsNotNull()) skin.SourceChanged -= skinSourceChanged; } } diff --git a/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs b/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs index a8ec9f1d2f..7930a07551 100644 --- a/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs @@ -2,6 +2,8 @@ // 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.Input; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -17,19 +19,29 @@ using osu.Game.Scoring; namespace osu.Game.Rulesets.Catch.UI { - public class DrawableCatchRuleset : DrawableScrollingRuleset + public partial class DrawableCatchRuleset : DrawableScrollingRuleset { protected override ScrollVisualisationMethod VisualisationMethod => ScrollVisualisationMethod.Constant; protected override bool UserScrollSpeedAdjustment => false; - public DrawableCatchRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) + public DrawableCatchRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList? mods = null) : base(ruleset, beatmap, mods) { Direction.Value = ScrollingDirection.Down; - TimeRange.Value = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450); + TimeRange.Value = GetTimeRange(beatmap.Difficulty.ApproachRate); } + [BackgroundDependencyLoader] + private void load() + { + // With relax mod, input maps directly to x position and left/right buttons are not used. + if (!Mods.Any(m => m is ModRelax)) + KeyBindingInputManager.Add(new CatchTouchInputMapper()); + } + + protected double GetTimeRange(float approachRate) => IBeatmapDifficultyInfo.DifficultyRange(approachRate, 1800, 1200, 450); + protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new CatchFramedReplayInputHandler(replay); protected override ReplayRecorder CreateReplayRecorder(Score score) => new CatchReplayRecorder(score, (CatchPlayfield)Playfield); @@ -40,6 +52,6 @@ namespace osu.Game.Rulesets.Catch.UI protected override PassThroughInputManager CreateInputManager() => new CatchInputManager(Ruleset.RulesetInfo); - public override DrawableHitObject CreateDrawableRepresentation(CatchHitObject h) => null; + public override DrawableHitObject? CreateDrawableRepresentation(CatchHitObject h) => null; } } diff --git a/osu.Game.Rulesets.Catch/UI/DroppedObjectContainer.cs b/osu.Game.Rulesets.Catch/UI/DroppedObjectContainer.cs index b44b0caae4..df1e932ad5 100644 --- a/osu.Game.Rulesets.Catch/UI/DroppedObjectContainer.cs +++ b/osu.Game.Rulesets.Catch/UI/DroppedObjectContainer.cs @@ -7,7 +7,7 @@ using osu.Game.Rulesets.Catch.Objects.Drawables; namespace osu.Game.Rulesets.Catch.UI { - public class DroppedObjectContainer : Container + public partial class DroppedObjectContainer : Container { public DroppedObjectContainer() { diff --git a/osu.Game.Rulesets.Catch/UI/HitExplosion.cs b/osu.Game.Rulesets.Catch/UI/HitExplosion.cs index 955b1e6edb..c53957100a 100644 --- a/osu.Game.Rulesets.Catch/UI/HitExplosion.cs +++ b/osu.Game.Rulesets.Catch/UI/HitExplosion.cs @@ -6,11 +6,9 @@ using osu.Game.Rulesets.Catch.Skinning.Default; using osu.Game.Rulesets.Objects.Pooling; using osu.Game.Skinning; -#nullable enable - namespace osu.Game.Rulesets.Catch.UI { - public class HitExplosion : PoolableDrawableWithLifetime + public partial class HitExplosion : PoolableDrawableWithLifetime { private readonly SkinnableDrawable skinnableExplosion; @@ -20,7 +18,7 @@ namespace osu.Game.Rulesets.Catch.UI Anchor = Anchor.BottomCentre; Origin = Anchor.BottomCentre; - InternalChild = skinnableExplosion = new SkinnableDrawable(new CatchSkinComponent(CatchSkinComponents.HitExplosion), _ => new DefaultHitExplosion()) + InternalChild = skinnableExplosion = new SkinnableDrawable(new CatchSkinComponentLookup(CatchSkinComponents.HitExplosion), _ => new DefaultHitExplosion()) { CentreComponent = false, Anchor = Anchor.BottomCentre, diff --git a/osu.Game.Rulesets.Catch/UI/HitExplosionContainer.cs b/osu.Game.Rulesets.Catch/UI/HitExplosionContainer.cs index 6df13e52ef..1e2d94433c 100644 --- a/osu.Game.Rulesets.Catch/UI/HitExplosionContainer.cs +++ b/osu.Game.Rulesets.Catch/UI/HitExplosionContainer.cs @@ -7,7 +7,7 @@ using osu.Game.Rulesets.Objects.Pooling; namespace osu.Game.Rulesets.Catch.UI { - public class HitExplosionContainer : PooledDrawableWithLifetimeContainer + public partial class HitExplosionContainer : PooledDrawableWithLifetimeContainer { protected override bool RemoveRewoundEntry => true; diff --git a/osu.Game.Rulesets.Catch/UI/HitExplosionEntry.cs b/osu.Game.Rulesets.Catch/UI/HitExplosionEntry.cs index 88871c77f6..f265da149a 100644 --- a/osu.Game.Rulesets.Catch/UI/HitExplosionEntry.cs +++ b/osu.Game.Rulesets.Catch/UI/HitExplosionEntry.cs @@ -6,8 +6,6 @@ using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Judgements; using osuTK.Graphics; -#nullable enable - namespace osu.Game.Rulesets.Catch.UI { public class HitExplosionEntry : LifetimeEntry diff --git a/osu.Game.Rulesets.Catch/UI/IHitExplosion.cs b/osu.Game.Rulesets.Catch/UI/IHitExplosion.cs index c744c00d9a..4a9d7e8ac0 100644 --- a/osu.Game.Rulesets.Catch/UI/IHitExplosion.cs +++ b/osu.Game.Rulesets.Catch/UI/IHitExplosion.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 enable - namespace osu.Game.Rulesets.Catch.UI { /// diff --git a/osu.Game.Rulesets.Catch/UI/SkinnableCatcher.cs b/osu.Game.Rulesets.Catch/UI/SkinnableCatcher.cs index 8d707a4beb..bcc59a5e4f 100644 --- a/osu.Game.Rulesets.Catch/UI/SkinnableCatcher.cs +++ b/osu.Game.Rulesets.Catch/UI/SkinnableCatcher.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Catch.UI /// The visual representation of the . /// It includes the body part of the catcher and the catcher plate. /// - public class SkinnableCatcher : SkinnableDrawable + public partial class SkinnableCatcher : SkinnableDrawable { /// /// This is used by skin elements to determine which texture of the catcher is used. @@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Catch.UI public readonly Bindable AnimationState = new Bindable(); public SkinnableCatcher() - : base(new CatchSkinComponent(CatchSkinComponents.Catcher), _ => new DefaultCatcher()) + : base(new CatchSkinComponentLookup(CatchSkinComponents.Catcher), _ => new DefaultCatcher()) { Anchor = Anchor.TopCentre; // Sets the origin roughly to the centre of the catcher's plate to allow for correct scaling. diff --git a/osu.Game.Rulesets.Catch/osu.Game.Rulesets.Catch.csproj b/osu.Game.Rulesets.Catch/osu.Game.Rulesets.Catch.csproj index e2f95ca177..ecce7c1b3f 100644 --- a/osu.Game.Rulesets.Catch/osu.Game.Rulesets.Catch.csproj +++ b/osu.Game.Rulesets.Catch/osu.Game.Rulesets.Catch.csproj @@ -1,6 +1,6 @@  - netstandard2.1 + net6.0 Library true catch the fruit. to the beat. @@ -15,4 +15,4 @@ - \ No newline at end of file + diff --git a/osu.Game.Rulesets.Mania.Tests.Android/Properties/AndroidManifest.xml b/osu.Game.Rulesets.Mania.Tests.Android/AndroidManifest.xml similarity index 95% rename from osu.Game.Rulesets.Mania.Tests.Android/Properties/AndroidManifest.xml rename to osu.Game.Rulesets.Mania.Tests.Android/AndroidManifest.xml index de7935b2ef..4a1545a423 100644 --- a/osu.Game.Rulesets.Mania.Tests.Android/Properties/AndroidManifest.xml +++ b/osu.Game.Rulesets.Mania.Tests.Android/AndroidManifest.xml @@ -1,5 +1,5 @@  - + \ No newline at end of file diff --git a/osu.Game.Rulesets.Mania.Tests.Android/MainActivity.cs b/osu.Game.Rulesets.Mania.Tests.Android/MainActivity.cs index 518071fd49..789fc9e22d 100644 --- a/osu.Game.Rulesets.Mania.Tests.Android/MainActivity.cs +++ b/osu.Game.Rulesets.Mania.Tests.Android/MainActivity.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Android.App; using osu.Framework.Android; using osu.Game.Tests; diff --git a/osu.Game.Rulesets.Mania.Tests.Android/osu.Game.Rulesets.Mania.Tests.Android.csproj b/osu.Game.Rulesets.Mania.Tests.Android/osu.Game.Rulesets.Mania.Tests.Android.csproj index 9674186039..25335754d2 100644 --- a/osu.Game.Rulesets.Mania.Tests.Android/osu.Game.Rulesets.Mania.Tests.Android.csproj +++ b/osu.Game.Rulesets.Mania.Tests.Android/osu.Game.Rulesets.Mania.Tests.Android.csproj @@ -1,49 +1,24 @@ - - + - Debug - AnyCPU - 8.0.30703 - 2.0 - {531F1092-DB27-445D-AA33-2A77C7187C99} - {EFBA0AD7-5A72-4C68-AF49-83D382785DCF};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - {122416d6-6b49-4ee2-a1e8-b825f31c79fe} + net6.0-android + Exe osu.Game.Rulesets.Mania.Tests osu.Game.Rulesets.Mania.Tests.Android - Properties\AndroidManifest.xml - armeabi-v7a;x86;arm64-v8a - - - None - cjk;mideast;other;rare;west - true - - - - - - - + %(RecursiveDir)%(Filename)%(Extension) + + + %(RecursiveDir)%(Filename)%(Extension) + Android\%(RecursiveDir)%(Filename)%(Extension) + - - {48f4582b-7687-4621-9cbe-5c24197cb536} - osu.Game.Rulesets.Mania - - - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D} - osu.Game - + + - - - 5.0.0 - - - \ No newline at end of file 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 9cd1e47023..0000000000 --- a/osu.Game.Rulesets.Mania.Tests.iOS/AppDelegate.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using 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 ec3db17e97..75a5a73058 100644 --- a/osu.Game.Rulesets.Mania.Tests.iOS/Application.cs +++ b/osu.Game.Rulesets.Mania.Tests.iOS/Application.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.iOS; -using UIKit; +using osu.Game.Tests; namespace osu.Game.Rulesets.Mania.Tests.iOS { @@ -10,7 +10,7 @@ namespace osu.Game.Rulesets.Mania.Tests.iOS { public static void Main(string[] args) { - UIApplication.Main(args, typeof(GameUIApplication), typeof(AppDelegate)); + GameApplication.Main(new OsuTestBrowser()); } } } diff --git a/osu.Game.Rulesets.Mania.Tests.iOS/Info.plist b/osu.Game.Rulesets.Mania.Tests.iOS/Info.plist index 82d1c8ea24..ff5dde856e 100644 --- a/osu.Game.Rulesets.Mania.Tests.iOS/Info.plist +++ b/osu.Game.Rulesets.Mania.Tests.iOS/Info.plist @@ -13,7 +13,7 @@ LSRequiresIPhoneOS MinimumOSVersion - 10.0 + 13.4 UIDeviceFamily 1 diff --git a/osu.Game.Rulesets.Mania.Tests.iOS/osu.Game.Rulesets.Mania.Tests.iOS.csproj b/osu.Game.Rulesets.Mania.Tests.iOS/osu.Game.Rulesets.Mania.Tests.iOS.csproj index 88ad484bc1..51e07dd6c1 100644 --- a/osu.Game.Rulesets.Mania.Tests.iOS/osu.Game.Rulesets.Mania.Tests.iOS.csproj +++ b/osu.Game.Rulesets.Mania.Tests.iOS/osu.Game.Rulesets.Mania.Tests.iOS.csproj @@ -1,35 +1,19 @@ - - + - Debug - iPhoneSimulator - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C} Exe + net6.0-ios + 13.4 osu.Game.Rulesets.Mania.Tests osu.Game.Rulesets.Mania.Tests.iOS - - - - Linker.xml - - - %(RecursiveDir)%(Filename)%(Extension) - - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D} - osu.Game - - - {48F4582B-7687-4621-9CBE-5C24197CB536} - osu.Game.Rulesets.Mania - + + - - \ No newline at end of file + diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/ManiaPlacementBlueprintTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaPlacementBlueprintTestScene.cs index 94f385bbf1..d2a44122aa 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/ManiaPlacementBlueprintTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaPlacementBlueprintTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using osu.Framework.Allocation; @@ -8,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Timing; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mods; @@ -18,7 +21,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Tests.Editor { - public abstract class ManiaPlacementBlueprintTestScene : PlacementBlueprintTestScene + public abstract partial class ManiaPlacementBlueprintTestScene : PlacementBlueprintTestScene { private readonly Column column; @@ -28,15 +31,18 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor [Cached(typeof(IScrollingInfo))] private IScrollingInfo scrollingInfo; + [Cached] + private readonly StageDefinition stage = new StageDefinition(5); + protected ManiaPlacementBlueprintTestScene() { scrollingInfo = ((ScrollingTestContainer)HitObjectContainer).ScrollingInfo; - Add(column = new Column(0) + Add(column = new Column(0, false) { Anchor = Anchor.Centre, Origin = Anchor.Centre, - AccentColour = Color4.OrangeRed, + AccentColour = { Value = Color4.OrangeRed }, Clock = new FramedClock(new StopwatchClock()), // No scroll }); } diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs index 124e1a35f9..2fda012f07 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -13,7 +15,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Mania.Tests.Editor { - public abstract class ManiaSelectionBlueprintTestScene : SelectionBlueprintTestScene + public abstract partial class ManiaSelectionBlueprintTestScene : SelectionBlueprintTestScene { protected override Container Content => blueprints ?? base.Content; @@ -31,7 +33,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor protected ManiaSelectionBlueprintTestScene(int columns) { - var stageDefinitions = new List { new StageDefinition { Columns = columns } }; + var stageDefinitions = new List { new StageDefinition(columns) }; base.Content.Child = scrollingTestContainer = new ScrollingTestContainer(ScrollingDirection.Up) { RelativeSizeAxes = Axes.Both, diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneEditor.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneEditor.cs index 5300747633..0a21098d0d 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneEditor.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneEditor.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -12,7 +14,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Mania.Tests.Editor { [TestFixture] - public class TestSceneEditor : EditorTestScene + public partial class TestSceneEditor : EditorTestScene { private readonly Bindable direction = new Bindable(); diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNotePlacementBlueprint.cs index 87c74a12cf..4b332c3faa 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNotePlacementBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Edit.Blueprints; using osu.Game.Rulesets.Mania.Objects; @@ -10,7 +12,7 @@ using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Rulesets.Mania.Tests.Editor { - public class TestSceneHoldNotePlacementBlueprint : ManiaPlacementBlueprintTestScene + public partial class TestSceneHoldNotePlacementBlueprint : ManiaPlacementBlueprintTestScene { protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableHoldNote((HoldNote)hitObject); protected override PlacementBlueprint CreateBlueprint() => new HoldNotePlacementBlueprint(); diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteSelectionBlueprint.cs index 9953b8e3c0..a65f949cec 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteSelectionBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Mania.Edit.Blueprints; @@ -9,7 +11,7 @@ using osu.Game.Rulesets.Mania.Objects.Drawables; namespace osu.Game.Rulesets.Mania.Tests.Editor { - public class TestSceneHoldNoteSelectionBlueprint : ManiaSelectionBlueprintTestScene + public partial class TestSceneHoldNoteSelectionBlueprint : ManiaSelectionBlueprintTestScene { public TestSceneHoldNoteSelectionBlueprint() : base(4) diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs index 6130a80bb4..aca555552f 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -22,13 +24,13 @@ using osuTK; namespace osu.Game.Rulesets.Mania.Tests.Editor { - public class TestSceneManiaBeatSnapGrid : EditorClockTestScene + public partial class TestSceneManiaBeatSnapGrid : EditorClockTestScene { [Cached(typeof(IScrollingInfo))] private ScrollingTestContainer.TestScrollingInfo scrollingInfo = new ScrollingTestContainer.TestScrollingInfo(); [Cached(typeof(EditorBeatmap))] - private EditorBeatmap editorBeatmap = new EditorBeatmap(new ManiaBeatmap(new StageDefinition()) + private EditorBeatmap editorBeatmap = new EditorBeatmap(new ManiaBeatmap(new StageDefinition(2)) { BeatmapInfo = { @@ -54,8 +56,8 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor { Playfield = new ManiaPlayfield(new List { - new StageDefinition { Columns = 4 }, - new StageDefinition { Columns = 3 } + new StageDefinition(4), + new StageDefinition(3) }) { Clock = new FramedClock(new StopwatchClock()) @@ -81,13 +83,14 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor public ManiaPlayfield Playfield { get; } } - public class TestHitObjectComposer : HitObjectComposer + public partial class TestHitObjectComposer : HitObjectComposer { public override Playfield Playfield { get; } public override IEnumerable HitObjects => Enumerable.Empty(); public override bool CursorInPlacementArea => false; public TestHitObjectComposer(Playfield playfield) + : base(new ManiaRuleset()) { Playfield = playfield; } diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaComposeScreen.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaComposeScreen.cs index 746bdae02e..a1f4b234c4 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaComposeScreen.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaComposeScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -8,6 +10,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Database; using osu.Game.Overlays; using osu.Game.Rulesets.Edit; @@ -19,7 +22,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Mania.Tests.Editor { - public class TestSceneManiaComposeScreen : EditorClockTestScene + public partial class TestSceneManiaComposeScreen : EditorClockTestScene { [Resolved] private SkinManager skins { get; set; } @@ -32,10 +35,14 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor { AddStep("setup compose screen", () => { - var editorBeatmap = new EditorBeatmap(new ManiaBeatmap(new StageDefinition { Columns = 4 }) + var beatmap = new ManiaBeatmap(new StageDefinition(4)) { BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo }, - }); + }; + + beatmap.ControlPointInfo.Add(0, new TimingControlPoint()); + + var editorBeatmap = new EditorBeatmap(beatmap, new LegacyBeatmapSkin(beatmap.BeatmapInfo, null)); Beatmap.Value = CreateWorkingBeatmap(editorBeatmap.PlayableBeatmap); @@ -48,7 +55,11 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor (typeof(IBeatSnapProvider), editorBeatmap), (typeof(OverlayColourProvider), new OverlayColourProvider(OverlayColourScheme.Green)), }, - Child = new ComposeScreen { State = { Value = Visibility.Visible } }, + Children = new Drawable[] + { + editorBeatmap, + new ComposeScreen { State = { Value = Visibility.Visible } }, + } }; }); @@ -58,7 +69,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor [Test] public void TestDefaultSkin() { - AddStep("set default skin", () => skins.CurrentSkinInfo.Value = DefaultSkin.CreateInfo().ToLiveUnmanaged()); + AddStep("set default skin", () => skins.CurrentSkinInfo.Value = TrianglesSkin.CreateInfo().ToLiveUnmanaged()); } [Test] diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs index 9788dfe844..8e0b51dcf8 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -25,7 +27,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Mania.Tests.Editor { - public class TestSceneManiaHitObjectComposer : EditorClockTestScene + public partial class TestSceneManiaHitObjectComposer : EditorClockTestScene { private TestComposer composer; @@ -33,7 +35,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor public void Setup() => Schedule(() => { BeatDivisor.Value = 8; - Clock.Seek(0); + EditorClock.Seek(0); Child = composer = new TestComposer { RelativeSizeAxes = Axes.Both }; }); @@ -51,7 +53,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor { lastObject = this.ChildrenOfType().Single(d => d.HitObject == composer.EditorBeatmap.HitObjects.Last()); originalTime = lastObject.HitObject.StartTime; - Clock.Seek(composer.EditorBeatmap.HitObjects.Last().StartTime); + EditorClock.Seek(composer.EditorBeatmap.HitObjects.Last().StartTime); }); AddStep("select all objects", () => composer.EditorBeatmap.SelectedHitObjects.AddRange(composer.EditorBeatmap.HitObjects)); @@ -88,7 +90,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor { lastObject = this.ChildrenOfType().Single(d => d.HitObject == composer.EditorBeatmap.HitObjects.Last()); originalTime = lastObject.HitObject.StartTime; - Clock.Seek(composer.EditorBeatmap.HitObjects.Last().StartTime); + EditorClock.Seek(composer.EditorBeatmap.HitObjects.Last().StartTime); }); AddStep("select all objects", () => composer.EditorBeatmap.SelectedHitObjects.AddRange(composer.EditorBeatmap.HitObjects)); @@ -123,7 +125,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor AddStep("seek to last object", () => { lastObject = this.ChildrenOfType().Single(d => d.HitObject == composer.EditorBeatmap.HitObjects.Last()); - Clock.Seek(composer.EditorBeatmap.HitObjects.Last().StartTime); + EditorClock.Seek(composer.EditorBeatmap.HitObjects.Last().StartTime); }); AddStep("select all objects", () => composer.EditorBeatmap.SelectedHitObjects.AddRange(composer.EditorBeatmap.HitObjects)); @@ -191,7 +193,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor private void setScrollStep(ScrollingDirection direction) => AddStep($"set scroll direction = {direction}", () => ((Bindable)composer.Composer.ScrollingInfo.Direction).Value = direction); - private class TestComposer : CompositeDrawable + private partial class TestComposer : CompositeDrawable { [Cached(typeof(EditorBeatmap))] [Cached(typeof(IBeatSnapProvider))] @@ -203,7 +205,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor { InternalChildren = new Drawable[] { - EditorBeatmap = new EditorBeatmap(new ManiaBeatmap(new StageDefinition { Columns = 4 }) + EditorBeatmap = new EditorBeatmap(new ManiaBeatmap(new StageDefinition(4)) { BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo } }), diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNotePlacementBlueprint.cs index a162c5ec44..a446f13cbf 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNotePlacementBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Extensions.IEnumerableExtensions; @@ -19,7 +21,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Mania.Tests.Editor { - public class TestSceneNotePlacementBlueprint : ManiaPlacementBlueprintTestScene + public partial class TestSceneNotePlacementBlueprint : ManiaPlacementBlueprintTestScene { [SetUp] public void Setup() => Schedule(() => diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNoteSelectionBlueprint.cs index 3586eecc44..86e87e7486 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNoteSelectionBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Mania.Edit.Blueprints; @@ -9,7 +11,7 @@ using osu.Game.Rulesets.Mania.Objects.Drawables; namespace osu.Game.Rulesets.Mania.Tests.Editor { - public class TestSceneNoteSelectionBlueprint : ManiaSelectionBlueprintTestScene + public partial class TestSceneNoteSelectionBlueprint : ManiaSelectionBlueprintTestScene { public TestSceneNoteSelectionBlueprint() : base(4) diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneObjectPlacement.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneObjectPlacement.cs new file mode 100644 index 0000000000..13a116b209 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneObjectPlacement.cs @@ -0,0 +1,67 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Tests.Visual; +using osuTK.Input; + +namespace osu.Game.Rulesets.Mania.Tests.Editor +{ + public partial class TestSceneObjectPlacement : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new ManiaRuleset(); + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + [Test] + public void TestPlacementBeforeTrackStart() + { + AddStep("Seek to 0", () => EditorClock.Seek(0)); + AddStep("Select note", () => InputManager.Key(Key.Number2)); + AddStep("Hover negative span", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First(x => x.Name == "Icons").Children[0]); + }); + AddStep("Click", () => InputManager.Click(MouseButton.Left)); + AddAssert("No notes placed", () => EditorBeatmap.HitObjects.All(x => x.StartTime >= 0)); + } + + [Test] + public void TestSeekOnNotePlacement() + { + double? initialTime = null; + + AddStep("store initial time", () => initialTime = EditorClock.CurrentTime); + AddStep("change seek setting to true", () => config.SetValue(OsuSetting.EditorAutoSeekOnPlacement, true)); + placeObject(); + AddUntilStep("wait for seek to complete", () => !EditorClock.IsSeeking); + AddAssert("seeked forward to object", () => EditorClock.CurrentTime, () => Is.GreaterThan(initialTime)); + } + + [Test] + public void TestNoSeekOnNotePlacement() + { + double? initialTime = null; + + AddStep("store initial time", () => initialTime = EditorClock.CurrentTime); + AddStep("change seek setting to false", () => config.SetValue(OsuSetting.EditorAutoSeekOnPlacement, false)); + placeObject(); + AddAssert("not seeking", () => !EditorClock.IsSeeking); + AddAssert("time is unchanged", () => EditorClock.CurrentTime, () => Is.EqualTo(initialTime)); + } + + private void placeObject() + { + AddStep("select note placement tool", () => InputManager.Key(Key.Number2)); + AddStep("move mouse to centre of last column", () => InputManager.MoveMouseTo(this.ChildrenOfType().Last().ScreenSpaceDrawQuad.Centre)); + AddStep("place note", () => InputManager.Click(MouseButton.Left)); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapConversionTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapConversionTest.cs index 837474ad9e..ef6dca620a 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapConversionTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapConversionTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using NUnit.Framework; diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapSampleConversionTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapSampleConversionTest.cs index 9c690f360a..51f35d3c3d 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapSampleConversionTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapSampleConversionTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaColumnTypeTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaColumnTypeTest.cs deleted file mode 100644 index 66fe6d8cc5..0000000000 --- a/osu.Game.Rulesets.Mania.Tests/ManiaColumnTypeTest.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using osu.Game.Rulesets.Mania.Beatmaps; -using NUnit.Framework; - -namespace osu.Game.Rulesets.Mania.Tests -{ - [TestFixture] - public class ManiaColumnTypeTest - { - [TestCase(new[] - { - ColumnType.Special - }, 1)] - [TestCase(new[] - { - ColumnType.Odd, - ColumnType.Even, - ColumnType.Even, - ColumnType.Odd - }, 4)] - [TestCase(new[] - { - ColumnType.Odd, - ColumnType.Even, - ColumnType.Odd, - ColumnType.Special, - ColumnType.Odd, - ColumnType.Even, - ColumnType.Odd - }, 7)] - public void Test(IEnumerable expected, int columns) - { - var definition = new StageDefinition - { - Columns = columns - }; - var results = getResults(definition); - Assert.AreEqual(expected, results); - } - - private IEnumerable getResults(StageDefinition definition) - { - for (int i = 0; i < definition.Columns; i++) - yield return definition.GetTypeOfColumn(i); - } - } -} diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs index a5bd126782..4ae6cb9c7c 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; @@ -14,11 +16,11 @@ namespace osu.Game.Rulesets.Mania.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Mania"; - [TestCase(2.3449735700206298d, 242, "diffcalc-test")] + [TestCase(2.3493769750220914d, 242, "diffcalc-test")] public void Test(double expectedStarRating, int expectedMaxCombo, string name) => base.Test(expectedStarRating, expectedMaxCombo, name); - [TestCase(2.7879104989252959d, 242, "diffcalc-test")] + [TestCase(2.797245912537965d, 242, "diffcalc-test")] public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new ManiaModDoubleTime()); diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaInputTestScene.cs b/osu.Game.Rulesets.Mania.Tests/ManiaInputTestScene.cs index 9049bb3a82..c85583c1fd 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaInputTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaInputTestScene.cs @@ -1,14 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; +using osu.Game.Input.Bindings; using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Mania.Tests { - public abstract class ManiaInputTestScene : OsuTestScene + public abstract partial class ManiaInputTestScene : OsuTestScene { private readonly Container content; protected override Container Content => content ?? base.Content; @@ -18,7 +22,7 @@ namespace osu.Game.Rulesets.Mania.Tests base.Content.Add(content = new LocalInputManager(keys)); } - private class LocalInputManager : ManiaInputManager + private partial class LocalInputManager : ManiaInputManager { public LocalInputManager(int variant) : base(new ManiaRuleset().RulesetInfo, variant) @@ -28,14 +32,14 @@ namespace osu.Game.Rulesets.Mania.Tests protected override KeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) => new LocalKeyBindingContainer(ruleset, variant, unique); - private class LocalKeyBindingContainer : RulesetKeyBindingContainer + private partial class LocalKeyBindingContainer : RulesetKeyBindingContainer { public LocalKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) : base(ruleset, variant, unique) { } - protected override void ReloadMappings() + protected override void ReloadMappings(IQueryable realmKeyBindings) { KeyBindings = DefaultKeyBindings; } diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaLegacyModConversionTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaLegacyModConversionTest.cs index a28c188051..9dee861e66 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaLegacyModConversionTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaLegacyModConversionTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Game.Beatmaps.Legacy; @@ -21,10 +23,8 @@ namespace osu.Game.Rulesets.Mania.Tests new object[] { LegacyMods.SuddenDeath, new[] { typeof(ManiaModSuddenDeath) } }, new object[] { LegacyMods.DoubleTime, new[] { typeof(ManiaModDoubleTime) } }, new object[] { LegacyMods.HalfTime, new[] { typeof(ManiaModHalfTime) } }, - new object[] { LegacyMods.Nightcore, new[] { typeof(ManiaModNightcore) } }, new object[] { LegacyMods.Flashlight, new[] { typeof(ManiaModFlashlight) } }, new object[] { LegacyMods.Autoplay, new[] { typeof(ManiaModAutoplay) } }, - new object[] { LegacyMods.Perfect, new[] { typeof(ManiaModPerfect) } }, new object[] { LegacyMods.Key4, new[] { typeof(ManiaModKey4) } }, new object[] { LegacyMods.Key5, new[] { typeof(ManiaModKey5) } }, new object[] { LegacyMods.Key6, new[] { typeof(ManiaModKey6) } }, @@ -32,7 +32,6 @@ namespace osu.Game.Rulesets.Mania.Tests new object[] { LegacyMods.Key8, new[] { typeof(ManiaModKey8) } }, new object[] { LegacyMods.FadeIn, new[] { typeof(ManiaModFadeIn) } }, new object[] { LegacyMods.Random, new[] { typeof(ManiaModRandom) } }, - new object[] { LegacyMods.Cinema, new[] { typeof(ManiaModCinema) } }, new object[] { LegacyMods.Key9, new[] { typeof(ManiaModKey9) } }, new object[] { LegacyMods.KeyCoop, new[] { typeof(ManiaModDualStages) } }, new object[] { LegacyMods.Key1, new[] { typeof(ManiaModKey1) } }, @@ -43,12 +42,18 @@ namespace osu.Game.Rulesets.Mania.Tests }; [TestCaseSource(nameof(mania_mod_mapping))] + [TestCase(LegacyMods.Cinema, new[] { typeof(ManiaModCinema) })] [TestCase(LegacyMods.Cinema | LegacyMods.Autoplay, new[] { typeof(ManiaModCinema) })] + [TestCase(LegacyMods.Nightcore, new[] { typeof(ManiaModNightcore) })] [TestCase(LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(ManiaModNightcore) })] + [TestCase(LegacyMods.Perfect, new[] { typeof(ManiaModPerfect) })] [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath, new[] { typeof(ManiaModPerfect) })] public new void TestFromLegacy(LegacyMods legacyMods, Type[] expectedMods) => base.TestFromLegacy(legacyMods, expectedMods); [TestCaseSource(nameof(mania_mod_mapping))] + [TestCase(LegacyMods.Cinema | LegacyMods.Autoplay, new[] { typeof(ManiaModCinema) })] + [TestCase(LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(ManiaModNightcore) })] + [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath, new[] { typeof(ManiaModPerfect) })] public new void TestToLegacy(LegacyMods legacyMods, Type[] givenMods) => base.TestToLegacy(legacyMods, givenMods); protected override Ruleset CreateRuleset() => new ManiaRuleset(); diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaLegacyReplayTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaLegacyReplayTest.cs index 40bb83aece..7d1a934456 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaLegacyReplayTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaLegacyReplayTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Replays; @@ -16,7 +18,7 @@ namespace osu.Game.Rulesets.Mania.Tests [TestCase(ManiaAction.Key8)] public void TestEncodeDecodeSingleStage(params ManiaAction[] actions) { - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 9 }); + var beatmap = new ManiaBeatmap(new StageDefinition(9)); var frame = new ManiaReplayFrame(0, actions); var legacyFrame = frame.ToLegacy(beatmap); @@ -36,8 +38,8 @@ namespace osu.Game.Rulesets.Mania.Tests [TestCase(ManiaAction.Key8)] public void TestEncodeDecodeDualStage(params ManiaAction[] actions) { - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 5 }); - beatmap.Stages.Add(new StageDefinition { Columns = 5 }); + var beatmap = new ManiaBeatmap(new StageDefinition(5)); + beatmap.Stages.Add(new StageDefinition(5)); var frame = new ManiaReplayFrame(0, actions); var legacyFrame = frame.ToLegacy(beatmap); diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaSpecialColumnTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaSpecialColumnTest.cs new file mode 100644 index 0000000000..3bd654e75e --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/ManiaSpecialColumnTest.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. + +#nullable disable + +using System.Collections.Generic; +using osu.Game.Rulesets.Mania.Beatmaps; +using NUnit.Framework; + +namespace osu.Game.Rulesets.Mania.Tests +{ + [TestFixture] + public class ManiaSpecialColumnTest + { + [TestCase(new[] + { + true + }, 1)] + [TestCase(new[] + { + false, + false, + false, + false + }, 4)] + [TestCase(new[] + { + false, + false, + false, + true, + false, + false, + false + }, 7)] + public void Test(IEnumerable special, int columns) + { + var definition = new StageDefinition(columns); + var results = getResults(definition); + Assert.AreEqual(special, results); + } + + private IEnumerable getResults(StageDefinition definition) + { + for (int i = 0; i < definition.Columns; i++) + yield return definition.IsSpecialColumn(i); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModConstantSpeed.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModConstantSpeed.cs index 60363aaeef..dc4f660a45 100644 --- a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModConstantSpeed.cs +++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModConstantSpeed.cs @@ -13,7 +13,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Mania.Tests.Mods { - public class TestSceneManiaModConstantSpeed : ModTestScene + public partial class TestSceneManiaModConstantSpeed : ModTestScene { protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModFadeIn.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModFadeIn.cs new file mode 100644 index 0000000000..2c8c151e7f --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModFadeIn.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.Mania.Mods; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Mania.Tests.Mods +{ + public partial class TestSceneManiaModFadeIn : ModTestScene + { + protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); + + [TestCase(0.5f)] + [TestCase(0.1f)] + [TestCase(0.7f)] + public void TestCoverage(float coverage) => CreateModTest(new ModTestData { Mod = new ManiaModFadeIn { Coverage = { Value = coverage } }, PassCondition = () => true }); + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModFlashlight.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModFlashlight.cs new file mode 100644 index 0000000000..001d53c887 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModFlashlight.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Mania.Tests.Mods +{ + public partial class TestSceneManiaModFlashlight : ModTestScene + { + protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); + + [TestCase(1f)] + [TestCase(0.5f)] + [TestCase(1.5f)] + [TestCase(3f)] + public void TestSizeMultiplier(float sizeMultiplier) => CreateModTest(new ModTestData { Mod = new ManiaModFlashlight { SizeMultiplier = { Value = sizeMultiplier } }, PassCondition = () => true }); + + [Test] + public void TestComboBasedSize([Values] bool comboBasedSize) => CreateModTest(new ModTestData { Mod = new ManiaModFlashlight { ComboBasedSize = { Value = comboBasedSize } }, PassCondition = () => true }); + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModHidden.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModHidden.cs new file mode 100644 index 0000000000..204f26f151 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModHidden.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.Mania.Mods; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Mania.Tests.Mods +{ + public partial class TestSceneManiaModHidden : ModTestScene + { + protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); + + [TestCase(0.5f)] + [TestCase(0.2f)] + [TestCase(0.8f)] + public void TestCoverage(float coverage) => CreateModTest(new ModTestData { Mod = new ManiaModHidden { Coverage = { Value = coverage } }, PassCondition = () => true }); + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModHoldOff.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModHoldOff.cs index 7970d5b594..3011a93755 100644 --- a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModHoldOff.cs +++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModHoldOff.cs @@ -13,7 +13,7 @@ using osu.Game.Rulesets.Mania.Beatmaps; namespace osu.Game.Rulesets.Mania.Tests.Mods { - public class TestSceneManiaModHoldOff : ModTestScene + public partial class TestSceneManiaModHoldOff : ModTestScene { protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); @@ -85,7 +85,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods private static ManiaBeatmap createRawBeatmap() { - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 1 }); + var beatmap = new ManiaBeatmap(new StageDefinition(1)); beatmap.ControlPointInfo.Add(0.0, new TimingControlPoint { BeatLength = 1000 }); // Set BPM to 60 // Add test hit objects diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModInvert.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModInvert.cs index f2cc254e38..2977241dc6 100644 --- a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModInvert.cs +++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModInvert.cs @@ -7,7 +7,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Mania.Tests.Mods { - public class TestSceneManiaModInvert : ModTestScene + public partial class TestSceneManiaModInvert : ModTestScene { protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModPerfect.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModPerfect.cs index 2e3b21aed7..97a6ee28f4 100644 --- a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModPerfect.cs +++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModPerfect.cs @@ -8,7 +8,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Mania.Tests.Mods { - public class TestSceneManiaModPerfect : ModPerfectTestScene + public partial class TestSceneManiaModPerfect : ModPerfectTestScene { protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/LongNoteTailWang.png b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/LongNoteTailWang.png new file mode 100644 index 0000000000..982cc1d259 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/LongNoteTailWang.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/skin.ini b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/skin.ini index 9c987efc60..7c51036d69 100644 --- a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/skin.ini +++ b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/skin.ini @@ -14,4 +14,6 @@ Hit200: mania/hit200@2x Hit300: mania/hit300@2x Hit300g: mania/hit300g@2x StageLeft: mania/stage-left -StageRight: mania/stage-right \ No newline at end of file +StageRight: mania/stage-right +NoteImage0L: LongNoteTailWang +NoteImage1L: LongNoteTailWang diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/ColumnTestContainer.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/ColumnTestContainer.cs index 8ba58e3af3..0c55cebf0d 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/ColumnTestContainer.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/ColumnTestContainer.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 osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.UI; -using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Tests.Skinning { /// /// A container to be used in a to provide a resolvable dependency. /// - public class ColumnTestContainer : Container + public partial class ColumnTestContainer : Container { protected override Container Content => content; @@ -22,15 +23,16 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning [Cached] private readonly Column column; + [Cached] + private readonly StageDefinition stageDefinition = new StageDefinition(5); + public ColumnTestContainer(int column, ManiaAction action, bool showColumn = false) { InternalChildren = new[] { - this.column = new Column(column) + this.column = new Column(column, false) { Action = { Value = action }, - AccentColour = Color4.Orange, - ColumnType = column % 2 == 0 ? ColumnType.Even : ColumnType.Odd, Alpha = showColumn ? 1 : 0 }, content = new ManiaInputManager(new ManiaRuleset().RulesetInfo, 4) diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs index 68cf3b67df..25e120edc5 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -13,7 +15,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning /// /// A test scene for a mania hitobject. /// - public abstract class ManiaHitObjectTestScene : ManiaSkinnableTestScene + public abstract partial class ManiaHitObjectTestScene : ManiaSkinnableTestScene { [SetUp] public void SetUp() => Schedule(() => @@ -59,7 +61,6 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning c.Add(CreateHitObject().With(h => { h.HitObject.StartTime = Time.Current + 5000; - h.AccentColour.Value = Color4.Orange; })); }) }, diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaSkinnableTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaSkinnableTestScene.cs index ddfd057cd8..30bd600d9d 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaSkinnableTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaSkinnableTestScene.cs @@ -1,12 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling.Algorithms; using osu.Game.Tests.Visual; @@ -17,11 +20,14 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning /// /// A test scene for skinnable mania components. /// - public abstract class ManiaSkinnableTestScene : SkinnableTestScene + public abstract partial class ManiaSkinnableTestScene : SkinnableTestScene { [Cached(Type = typeof(IScrollingInfo))] private readonly TestScrollingInfo scrollingInfo = new TestScrollingInfo(); + [Cached] + private readonly StageDefinition stage = new StageDefinition(4); + protected override Ruleset CreateRulesetForSkinProvider() => new ManiaRuleset(); protected ManiaSkinnableTestScene() diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneBarLine.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneBarLine.cs new file mode 100644 index 0000000000..ab9f57ecc3 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneBarLine.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.UI; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + public partial class TestSceneBarLine : ManiaSkinnableTestScene + { + [Test] + public void TestMinor() + { + AddStep("Create barlines", () => recreate()); + } + + private void recreate(Func>? createBarLines = null) + { + var stageDefinitions = new List + { + new StageDefinition(4), + }; + + SetContents(_ => new ManiaPlayfield(stageDefinitions).With(s => + { + if (createBarLines != null) + { + var barLines = createBarLines(); + + foreach (var b in barLines) + s.Add(b); + + return; + } + + for (int i = 0; i < 64; i++) + { + s.Add(new BarLine + { + StartTime = Time.Current + i * 500, + Major = i % 4 == 0, + }); + } + })); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnBackground.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnBackground.cs index 106b2d188d..3881aae22e 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnBackground.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnBackground.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,7 +12,7 @@ using osuTK; namespace osu.Game.Rulesets.Mania.Tests.Skinning { - public class TestSceneColumnBackground : ManiaSkinnableTestScene + public partial class TestSceneColumnBackground : ManiaSkinnableTestScene { [BackgroundDependencyLoader] private void load() @@ -28,7 +30,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning { RelativeSizeAxes = Axes.Both, Width = 0.5f, - Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground), _ => new DefaultColumnBackground()) + Child = new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.ColumnBackground), _ => new DefaultColumnBackground()) { RelativeSizeAxes = Axes.Both } @@ -37,7 +39,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning { RelativeSizeAxes = Axes.Both, Width = 0.5f, - Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground), _ => new DefaultColumnBackground()) + Child = new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.ColumnBackground), _ => new DefaultColumnBackground()) { RelativeSizeAxes = Axes.Both } diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnHitObjectArea.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnHitObjectArea.cs index 8034341d15..9cccc2dd86 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnHitObjectArea.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnHitObjectArea.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,7 +12,7 @@ using osuTK; namespace osu.Game.Rulesets.Mania.Tests.Skinning { - public class TestSceneColumnHitObjectArea : ManiaSkinnableTestScene + public partial class TestSceneColumnHitObjectArea : ManiaSkinnableTestScene { [BackgroundDependencyLoader] private void load() diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneDrawableJudgement.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneDrawableJudgement.cs index d033676ec7..2a9727dbd4 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneDrawableJudgement.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneDrawableJudgement.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using osu.Framework.Extensions; @@ -15,7 +17,7 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Tests.Skinning { - public class TestSceneDrawableJudgement : ManiaSkinnableTestScene + public partial class TestSceneDrawableJudgement : ManiaSkinnableTestScene { public TestSceneDrawableJudgement() { diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHitExplosion.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHitExplosion.cs index 9e6e0a7776..30dd83123d 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHitExplosion.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHitExplosion.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -18,7 +20,7 @@ using osuTK; namespace osu.Game.Rulesets.Mania.Tests.Skinning { [TestFixture] - public class TestSceneHitExplosion : ManiaSkinnableTestScene + public partial class TestSceneHitExplosion : ManiaSkinnableTestScene { private readonly List> hitExplosionPools = new List>(); diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHoldNote.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHoldNote.cs index e88ff8e2ac..0b9ca42af8 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHoldNote.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHoldNote.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -13,7 +15,7 @@ using osu.Game.Rulesets.Mania.Objects.Drawables; namespace osu.Game.Rulesets.Mania.Tests.Skinning { - public class TestSceneHoldNote : ManiaHitObjectTestScene + public partial class TestSceneHoldNote : ManiaHitObjectTestScene { [Test] public void TestHoldNote() diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneKeyArea.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneKeyArea.cs deleted file mode 100644 index 7564bd84ad..0000000000 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneKeyArea.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Rulesets.Mania.UI.Components; -using osu.Game.Skinning; -using osuTK; - -namespace osu.Game.Rulesets.Mania.Tests.Skinning -{ - public class TestSceneKeyArea : ManiaSkinnableTestScene - { - [BackgroundDependencyLoader] - private void load() - { - SetContents(_ => new FillFlowContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.8f), - Direction = FillDirection.Horizontal, - Children = new Drawable[] - { - new ColumnTestContainer(0, ManiaAction.Key1) - { - RelativeSizeAxes = Axes.Both, - Width = 0.5f, - Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea), _ => new DefaultKeyArea()) - { - RelativeSizeAxes = Axes.Both - }, - }, - new ColumnTestContainer(1, ManiaAction.Key2) - { - RelativeSizeAxes = Axes.Both, - Width = 0.5f, - Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea), _ => new DefaultKeyArea()) - { - RelativeSizeAxes = Axes.Both - }, - }, - } - }); - } - } -} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneNote.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneNote.cs index bc3bdf0bcb..d049d88ea8 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneNote.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneNote.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Mania.Objects; @@ -8,7 +10,7 @@ using osu.Game.Rulesets.Mania.Objects.Drawables; namespace osu.Game.Rulesets.Mania.Tests.Skinning { - public class TestSceneNote : ManiaHitObjectTestScene + public partial class TestSceneNote : ManiaHitObjectTestScene { protected override DrawableManiaHitObject CreateHitObject() { diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs index c7dc5fc8b5..f85e303940 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using NUnit.Framework; using osu.Game.Beatmaps; @@ -9,7 +11,7 @@ using osu.Game.Rulesets.Mania.UI; namespace osu.Game.Rulesets.Mania.Tests.Skinning { - public class TestScenePlayfield : ManiaSkinnableTestScene + public partial class TestScenePlayfield : ManiaSkinnableTestScene { private List stageDefinitions = new List(); @@ -20,7 +22,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning { stageDefinitions = new List { - new StageDefinition { Columns = 2 } + new StageDefinition(2) }; SetContents(_ => new ManiaPlayfield(stageDefinitions)); @@ -34,8 +36,8 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning { stageDefinitions = new List { - new StageDefinition { Columns = 2 }, - new StageDefinition { Columns = 2 } + new StageDefinition(2), + new StageDefinition(2) }; SetContents(_ => new ManiaPlayfield(stageDefinitions)); diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStage.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStage.cs index 7804261906..25e24929c9 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStage.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStage.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.UI; namespace osu.Game.Rulesets.Mania.Tests.Skinning { - public class TestSceneStage : ManiaSkinnableTestScene + public partial class TestSceneStage : ManiaSkinnableTestScene { [BackgroundDependencyLoader] private void load() @@ -19,7 +21,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning return new ManiaInputManager(new ManiaRuleset().RulesetInfo, 4) { - Child = new Stage(0, new StageDefinition { Columns = 4 }, ref normalAction, ref specialAction) + Child = new Stage(0, new StageDefinition(4), ref normalAction, ref specialAction) }; }); } diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageBackground.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageBackground.cs index 410a43fc73..0557a201c8 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageBackground.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageBackground.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 osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.UI.Components; using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.Tests.Skinning { - public class TestSceneStageBackground : ManiaSkinnableTestScene + public partial class TestSceneStageBackground : ManiaSkinnableTestScene { [BackgroundDependencyLoader] private void load() { - SetContents(_ => new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageBackground, stageDefinition: new StageDefinition { Columns = 4 }), + SetContents(_ => new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.StageBackground), _ => new DefaultStageBackground()) { Anchor = Anchor.Centre, diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageForeground.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageForeground.cs index 27e97152bc..04d2eee83e 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageForeground.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageForeground.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 osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.Tests.Skinning { - public class TestSceneStageForeground : ManiaSkinnableTestScene + public partial class TestSceneStageForeground : ManiaSkinnableTestScene { [BackgroundDependencyLoader] private void load() { - SetContents(_ => new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageForeground, stageDefinition: new StageDefinition { Columns = 4 }), _ => null) + SetContents(_ => new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.StageForeground), _ => null) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs index cffec3dfd5..9fdd93bcc9 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Testing; @@ -14,7 +16,7 @@ namespace osu.Game.Rulesets.Mania.Tests { [TestFixture] [HeadlessTest] - public class TestSceneAutoGeneration : OsuTestScene + public partial class TestSceneAutoGeneration : OsuTestScene { /// /// The number of frames which are generated at the start of a replay regardless of hitobject content. @@ -28,7 +30,7 @@ namespace osu.Game.Rulesets.Mania.Tests // | - | // | | - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 1 }); + var beatmap = new ManiaBeatmap(new StageDefinition(1)); beatmap.HitObjects.Add(new Note { StartTime = 1000 }); var generated = new ManiaAutoGenerator(beatmap).Generate(); @@ -49,7 +51,7 @@ namespace osu.Game.Rulesets.Mania.Tests // | * | // | | - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 1 }); + var beatmap = new ManiaBeatmap(new StageDefinition(1)); beatmap.HitObjects.Add(new HoldNote { StartTime = 1000, Duration = 2000 }); var generated = new ManiaAutoGenerator(beatmap).Generate(); @@ -68,7 +70,7 @@ namespace osu.Game.Rulesets.Mania.Tests // | - | - | // | | | - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 2 }); + var beatmap = new ManiaBeatmap(new StageDefinition(2)); beatmap.HitObjects.Add(new Note { StartTime = 1000 }); beatmap.HitObjects.Add(new Note { StartTime = 1000, Column = 1 }); @@ -90,7 +92,7 @@ namespace osu.Game.Rulesets.Mania.Tests // | * | * | // | | | - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 2 }); + var beatmap = new ManiaBeatmap(new StageDefinition(2)); beatmap.HitObjects.Add(new HoldNote { StartTime = 1000, Duration = 2000 }); beatmap.HitObjects.Add(new HoldNote { StartTime = 1000, Duration = 2000, Column = 1 }); @@ -113,7 +115,7 @@ namespace osu.Game.Rulesets.Mania.Tests // | - | | // | | | - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 2 }); + var beatmap = new ManiaBeatmap(new StageDefinition(2)); beatmap.HitObjects.Add(new Note { StartTime = 1000 }); beatmap.HitObjects.Add(new Note { StartTime = 2000, Column = 1 }); @@ -140,7 +142,7 @@ namespace osu.Game.Rulesets.Mania.Tests // | * | | // | | | - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 2 }); + var beatmap = new ManiaBeatmap(new StageDefinition(2)); beatmap.HitObjects.Add(new HoldNote { StartTime = 1000, Duration = 2000 }); beatmap.HitObjects.Add(new HoldNote { StartTime = 2000, Duration = 2000, Column = 1 }); @@ -167,7 +169,7 @@ namespace osu.Game.Rulesets.Mania.Tests // | * | | // | | | - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 2 }); + var beatmap = new ManiaBeatmap(new StageDefinition(2)); beatmap.HitObjects.Add(new HoldNote { StartTime = 1000, Duration = 2000 }); beatmap.HitObjects.Add(new Note { StartTime = 3000, Column = 1 }); diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneColumn.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneColumn.cs index d9b1ad22fa..b96fab9ec0 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneColumn.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneColumn.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using NUnit.Framework; @@ -9,6 +11,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.UI; @@ -21,11 +24,14 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Tests { [TestFixture] - public class TestSceneColumn : ManiaInputTestScene + public partial class TestSceneColumn : ManiaInputTestScene { [Cached(typeof(IReadOnlyList))] private IReadOnlyList mods { get; set; } = Array.Empty(); + [Cached] + private readonly StageDefinition stage = new StageDefinition(1); + private readonly List columns = new List(); public TestSceneColumn() @@ -82,12 +88,12 @@ namespace osu.Game.Rulesets.Mania.Tests private Drawable createColumn(ScrollingDirection direction, ManiaAction action, int index) { - var column = new Column(index) + var column = new Column(index, false) { Anchor = Anchor.Centre, Origin = Anchor.Centre, Height = 0.85f, - AccentColour = Color4.OrangeRed, + AccentColour = { Value = Color4.OrangeRed }, Action = { Value = action }, }; diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneDrawableManiaHitObject.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneDrawableManiaHitObject.cs index 92c95b8fde..51c2bac6d1 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneDrawableManiaHitObject.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneDrawableManiaHitObject.cs @@ -1,12 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Input.Events; using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.UI; @@ -16,12 +20,15 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Tests { - public class TestSceneDrawableManiaHitObject : OsuTestScene + public partial class TestSceneDrawableManiaHitObject : OsuTestScene { private readonly ManualClock clock = new ManualClock(); private Column column; + [Cached] + private readonly StageDefinition stage = new StageDefinition(1); + [SetUp] public void SetUp() => Schedule(() => { @@ -33,11 +40,11 @@ namespace osu.Game.Rulesets.Mania.Tests RelativeSizeAxes = Axes.Y, TimeRange = 2000, Clock = new FramedClock(clock), - Child = column = new Column(0) + Child = column = new Column(0, false) { Action = { Value = ManiaAction.Key1 }, Height = 0.85f, - AccentColour = Color4.Gray + AccentColour = { Value = Color4.Gray }, }, }; }); diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs index f973cb5ed3..42e2099e3f 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -21,7 +23,16 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Mania.Tests { - public class TestSceneHoldNoteInput : RateAdjustedBeatmapTestScene + /// + /// Diagrams in this class are represented as: + /// - : time + /// O : note + /// [ ] : hold note + /// + /// x : button press + /// o : button release + /// + public partial class TestSceneHoldNoteInput : RateAdjustedBeatmapTestScene { private const double time_before_head = 250; private const double time_head = 1500; @@ -47,7 +58,7 @@ namespace osu.Game.Rulesets.Mania.Tests assertHeadJudgement(HitResult.Miss); assertTickJudgement(HitResult.LargeTickMiss); assertTailJudgement(HitResult.Miss); - assertNoteJudgement(HitResult.IgnoreHit); + assertNoteJudgement(HitResult.IgnoreMiss); } /// @@ -221,6 +232,149 @@ namespace osu.Game.Rulesets.Mania.Tests assertTailJudgement(HitResult.Meh); } + /// + /// -----[ ]-O------------- + /// xo o + /// + [Test] + public void TestPressAndReleaseJustBeforeTailWithNearbyNoteAndCloseByHead() + { + Note note; + + const int duration = 50; + + var beatmap = new Beatmap + { + HitObjects = + { + // hold note is very short, to make the head still in range + new HoldNote + { + StartTime = time_head, + Duration = duration, + Column = 0, + }, + { + // Next note within tail lenience + note = new Note + { + StartTime = time_head + duration + 10 + } + } + }, + BeatmapInfo = + { + Difficulty = new BeatmapDifficulty { SliderTickRate = 4 }, + Ruleset = new ManiaRuleset().RulesetInfo + }, + }; + + performTest(new List + { + new ManiaReplayFrame(time_head + duration, ManiaAction.Key1), + new ManiaReplayFrame(time_head + duration + 10), + }, beatmap); + + assertHeadJudgement(HitResult.Good); + assertTailJudgement(HitResult.Perfect); + + assertHitObjectJudgement(note, HitResult.Miss); + } + + /// + /// -----[ ]--O-- + /// xo o + /// + [Test] + public void TestPressAndReleaseJustBeforeTailWithNearbyNote() + { + Note note; + + var beatmap = new Beatmap + { + HitObjects = + { + new HoldNote + { + StartTime = time_head, + Duration = time_tail - time_head, + Column = 0, + }, + { + // Next note within tail lenience + note = new Note + { + StartTime = time_tail + 50 + } + } + }, + BeatmapInfo = + { + Difficulty = new BeatmapDifficulty { SliderTickRate = 4 }, + Ruleset = new ManiaRuleset().RulesetInfo + }, + }; + + performTest(new List + { + new ManiaReplayFrame(time_tail - 10, ManiaAction.Key1), + new ManiaReplayFrame(time_tail), + }, beatmap); + + assertHeadJudgement(HitResult.Miss); + assertTickJudgement(HitResult.LargeTickMiss); + assertTailJudgement(HitResult.Miss); + + assertHitObjectJudgement(note, HitResult.Good); + } + + /// + /// -----[ ]--O-- + /// xo o + /// + [Test] + public void TestPressAndReleaseJustAfterTailWithNearbyNote() + { + Note note; + + var beatmap = new Beatmap + { + HitObjects = + { + new HoldNote + { + StartTime = time_head, + Duration = time_tail - time_head, + Column = 0, + }, + { + // Next note within tail lenience + note = new Note + { + StartTime = time_tail + 50 + } + } + }, + BeatmapInfo = + { + Difficulty = new BeatmapDifficulty { SliderTickRate = 4 }, + Ruleset = new ManiaRuleset().RulesetInfo + }, + }; + + performTest(new List + { + new ManiaReplayFrame(time_tail + 10, ManiaAction.Key1), + new ManiaReplayFrame(time_tail + 20), + }, beatmap); + + assertHeadJudgement(HitResult.Miss); + assertTickJudgement(HitResult.LargeTickMiss); + assertTailJudgement(HitResult.Miss); + + assertHitObjectJudgement(note, HitResult.Great); + } + /// /// -----[ ]----- /// xo o @@ -349,20 +503,23 @@ namespace osu.Game.Rulesets.Mania.Tests .All(j => j.Type.IsHit())); } + private void assertHitObjectJudgement(HitObject hitObject, HitResult result) + => AddAssert($"object judged as {result}", () => judgementResults.First(j => j.HitObject == hitObject).Type, () => Is.EqualTo(result)); + private void assertHeadJudgement(HitResult result) - => AddAssert($"head judged as {result}", () => judgementResults.First(j => j.HitObject is Note).Type == result); + => AddAssert($"head judged as {result}", () => judgementResults.First(j => j.HitObject is Note).Type, () => Is.EqualTo(result)); private void assertTailJudgement(HitResult result) - => AddAssert($"tail judged as {result}", () => judgementResults.Single(j => j.HitObject is TailNote).Type == result); + => AddAssert($"tail judged as {result}", () => judgementResults.Single(j => j.HitObject is TailNote).Type, () => Is.EqualTo(result)); private void assertNoteJudgement(HitResult result) - => AddAssert($"hold note judged as {result}", () => judgementResults.Single(j => j.HitObject is HoldNote).Type == result); + => AddAssert($"hold note judged as {result}", () => judgementResults.Single(j => j.HitObject is HoldNote).Type, () => Is.EqualTo(result)); private void assertTickJudgement(HitResult result) - => AddAssert($"any tick judged as {result}", () => judgementResults.Where(j => j.HitObject is HoldNoteTick).Any(j => j.Type == result)); + => AddAssert($"any tick judged as {result}", () => judgementResults.Where(j => j.HitObject is HoldNoteTick).Select(j => j.Type), () => Does.Contain(result)); private void assertLastTickJudgement(HitResult result) - => AddAssert($"last tick judged as {result}", () => judgementResults.Last(j => j.HitObject is HoldNoteTick).Type == result); + => AddAssert($"last tick judged as {result}", () => judgementResults.Last(j => j.HitObject is HoldNoteTick).Type, () => Is.EqualTo(result)); private ScoreAccessibleReplayPlayer currentPlayer; @@ -415,7 +572,7 @@ namespace osu.Game.Rulesets.Mania.Tests AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor?.HasCompleted.Value == true); } - private class ScoreAccessibleReplayPlayer : ReplayPlayer + private partial class ScoreAccessibleReplayPlayer : ReplayPlayer { public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectSamples.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectSamples.cs index ea57e51d1c..7021c081b7 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectSamples.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectSamples.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Reflection; using NUnit.Framework; using osu.Framework.IO.Stores; @@ -8,7 +10,7 @@ using osu.Game.Tests.Beatmaps; namespace osu.Game.Rulesets.Mania.Tests { - public class TestSceneManiaHitObjectSamples : HitObjectSampleTest + public partial class TestSceneManiaHitObjectSamples : HitObjectSampleTest { protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); protected override IResourceStore RulesetResources => new DllResourceStore(Assembly.GetAssembly(typeof(TestSceneManiaHitObjectSamples))); diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneManiaPlayer.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaPlayer.cs index a399b90585..4e50fd924c 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneManiaPlayer.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaPlayer.cs @@ -1,12 +1,31 @@ // 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.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 { - public class TestSceneManiaPlayer : PlayerTestScene + 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.Tests/TestSceneNotes.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneNotes.cs index 706268e478..31ff57395c 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneNotes.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneNotes.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -27,7 +29,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Tests { [TestFixture] - public class TestSceneNotes : OsuTestScene + public partial class TestSceneNotes : OsuTestScene { [Test] public void TestVariousNotes() @@ -103,7 +105,7 @@ namespace osu.Game.Rulesets.Mania.Tests private bool verifyAnchors(DrawableHoldNote holdNote, Anchor expectedAnchor) => verifyAnchors((DrawableHitObject)holdNote, expectedAnchor) && holdNote.NestedHitObjects.All(n => verifyAnchors(n, expectedAnchor)); - private class NoteContainer : Container + private partial class NoteContainer : Container { private readonly Container content; protected override Container Content => content; diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneOutOfOrderHits.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneOutOfOrderHits.cs index 89e13acad6..e49b259615 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneOutOfOrderHits.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneOutOfOrderHits.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -22,7 +24,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Mania.Tests { - public class TestSceneOutOfOrderHits : RateAdjustedBeatmapTestScene + public partial class TestSceneOutOfOrderHits : RateAdjustedBeatmapTestScene { [Test] public void TestPreviousHitWindowDoesNotExtendPastNextObject() @@ -74,8 +76,8 @@ namespace osu.Game.Rulesets.Mania.Tests performTest(objects, new List()); - addJudgementAssert(objects[0], HitResult.IgnoreHit); - addJudgementAssert(objects[1], HitResult.IgnoreHit); + addJudgementAssert(objects[0], HitResult.IgnoreMiss); + addJudgementAssert(objects[1], HitResult.IgnoreMiss); } [Test] @@ -139,7 +141,7 @@ namespace osu.Game.Rulesets.Mania.Tests { AddStep("load player", () => { - Beatmap.Value = CreateWorkingBeatmap(new ManiaBeatmap(new StageDefinition { Columns = 4 }) + Beatmap.Value = CreateWorkingBeatmap(new ManiaBeatmap(new StageDefinition(4)) { HitObjects = hitObjects, BeatmapInfo = @@ -169,7 +171,7 @@ namespace osu.Game.Rulesets.Mania.Tests AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor.HasCompleted.Value); } - private class ScoreAccessibleReplayPlayer : ReplayPlayer + private partial class ScoreAccessibleReplayPlayer : ReplayPlayer { public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; diff --git a/osu.Game.Rulesets.Mania.Tests/TestScenePlayfieldCoveringContainer.cs b/osu.Game.Rulesets.Mania.Tests/TestScenePlayfieldCoveringContainer.cs index 8698ba3abd..f497c88bcc 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestScenePlayfieldCoveringContainer.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestScenePlayfieldCoveringContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; @@ -12,7 +14,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Tests { - public class TestScenePlayfieldCoveringContainer : OsuTestScene + public partial class TestScenePlayfieldCoveringContainer : OsuTestScene { private readonly ScrollingTestContainer scrollingContainer; private readonly PlayfieldCoveringWrapper cover; diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs index 7376a90f17..86499a7c6e 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -22,7 +24,7 @@ using osuTK; namespace osu.Game.Rulesets.Mania.Tests { [TestFixture] - public class TestSceneStage : ManiaInputTestScene + public partial class TestSceneStage : ManiaInputTestScene { private const int columns = 4; @@ -133,7 +135,7 @@ namespace osu.Game.Rulesets.Mania.Tests { var specialAction = ManiaAction.Special1; - var stage = new Stage(0, new StageDefinition { Columns = 2 }, ref action, ref specialAction); + var stage = new Stage(0, new StageDefinition(2), ref action, ref specialAction); stages.Add(stage); return new ScrollingTestContainer(direction) diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs index 4c688520ef..81557c198d 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -22,7 +24,7 @@ using osu.Game.Rulesets.Mania.UI; namespace osu.Game.Rulesets.Mania.Tests { [TestFixture] - public class TestSceneTimingBasedNoteColouring : OsuTestScene + public partial class TestSceneTimingBasedNoteColouring : OsuTestScene { private Bindable configTimingBasedNoteColouring; @@ -83,7 +85,7 @@ namespace osu.Game.Rulesets.Mania.Tests { const double beat_length = 500; - var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 1 }) + var beatmap = new ManiaBeatmap(new StageDefinition(1)) { HitObjects = { diff --git a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj index d3b4b378c0..027bf60a0c 100644 --- a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj +++ b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj @@ -1,11 +1,9 @@  - - + - - + WinExe diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs index 0058f6f884..b5655a4579 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs @@ -1,6 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System; using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; @@ -58,5 +61,18 @@ namespace osu.Game.Rulesets.Mania.Beatmaps }, }; } + + public StageDefinition GetStageForColumnIndex(int column) + { + foreach (var stage in Stages) + { + if (column < stage.Columns) + return stage; + + column -= stage.Columns; + } + + throw new ArgumentOutOfRangeException(nameof(column), "Provided index exceeds all available stages"); + } } } diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs index 207c6907c8..632b7cdcc7 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Mania.Objects; using System; using System.Linq; @@ -91,10 +93,10 @@ namespace osu.Game.Rulesets.Mania.Beatmaps protected override Beatmap CreateBeatmap() { - beatmap = new ManiaBeatmap(new StageDefinition { Columns = TargetColumns }, originalTargetColumns); + beatmap = new ManiaBeatmap(new StageDefinition(TargetColumns), originalTargetColumns); if (Dual) - beatmap.Stages.Add(new StageDefinition { Columns = TargetColumns }); + beatmap.Stages.Add(new StageDefinition(TargetColumns)); return beatmap; } @@ -171,7 +173,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps switch (original) { - case IHasDistance _: + case IHasDistance: { var generator = new DistanceObjectPatternGenerator(Random, original, beatmap, lastPattern, originalBeatmap); conversion = generator; diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs index dafe65f415..2bdd0e16ad 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/EndTimeObjectPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/EndTimeObjectPatternGenerator.cs index 2265d3d347..630fdf7ae2 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/EndTimeObjectPatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/EndTimeObjectPatternGenerator.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs index 41d4c9322b..912cac4fe4 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternGenerator.cs index d5689c047a..77f93b4ef9 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternGenerator.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using JetBrains.Annotations; @@ -33,8 +35,8 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy protected PatternGenerator(LegacyRandom random, HitObject hitObject, ManiaBeatmap beatmap, Pattern previousPattern, IBeatmap originalBeatmap) : base(hitObject, beatmap, previousPattern) { - if (random == null) throw new ArgumentNullException(nameof(random)); - if (originalBeatmap == null) throw new ArgumentNullException(nameof(originalBeatmap)); + ArgumentNullException.ThrowIfNull(random); + ArgumentNullException.ThrowIfNull(originalBeatmap); Random = random; OriginalBeatmap = originalBeatmap; diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternType.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternType.cs index e4a28167ec..bf54dc3179 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternType.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternType.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Pattern.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Pattern.cs index 828f2ec393..4b3902657f 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Pattern.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Pattern.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Game.Rulesets.Mania.Objects; diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/PatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/PatternGenerator.cs index 71b3f6ecf2..931673f337 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/PatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/PatternGenerator.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using osu.Game.Rulesets.Objects; @@ -31,9 +33,9 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns protected PatternGenerator(HitObject hitObject, ManiaBeatmap beatmap, Pattern previousPattern) { - if (hitObject == null) throw new ArgumentNullException(nameof(hitObject)); - if (beatmap == null) throw new ArgumentNullException(nameof(beatmap)); - if (previousPattern == null) throw new ArgumentNullException(nameof(previousPattern)); + ArgumentNullException.ThrowIfNull(hitObject); + ArgumentNullException.ThrowIfNull(beatmap); + ArgumentNullException.ThrowIfNull(previousPattern); HitObject = hitObject; Beatmap = beatmap; diff --git a/osu.Game.Rulesets.Mania/Beatmaps/StageDefinition.cs b/osu.Game.Rulesets.Mania/Beatmaps/StageDefinition.cs index 3052fc7d34..898b558eb3 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/StageDefinition.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/StageDefinition.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Game.Rulesets.Mania.UI; @@ -9,32 +11,26 @@ namespace osu.Game.Rulesets.Mania.Beatmaps /// /// Defines properties for each stage in a . /// - public struct StageDefinition + public class StageDefinition { /// /// The number of s which this stage contains. /// - public int Columns; + public readonly int Columns; + + public StageDefinition(int columns) + { + if (columns < 1) + throw new ArgumentException("Column count must be above zero.", nameof(columns)); + + Columns = columns; + } /// /// Whether the column index is a special column for this stage. /// /// The 0-based column index. /// Whether the column is a special column. - public readonly bool IsSpecialColumn(int column) => Columns % 2 == 1 && column == Columns / 2; - - /// - /// Get the type of column given a column index. - /// - /// The 0-based column index. - /// The type of the column. - public readonly ColumnType GetTypeOfColumn(int column) - { - if (IsSpecialColumn(column)) - return ColumnType.Special; - - int distanceToEdge = Math.Min(column, (Columns - 1) - column); - return distanceToEdge % 2 == 0 ? ColumnType.Odd : ColumnType.Even; - } + public bool IsSpecialColumn(int column) => Columns % 2 == 1 && column == Columns / 2; } } diff --git a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs index 8e09a01469..99a80ef28d 100644 --- a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs +++ b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs @@ -4,6 +4,7 @@ using System; using osu.Framework.Configuration.Tracking; using osu.Game.Configuration; +using osu.Game.Localisation; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Mania.UI; @@ -11,7 +12,7 @@ namespace osu.Game.Rulesets.Mania.Configuration { public class ManiaRulesetConfigManager : RulesetConfigManager { - public ManiaRulesetConfigManager(SettingsStore settings, RulesetInfo ruleset, int? variant = null) + public ManiaRulesetConfigManager(SettingsStore? settings, RulesetInfo ruleset, int? variant = null) : base(settings, ruleset, variant) { } @@ -30,8 +31,8 @@ namespace osu.Game.Rulesets.Mania.Configuration new TrackedSetting(ManiaRulesetSetting.ScrollTime, scrollTime => new SettingDescription( rawValue: scrollTime, - name: "Scroll Speed", - value: $"{(int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / scrollTime)} ({scrollTime}ms)" + name: RulesetSettingsStrings.ScrollSpeed, + value: RulesetSettingsStrings.ScrollSpeedTooltip(scrollTime, (int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / scrollTime)) ) ) }; diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs index c35a3dcdc2..d259c2af8e 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using Newtonsoft.Json; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; namespace osu.Game.Rulesets.Mania.Difficulty @@ -18,12 +19,6 @@ namespace osu.Game.Rulesets.Mania.Difficulty [JsonProperty("great_hit_window")] public double GreatHitWindow { get; set; } - /// - /// The score multiplier applied via score-reducing mods. - /// - [JsonProperty("score_multiplier")] - public double ScoreMultiplier { get; set; } - public override IEnumerable<(int attributeId, object value)> ToDatabaseAttributes() { foreach (var v in base.ToDatabaseAttributes()) @@ -32,17 +27,15 @@ namespace osu.Game.Rulesets.Mania.Difficulty yield return (ATTRIB_ID_MAX_COMBO, MaxCombo); yield return (ATTRIB_ID_DIFFICULTY, StarRating); yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow); - yield return (ATTRIB_ID_SCORE_MULTIPLIER, ScoreMultiplier); } - public override void FromDatabaseAttributes(IReadOnlyDictionary values) + public override void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo) { - base.FromDatabaseAttributes(values); + base.FromDatabaseAttributes(values, onlineInfo); MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO]; StarRating = values[ATTRIB_ID_DIFFICULTY]; GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW]; - ScoreMultiplier = values[ATTRIB_ID_SCORE_MULTIPLIER]; } } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs index 88f51bf961..63e61f17e3 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -29,6 +31,8 @@ namespace osu.Game.Rulesets.Mania.Difficulty private readonly bool isForCurrentRuleset; private readonly double originalOverallDifficulty; + public override int Version => 20220902; + public ManiaDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) { @@ -51,7 +55,6 @@ namespace osu.Game.Rulesets.Mania.Difficulty // In osu-stable mania, rate-adjustment mods don't affect the hit window. // This is done the way it is to introduce fractional differences in order to match osu-stable for the time being. GreatHitWindow = Math.Ceiling((int)(getHitWindow300(mods) * clockRate) / clockRate), - ScoreMultiplier = getScoreMultiplier(mods), MaxCombo = beatmap.HitObjects.Sum(maxComboForObject) }; } @@ -70,8 +73,12 @@ namespace osu.Game.Rulesets.Mania.Difficulty LegacySortHelper.Sort(sortedObjects, Comparer.Create((a, b) => (int)Math.Round(a.StartTime) - (int)Math.Round(b.StartTime))); + List objects = new List(); + for (int i = 1; i < sortedObjects.Length; i++) - yield return new ManiaDifficultyHitObject(sortedObjects[i], sortedObjects[i - 1], clockRate); + objects.Add(new ManiaDifficultyHitObject(sortedObjects[i], sortedObjects[i - 1], clockRate, objects, objects.Count)); + + return objects; } // Sorting is done in CreateDifficultyHitObjects, since the full list of hitobjects is required. @@ -141,32 +148,5 @@ namespace osu.Game.Rulesets.Mania.Difficulty return value; } } - - private double getScoreMultiplier(Mod[] mods) - { - double scoreMultiplier = 1; - - foreach (var m in mods) - { - switch (m) - { - case ManiaModNoFail _: - case ManiaModEasy _: - case ManiaModHalfTime _: - scoreMultiplier *= 0.5; - break; - } - } - - var maniaBeatmap = (ManiaBeatmap)Beatmap; - int diff = maniaBeatmap.TotalColumns - maniaBeatmap.OriginalTotalColumns; - - if (diff > 0) - scoreMultiplier *= 0.9; - else if (diff < 0) - scoreMultiplier *= 0.9 + 0.04 * diff; - - return scoreMultiplier; - } } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceAttributes.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceAttributes.cs index 17c864a268..01474e6e00 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceAttributes.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceAttributes.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using Newtonsoft.Json; using osu.Game.Rulesets.Difficulty; @@ -12,19 +14,12 @@ namespace osu.Game.Rulesets.Mania.Difficulty [JsonProperty("difficulty")] public double Difficulty { get; set; } - [JsonProperty("accuracy")] - public double Accuracy { get; set; } - - [JsonProperty("scaled_score")] - public double ScaledScore { get; set; } - public override IEnumerable GetAttributesForDisplay() { foreach (var attribute in base.GetAttributesForDisplay()) yield return attribute; yield return new PerformanceDisplayAttribute(nameof(Difficulty), "Difficulty", Difficulty); - yield return new PerformanceDisplayAttribute(nameof(Accuracy), "Accuracy", Accuracy); } } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs index b347cc9ae2..440dec82af 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -13,15 +15,13 @@ namespace osu.Game.Rulesets.Mania.Difficulty { public class ManiaPerformanceCalculator : PerformanceCalculator { - // Score after being scaled by non-difficulty-increasing mods - private double scaledScore; - private int countPerfect; private int countGreat; private int countGood; private int countOk; private int countMeh; private int countMiss; + private double scoreAccuracy; public ManiaPerformanceCalculator() : base(new ManiaRuleset()) @@ -32,82 +32,53 @@ namespace osu.Game.Rulesets.Mania.Difficulty { var maniaAttributes = (ManiaDifficultyAttributes)attributes; - scaledScore = score.TotalScore; countPerfect = score.Statistics.GetValueOrDefault(HitResult.Perfect); countGreat = score.Statistics.GetValueOrDefault(HitResult.Great); countGood = score.Statistics.GetValueOrDefault(HitResult.Good); countOk = score.Statistics.GetValueOrDefault(HitResult.Ok); countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); - - if (maniaAttributes.ScoreMultiplier > 0) - { - // Scale score up, so it's comparable to other keymods - scaledScore *= 1.0 / maniaAttributes.ScoreMultiplier; - } + scoreAccuracy = calculateCustomAccuracy(); // Arbitrary initial value for scaling pp in order to standardize distributions across game modes. // The specific number has no intrinsic meaning and can be adjusted as needed. - double multiplier = 0.8; + double multiplier = 8.0; if (score.Mods.Any(m => m is ModNoFail)) - multiplier *= 0.9; + multiplier *= 0.75; if (score.Mods.Any(m => m is ModEasy)) multiplier *= 0.5; double difficultyValue = computeDifficultyValue(maniaAttributes); - double accValue = computeAccuracyValue(difficultyValue, maniaAttributes); - double totalValue = - Math.Pow( - Math.Pow(difficultyValue, 1.1) + - Math.Pow(accValue, 1.1), 1.0 / 1.1 - ) * multiplier; + double totalValue = difficultyValue * multiplier; return new ManiaPerformanceAttributes { Difficulty = difficultyValue, - Accuracy = accValue, - ScaledScore = scaledScore, Total = totalValue }; } private double computeDifficultyValue(ManiaDifficultyAttributes attributes) { - double difficultyValue = Math.Pow(5 * Math.Max(1, attributes.StarRating / 0.2) - 4.0, 2.2) / 135.0; - - difficultyValue *= 1.0 + 0.1 * Math.Min(1.0, totalHits / 1500.0); - - if (scaledScore <= 500000) - difficultyValue = 0; - else if (scaledScore <= 600000) - difficultyValue *= (scaledScore - 500000) / 100000 * 0.3; - else if (scaledScore <= 700000) - difficultyValue *= 0.3 + (scaledScore - 600000) / 100000 * 0.25; - else if (scaledScore <= 800000) - difficultyValue *= 0.55 + (scaledScore - 700000) / 100000 * 0.20; - else if (scaledScore <= 900000) - difficultyValue *= 0.75 + (scaledScore - 800000) / 100000 * 0.15; - else - difficultyValue *= 0.90 + (scaledScore - 900000) / 100000 * 0.1; + double difficultyValue = Math.Pow(Math.Max(attributes.StarRating - 0.15, 0.05), 2.2) // Star rating to pp curve + * Math.Max(0, 5 * scoreAccuracy - 4) // From 80% accuracy, 1/20th of total pp is awarded per additional 1% accuracy + * (1 + 0.1 * Math.Min(1, totalHits / 1500)); // Length bonus, capped at 1500 notes return difficultyValue; } - private double computeAccuracyValue(double difficultyValue, ManiaDifficultyAttributes attributes) + private double totalHits => countPerfect + countOk + countGreat + countGood + countMeh + countMiss; + + /// + /// Accuracy used to weight judgements independently from the score's actual accuracy. + /// + private double calculateCustomAccuracy() { - if (attributes.GreatHitWindow <= 0) + if (totalHits == 0) return 0; - // Lots of arbitrary values from testing. - // Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution - double accuracyValue = Math.Max(0.0, 0.2 - (attributes.GreatHitWindow - 34) * 0.006667) - * difficultyValue - * Math.Pow(Math.Max(0.0, scaledScore - 960000) / 40000, 1.1); - - return accuracyValue; + return (countPerfect * 320 + countGreat * 300 + countGood * 200 + countOk * 100 + countMeh * 50) / (totalHits * 320); } - - private double totalHits => countPerfect + countOk + countGreat + countGood + countMeh + countMiss; } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/Preprocessing/ManiaDifficultyHitObject.cs b/osu.Game.Rulesets.Mania/Difficulty/Preprocessing/ManiaDifficultyHitObject.cs index 29ba934e9f..df95654319 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/Preprocessing/ManiaDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/Preprocessing/ManiaDifficultyHitObject.cs @@ -1,6 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System.Collections.Generic; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Objects; @@ -11,8 +14,8 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Preprocessing { public new ManiaHitObject BaseObject => (ManiaHitObject)base.BaseObject; - public ManiaDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate) - : base(hitObject, lastObject, clockRate) + public ManiaDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List objects, int index) + : base(hitObject, lastObject, clockRate, objects, index) { } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs index 31550a8105..2c7c84de97 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Utils; using osu.Game.Rulesets.Difficulty.Preprocessing; @@ -19,7 +21,8 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills protected override double SkillMultiplier => 1; protected override double StrainDecayBase => 1; - private readonly double[] holdEndTimes; + private readonly double[] startTimes; + private readonly double[] endTimes; private readonly double[] individualStrains; private double individualStrain; @@ -28,7 +31,8 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills public Strain(Mod[] mods, int totalColumns) : base(mods) { - holdEndTimes = new double[totalColumns]; + startTimes = new double[totalColumns]; + endTimes = new double[totalColumns]; individualStrains = new double[totalColumns]; overallStrain = 1; } @@ -36,32 +40,27 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills protected override double StrainValueOf(DifficultyHitObject current) { var maniaCurrent = (ManiaDifficultyHitObject)current; + double startTime = maniaCurrent.StartTime; double endTime = maniaCurrent.EndTime; int column = maniaCurrent.BaseObject.Column; - double closestEndTime = Math.Abs(endTime - maniaCurrent.LastObject.StartTime); // Lowest value we can assume with the current information - - double holdFactor = 1.0; // Factor to all additional strains in case something else is held - double holdAddition = 0; // Addition to the current note in case it's a hold and has to be released awkwardly bool isOverlapping = false; - // Fill up the holdEndTimes array - for (int i = 0; i < holdEndTimes.Length; ++i) + double closestEndTime = Math.Abs(endTime - startTime); // Lowest value we can assume with the current information + double holdFactor = 1.0; // Factor to all additional strains in case something else is held + double holdAddition = 0; // Addition to the current note in case it's a hold and has to be released awkwardly + + for (int i = 0; i < endTimes.Length; ++i) { // The current note is overlapped if a previous note or end is overlapping the current note body - isOverlapping |= Precision.DefinitelyBigger(holdEndTimes[i], maniaCurrent.StartTime, 1) && Precision.DefinitelyBigger(endTime, holdEndTimes[i], 1); + isOverlapping |= Precision.DefinitelyBigger(endTimes[i], startTime, 1) && Precision.DefinitelyBigger(endTime, endTimes[i], 1); // We give a slight bonus to everything if something is held meanwhile - if (Precision.DefinitelyBigger(holdEndTimes[i], endTime, 1)) + if (Precision.DefinitelyBigger(endTimes[i], endTime, 1)) holdFactor = 1.25; - closestEndTime = Math.Min(closestEndTime, Math.Abs(endTime - holdEndTimes[i])); - - // Decay individual strains - individualStrains[i] = applyDecay(individualStrains[i], current.DeltaTime, individual_decay_base); + closestEndTime = Math.Min(closestEndTime, Math.Abs(endTime - endTimes[i])); } - holdEndTimes[column] = endTime; - // The hold addition is given if there was an overlap, however it is only valid if there are no other note with a similar ending. // Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away. // holdAddition @@ -75,18 +74,28 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills if (isOverlapping) holdAddition = 1 / (1 + Math.Exp(0.5 * (release_threshold - closestEndTime))); - // Increase individual strain in own column + // Decay and increase individualStrains in own column + individualStrains[column] = applyDecay(individualStrains[column], startTime - startTimes[column], individual_decay_base); individualStrains[column] += 2.0 * holdFactor; - individualStrain = individualStrains[column]; - overallStrain = applyDecay(overallStrain, current.DeltaTime, overall_decay_base) + (1 + holdAddition) * holdFactor; + // For notes at the same time (in a chord), the individualStrain should be the hardest individualStrain out of those columns + individualStrain = maniaCurrent.DeltaTime <= 1 ? Math.Max(individualStrain, individualStrains[column]) : individualStrains[column]; + // Decay and increase overallStrain + overallStrain = applyDecay(overallStrain, current.DeltaTime, overall_decay_base); + overallStrain += (1 + holdAddition) * holdFactor; + + // Update startTimes and endTimes arrays + startTimes[column] = startTime; + endTimes[column] = endTime; + + // By subtracting CurrentStrain, this skill effectively only considers the maximum strain of any one hitobject within each strain section. return individualStrain + overallStrain - CurrentStrain; } - protected override double CalculateInitialStrain(double offset) - => applyDecay(individualStrain, offset - Previous[0].StartTime, individual_decay_base) - + applyDecay(overallStrain, offset - Previous[0].StartTime, overall_decay_base); + protected override double CalculateInitialStrain(double offset, DifficultyHitObject current) + => applyDecay(individualStrain, offset - current.Previous(0).StartTime, individual_decay_base) + + applyDecay(overallStrain, offset - current.Previous(0).StartTime, overall_decay_base); private double applyDecay(double value, double deltaTime, double decayBase) => value * Math.Pow(decayBase, deltaTime / 1000); diff --git a/osu.Game.Rulesets.Mania/DualStageVariantGenerator.cs b/osu.Game.Rulesets.Mania/DualStageVariantGenerator.cs index 8d39e08b26..262247e244 100644 --- a/osu.Game.Rulesets.Mania/DualStageVariantGenerator.cs +++ b/osu.Game.Rulesets.Mania/DualStageVariantGenerator.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Input.Bindings; diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs index f5067ea366..be1cc9a7fe 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Graphics; @@ -8,7 +10,7 @@ using osu.Game.Rulesets.Mania.Skinning.Default; namespace osu.Game.Rulesets.Mania.Edit.Blueprints.Components { - public class EditBodyPiece : DefaultBodyPiece + public partial class EditBodyPiece : DefaultBodyPiece { [BackgroundDependencyLoader] private void load(OsuColour colours) diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs index 9c9273de3a..ef7ce9073c 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; @@ -8,7 +10,7 @@ using osu.Game.Rulesets.Mania.Skinning.Default; namespace osu.Game.Rulesets.Mania.Edit.Blueprints.Components { - public class EditNotePiece : CompositeDrawable + public partial class EditNotePiece : CompositeDrawable { public EditNotePiece() { diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs index 093a8da24f..21beee0769 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -14,7 +16,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { - public class HoldNotePlacementBlueprint : ManiaPlacementBlueprint + public partial class HoldNotePlacementBlueprint : ManiaPlacementBlueprint { private readonly EditBodyPiece bodyPiece; private readonly EditNotePiece headPiece; diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs index 35889aea0c..8ec5213d5f 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -13,7 +15,7 @@ using osuTK; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { - public class HoldNoteSelectionBlueprint : ManiaSelectionBlueprint + public partial class HoldNoteSelectionBlueprint : ManiaSelectionBlueprint { [Resolved] private OsuColour colours { get; set; } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs index 7a99565e8a..5e0512b5dc 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Input.Events; using osu.Game.Rulesets.Edit; @@ -13,7 +15,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { - public abstract class ManiaPlacementBlueprint : PlacementBlueprint + public abstract partial class ManiaPlacementBlueprint : PlacementBlueprint where T : ManiaHitObject { protected new T HitObject => (T)base.HitObject; diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs index 955336db57..cf4bca0030 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Rulesets.Edit; @@ -11,7 +13,7 @@ using osu.Game.Rulesets.UI.Scrolling; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { - public abstract class ManiaSelectionBlueprint : HitObjectSelectionBlueprint + public abstract partial class ManiaSelectionBlueprint : HitObjectSelectionBlueprint where T : ManiaHitObject { [Resolved] diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs index 3db89c8ae6..d77abca350 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Input.Events; using osu.Game.Rulesets.Edit; @@ -10,7 +12,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { - public class NotePlacementBlueprint : ManiaPlacementBlueprint + public partial class NotePlacementBlueprint : ManiaPlacementBlueprint { private readonly EditNotePiece piece; diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs index e7a03905d2..a1392f09fa 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Rulesets.Mania.Edit.Blueprints.Components; using osu.Game.Rulesets.Mania.Objects; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { - public class NoteSelectionBlueprint : ManiaSelectionBlueprint + public partial class NoteSelectionBlueprint : ManiaSelectionBlueprint { public NoteSelectionBlueprint(Note note) : base(note) diff --git a/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs b/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs index b0af8c503b..4a070e70b4 100644 --- a/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs +++ b/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Graphics; using osuTK; @@ -12,7 +14,7 @@ using osu.Game.Rulesets.UI.Scrolling; namespace osu.Game.Rulesets.Mania.Edit { - public class DrawableManiaEditorRuleset : DrawableManiaRuleset + public partial class DrawableManiaEditorRuleset : DrawableManiaRuleset { public new IScrollingInfo ScrollingInfo => base.ScrollingInfo; diff --git a/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.cs b/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.cs index a5f10ed436..960a08eeeb 100644 --- a/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.cs +++ b/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs b/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs index 1aa20f4737..d1d5492b7a 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -23,7 +25,7 @@ namespace osu.Game.Rulesets.Mania.Edit /// /// A grid which displays coloured beat divisor lines in proximity to the selection or placement cursor. /// - public class ManiaBeatSnapGrid : Component + public partial class ManiaBeatSnapGrid : Component { private const double visible_range = 750; @@ -164,7 +166,7 @@ namespace osu.Game.Rulesets.Mania.Edit } } - private class DrawableGridLine : DrawableHitObject + private partial class DrawableGridLine : DrawableHitObject { [Resolved] private IScrollingInfo scrollingInfo { get; set; } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs index c5a109a6d1..05d8ccc73f 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Edit.Blueprints; using osu.Game.Rulesets.Mania.Objects; @@ -9,7 +11,7 @@ using osu.Game.Screens.Edit.Compose.Components; namespace osu.Game.Rulesets.Mania.Edit { - public class ManiaBlueprintContainer : ComposeBlueprintContainer + public partial class ManiaBlueprintContainer : ComposeBlueprintContainer { public ManiaBlueprintContainer(HitObjectComposer composer) : base(composer) @@ -31,5 +33,7 @@ namespace osu.Game.Rulesets.Mania.Edit } protected override SelectionHandler CreateSelectionHandler() => new ManiaSelectionHandler(); + + protected sealed override DragBox CreateDragBox() => new ScrollingDragBox(Composer.Playfield); } } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaEditorPlayfield.cs b/osu.Game.Rulesets.Mania/Edit/ManiaEditorPlayfield.cs index 186d50716e..0a697ca986 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaEditorPlayfield.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaEditorPlayfield.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.UI; using System.Collections.Generic; namespace osu.Game.Rulesets.Mania.Edit { - public class ManiaEditorPlayfield : ManiaPlayfield + public partial class ManiaEditorPlayfield : ManiaPlayfield { public ManiaEditorPlayfield(List stages) : base(stages) diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs index c389e1bced..5e577a2964 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -19,7 +21,7 @@ using osuTK; namespace osu.Game.Rulesets.Mania.Edit { - public class ManiaHitObjectComposer : HitObjectComposer + public partial class ManiaHitObjectComposer : HitObjectComposer { private DrawableManiaEditorRuleset drawableRuleset; private ManiaBeatSnapGrid beatSnapGrid; diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs index 9fe1eb7932..5e6ae9bb11 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using osu.Framework.Allocation; @@ -11,7 +13,7 @@ using osu.Game.Screens.Edit.Compose.Components; namespace osu.Game.Rulesets.Mania.Edit { - public class ManiaSelectionHandler : EditorSelectionHandler + public partial class ManiaSelectionHandler : EditorSelectionHandler { [Resolved] private HitObjectComposer composer { get; set; } diff --git a/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.cs b/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.cs index 9f54152596..179f920c2f 100644 --- a/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.cs +++ b/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; diff --git a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs b/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs index a206aafb8a..508733ad14 100644 --- a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs +++ b/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Graphics.UserInterfaceV2; @@ -8,7 +10,7 @@ using osu.Game.Screens.Edit.Setup; namespace osu.Game.Rulesets.Mania.Edit.Setup { - public class ManiaSetupSection : RulesetSetupSection + public partial class ManiaSetupSection : RulesetSetupSection { private LabelledSwitchButton specialStyle; @@ -41,6 +43,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup private void updateBeatmap() { Beatmap.BeatmapInfo.SpecialStyle = specialStyle.Current.Value; + Beatmap.SaveState(); } } } diff --git a/osu.Game.Rulesets.Mania/Judgements/HoldNoteTickJudgement.cs b/osu.Game.Rulesets.Mania/Judgements/HoldNoteTickJudgement.cs index ee6cbbc828..4b94198c4d 100644 --- a/osu.Game.Rulesets.Mania/Judgements/HoldNoteTickJudgement.cs +++ b/osu.Game.Rulesets.Mania/Judgements/HoldNoteTickJudgement.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Judgements diff --git a/osu.Game.Rulesets.Mania/Judgements/ManiaJudgement.cs b/osu.Game.Rulesets.Mania/Judgements/ManiaJudgement.cs index d28b7bdf58..32f9689d7e 100644 --- a/osu.Game.Rulesets.Mania/Judgements/ManiaJudgement.cs +++ b/osu.Game.Rulesets.Mania/Judgements/ManiaJudgement.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; diff --git a/osu.Game.Rulesets.Mania/ManiaInputManager.cs b/osu.Game.Rulesets.Mania/ManiaInputManager.cs index 14ca27a11a..9ad24d6256 100644 --- a/osu.Game.Rulesets.Mania/ManiaInputManager.cs +++ b/osu.Game.Rulesets.Mania/ManiaInputManager.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; using osu.Framework.Allocation; using osu.Framework.Input.Bindings; @@ -9,7 +11,7 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Mania { [Cached] // Used for touch input, see ColumnTouchInputArea. - public class ManiaInputManager : RulesetInputManager + public partial class ManiaInputManager : RulesetInputManager { public ManiaInputManager(RulesetInfo ruleset, int variant) : base(ruleset, variant, SimultaneousBindingMode.Unique) diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index bd6a67bf67..d324682989 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -2,22 +2,17 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osu.Game.Beatmaps; -using osu.Game.Rulesets.Mania.Mods; -using osu.Game.Rulesets.Mania.UI; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.UI; using System.Collections.Generic; using System.Linq; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; -using osu.Game.Graphics; -using osu.Game.Rulesets.Mania.Replays; -using osu.Game.Rulesets.Replays.Types; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; using osu.Game.Beatmaps.Legacy; using osu.Game.Configuration; +using osu.Game.Graphics; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Difficulty; @@ -28,13 +23,21 @@ using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.Difficulty; using osu.Game.Rulesets.Mania.Edit; using osu.Game.Rulesets.Mania.Edit.Setup; +using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Rulesets.Mania.Replays; using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Mania.Skinning.Argon; +using osu.Game.Rulesets.Mania.Skinning.Default; using osu.Game.Rulesets.Mania.Skinning.Legacy; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Replays.Types; using osu.Game.Rulesets.Scoring; -using osu.Game.Skinning; +using osu.Game.Rulesets.UI; using osu.Game.Scoring; using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Ranking.Statistics; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania { @@ -45,11 +48,11 @@ namespace osu.Game.Rulesets.Mania /// public const int MAX_STAGE_KEYS = 10; - public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableManiaRuleset(this, beatmap, mods); + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList? mods = null) => new DrawableManiaRuleset(this, beatmap, mods); public override ScoreProcessor CreateScoreProcessor() => new ManiaScoreProcessor(); - public override HealthProcessor CreateHealthProcessor(double drainStartTime) => new ManiaHealthProcessor(drainStartTime, 0.5); + public override HealthProcessor CreateHealthProcessor(double drainStartTime) => new ManiaHealthProcessor(drainStartTime); public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new ManiaBeatmapConverter(beatmap, this); @@ -57,9 +60,29 @@ namespace osu.Game.Rulesets.Mania public const string SHORT_NAME = "mania"; + public override string RulesetAPIVersionSupported => CURRENT_RULESET_API_VERSION; + public override HitObjectComposer CreateHitObjectComposer() => new ManiaHitObjectComposer(this); - public override ISkin CreateLegacySkinProvider(ISkin skin, IBeatmap beatmap) => new ManiaLegacySkinTransformer(skin, beatmap); + public override ISkin? CreateSkinTransformer(ISkin skin, IBeatmap beatmap) + { + switch (skin) + { + case TrianglesSkin: + return new ManiaTrianglesSkinTransformer(skin, beatmap); + + case ArgonSkin: + return new ManiaArgonSkinTransformer(skin, beatmap); + + case DefaultLegacySkin: + return new ManiaClassicSkinTransformer(skin, beatmap); + + case LegacySkin: + return new ManiaLegacySkinTransformer(skin, beatmap); + } + + return null; + } public override IEnumerable ConvertFromLegacyMods(LegacyMods mods) { @@ -144,56 +167,56 @@ namespace osu.Game.Rulesets.Mania { switch (mod) { - case ManiaModKey1 _: + case ManiaModKey1: value |= LegacyMods.Key1; break; - case ManiaModKey2 _: + case ManiaModKey2: value |= LegacyMods.Key2; break; - case ManiaModKey3 _: + case ManiaModKey3: value |= LegacyMods.Key3; break; - case ManiaModKey4 _: + case ManiaModKey4: value |= LegacyMods.Key4; break; - case ManiaModKey5 _: + case ManiaModKey5: value |= LegacyMods.Key5; break; - case ManiaModKey6 _: + case ManiaModKey6: value |= LegacyMods.Key6; break; - case ManiaModKey7 _: + case ManiaModKey7: value |= LegacyMods.Key7; break; - case ManiaModKey8 _: + case ManiaModKey8: value |= LegacyMods.Key8; break; - case ManiaModKey9 _: + case ManiaModKey9: value |= LegacyMods.Key9; break; - case ManiaModDualStages _: + case ManiaModDualStages: value |= LegacyMods.KeyCoop; break; - case ManiaModFadeIn _: + case ManiaModFadeIn: value |= LegacyMods.FadeIn; value &= ~LegacyMods.Hidden; // this is toggled on in the base call due to inheritance, but we don't want that. break; - case ManiaModMirror _: + case ManiaModMirror: value |= LegacyMods.Mirror; break; - case ManiaModRandom _: + case ManiaModRandom: value |= LegacyMods.Random; break; } @@ -222,6 +245,7 @@ namespace osu.Game.Rulesets.Mania new MultiMod(new ManiaModDoubleTime(), new ManiaModNightcore()), new MultiMod(new ManiaModFadeIn(), new ManiaModHidden()), new ManiaModFlashlight(), + new ModAccuracyChallenge(), }; case ModType.Conversion: @@ -280,7 +304,7 @@ namespace osu.Game.Rulesets.Mania public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new ManiaReplayFrame(); - public override IRulesetConfigManager CreateConfig(SettingsStore settings) => new ManiaRulesetConfigManager(settings, RulesetInfo); + public override IRulesetConfigManager CreateConfig(SettingsStore? settings) => new ManiaRulesetConfigManager(settings, RulesetInfo); public override RulesetSettingsSubsection CreateSettings() => new ManiaSettingsSubsection(this); @@ -309,7 +333,7 @@ namespace osu.Game.Rulesets.Mania return Array.Empty(); } - public override string GetVariantName(int variant) + public override LocalisableString GetVariantName(int variant) { switch (getPlayfieldType(variant)) { @@ -354,7 +378,7 @@ namespace osu.Game.Rulesets.Mania }; } - public override string GetDisplayNameForHitResult(HitResult result) + public override LocalisableString GetDisplayNameForHitResult(HitResult result) { switch (result) { diff --git a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs index bd3b8c3b10..fc0b4a9ed9 100644 --- a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs +++ b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs @@ -1,17 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.UI; namespace osu.Game.Rulesets.Mania { - public class ManiaSettingsSubsection : RulesetSettingsSubsection + public partial class ManiaSettingsSubsection : RulesetSettingsSubsection { protected override LocalisableString Header => "osu!mania"; @@ -29,21 +31,26 @@ namespace osu.Game.Rulesets.Mania { new SettingsEnumDropdown { - LabelText = "Scrolling direction", + LabelText = RulesetSettingsStrings.ScrollingDirection, Current = config.GetBindable(ManiaRulesetSetting.ScrollDirection) }, - new SettingsSlider + new SettingsSlider { - LabelText = "Scroll speed", + LabelText = RulesetSettingsStrings.ScrollSpeed, Current = config.GetBindable(ManiaRulesetSetting.ScrollTime), KeyboardStep = 5 }, new SettingsCheckbox { - LabelText = "Timing-based note colouring", + LabelText = RulesetSettingsStrings.TimingBasedColouring, Current = config.GetBindable(ManiaRulesetSetting.TimingBasedNoteColouring), } }; } + + private partial class ManiaScrollSlider : RoundedSliderBar + { + public override LocalisableString TooltipText => RulesetSettingsStrings.ScrollSpeedTooltip(Current.Value, (int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / Current.Value)); + } } } diff --git a/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs b/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs deleted file mode 100644 index 9aebf51576..0000000000 --- a/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Mania.Beatmaps; -using osu.Game.Rulesets.Mania.UI; -using osu.Game.Skinning; - -namespace osu.Game.Rulesets.Mania -{ - public class ManiaSkinComponent : GameplaySkinComponent - { - /// - /// The intended for this component. - /// May be null if the component is not a direct member of a . - /// - public readonly StageDefinition? StageDefinition; - - /// - /// Creates a new . - /// - /// The component. - /// The intended for this component. May be null if the component is not a direct member of a . - public ManiaSkinComponent(ManiaSkinComponents component, StageDefinition? stageDefinition = null) - : base(component) - { - StageDefinition = stageDefinition; - } - - protected override string RulesetPrefix => ManiaRuleset.SHORT_NAME; - - protected override string ComponentName => Component.ToString().ToLower(); - } - - public enum ManiaSkinComponents - { - ColumnBackground, - HitTarget, - KeyArea, - Note, - HoldNoteHead, - HoldNoteTail, - HoldNoteBody, - HitExplosion, - StageBackground, - StageForeground, - } -} diff --git a/osu.Game.Rulesets.Mania/ManiaSkinComponentLookup.cs b/osu.Game.Rulesets.Mania/ManiaSkinComponentLookup.cs new file mode 100644 index 0000000000..c9ee5af809 --- /dev/null +++ b/osu.Game.Rulesets.Mania/ManiaSkinComponentLookup.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Mania +{ + public class ManiaSkinComponentLookup : GameplaySkinComponentLookup + { + /// + /// Creates a new . + /// + /// The component. + public ManiaSkinComponentLookup(ManiaSkinComponents component) + : base(component) + { + } + + protected override string RulesetPrefix => ManiaRuleset.SHORT_NAME; + + protected override string ComponentName => Component.ToString().ToLowerInvariant(); + } + + public enum ManiaSkinComponents + { + ColumnBackground, + HitTarget, + KeyArea, + Note, + HoldNoteHead, + HoldNoteTail, + HoldNoteBody, + HitExplosion, + StageBackground, + StageForeground, + } +} diff --git a/osu.Game.Rulesets.Mania/MathUtils/LegacySortHelper.cs b/osu.Game.Rulesets.Mania/MathUtils/LegacySortHelper.cs index 5c595323c3..4d93826240 100644 --- a/osu.Game.Rulesets.Mania/MathUtils/LegacySortHelper.cs +++ b/osu.Game.Rulesets.Mania/MathUtils/LegacySortHelper.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics.Contracts; @@ -20,8 +22,7 @@ namespace osu.Game.Rulesets.Mania.MathUtils public static void Sort(T[] keys, IComparer comparer) { - if (keys == null) - throw new ArgumentNullException(nameof(keys)); + ArgumentNullException.ThrowIfNull(keys); if (keys.Length == 0) return; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs index 614ef76a3b..66269f5572 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.UI; @@ -16,9 +17,9 @@ namespace osu.Game.Rulesets.Mania.Mods public override string Acronym => "CS"; - public override double ScoreMultiplier => 1; + public override double ScoreMultiplier => 0.9; - public override string Description => "No more tricky speed changes!"; + public override LocalisableString Description => "No more tricky speed changes!"; public override IconUsage? Icon => FontAwesome.Solid.Equals; 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/ManiaModDualStages.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModDualStages.cs index c78bf72979..2457aa75d7 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModDualStages.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModDualStages.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mods; @@ -11,7 +12,7 @@ namespace osu.Game.Rulesets.Mania.Mods { public override string Name => "Dual Stages"; public override string Acronym => "DS"; - public override string Description => @"Double the stages, double the fun!"; + public override LocalisableString Description => @"Double the stages, double the fun!"; public override ModType Type => ModType.Conversion; public override double ScoreMultiplier => 1; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs index 4093aeb2a7..5c8cd6a5ae 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs @@ -1,12 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModEasy : ModEasyWithExtraLives { - public override string Description => @"More forgiving HP drain, less accuracy required, and three lives!"; + public override LocalisableString Description => @"More forgiving HP drain, less accuracy required, and three lives!"; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs index f80c9e1f7c..196514c7b1 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs @@ -3,6 +3,8 @@ using System; using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mania.UI; namespace osu.Game.Rulesets.Mania.Mods @@ -11,11 +13,19 @@ namespace osu.Game.Rulesets.Mania.Mods { public override string Name => "Fade In"; public override string Acronym => "FI"; - public override string Description => @"Keys appear out of nowhere!"; + public override LocalisableString Description => @"Keys appear out of nowhere!"; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ManiaModHidden)).ToArray(); protected override CoverExpandDirection ExpandDirection => CoverExpandDirection.AlongScroll; + + public override BindableNumber Coverage { get; } = new BindableFloat(0.5f) + { + Precision = 0.1f, + MinValue = 0.1f, + MaxValue = 0.7f, + Default = 0.5f, + }; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModFlashlight.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModFlashlight.cs index 8ef5bfd94c..2539945c20 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModFlashlight.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModFlashlight.cs @@ -5,47 +5,38 @@ using System; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Layout; -using osu.Game.Configuration; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mods; using osuTK; namespace osu.Game.Rulesets.Mania.Mods { - public class ManiaModFlashlight : ModFlashlight + public partial class ManiaModFlashlight : ModFlashlight { public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => new[] { typeof(ModHidden) }; - [SettingSource("Flashlight size", "Multiplier applied to the default flashlight size.")] - public override BindableFloat SizeMultiplier { get; } = new BindableFloat + public override BindableFloat SizeMultiplier { get; } = new BindableFloat(1) { MinValue = 0.5f, MaxValue = 3f, - Default = 1f, - Value = 1f, Precision = 0.1f }; - [SettingSource("Change size based on combo", "Decrease the flashlight size as combo increases.")] - public override BindableBool ComboBasedSize { get; } = new BindableBool - { - Default = false, - Value = false - }; + public override BindableBool ComboBasedSize { get; } = new BindableBool(); public override float DefaultFlashlightSize => 50; protected override Flashlight CreateFlashlight() => new ManiaFlashlight(this); - private class ManiaFlashlight : Flashlight + private partial class ManiaFlashlight : Flashlight { private readonly LayoutValue flashlightProperties = new LayoutValue(Invalidation.DrawSize); public ManiaFlashlight(ManiaModFlashlight modFlashlight) : base(modFlashlight) { - FlashlightSize = new Vector2(DrawWidth, GetSizeFor(0)); + FlashlightSize = new Vector2(DrawWidth, GetSize()); AddLayout(flashlightProperties); } @@ -63,9 +54,9 @@ namespace osu.Game.Rulesets.Mania.Mods } } - protected override void OnComboChange(ValueChangedEvent e) + protected override void UpdateFlashlightSize(float size) { - this.TransformTo(nameof(FlashlightSize), new Vector2(DrawWidth, GetSizeFor(e.NewValue)), FLASHLIGHT_FADE_DURATION); + this.TransformTo(nameof(FlashlightSize), new Vector2(DrawWidth, size), FLASHLIGHT_FADE_DURATION); } protected override string FragmentShader => "RectangularFlashlight"; 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/ManiaModHidden.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs index e3ac624a6e..f23cb335a5 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs @@ -3,15 +3,25 @@ using System; using System.Linq; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mania.UI; +using osu.Framework.Bindables; namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModHidden : ManiaModPlayfieldCover { - public override string Description => @"Keys fade out before you hit them!"; + public override LocalisableString Description => @"Keys fade out before you hit them!"; public override double ScoreMultiplier => 1; + public override BindableNumber Coverage { get; } = new BindableFloat(0.5f) + { + Precision = 0.1f, + MinValue = 0.2f, + MaxValue = 0.8f, + Default = 0.5f, + }; + public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ManiaModFadeIn)).ToArray(); protected override CoverExpandDirection ExpandDirection => CoverExpandDirection.AgainstScroll; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs index a65938184c..2b0098744f 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs @@ -8,6 +8,7 @@ using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mods; using osu.Framework.Graphics.Sprites; using System.Collections.Generic; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mania.Beatmaps; namespace osu.Game.Rulesets.Mania.Mods @@ -18,9 +19,9 @@ namespace osu.Game.Rulesets.Mania.Mods public override string Acronym => "HO"; - public override double ScoreMultiplier => 1; + public override double ScoreMultiplier => 0.9; - public override string Description => @"Replaces all hold notes with normal notes."; + public override LocalisableString Description => @"Replaces all hold notes with normal notes."; public override IconUsage? Icon => FontAwesome.Solid.DotCircle; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModInvert.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModInvert.cs index 4cbdaee323..ef9154d180 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModInvert.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModInvert.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; @@ -20,7 +21,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override string Acronym => "IN"; public override double ScoreMultiplier => 1; - public override string Description => "Hold the keys. To the beat."; + public override LocalisableString Description => "Hold the keys. To the beat."; public override IconUsage? Icon => FontAwesome.Solid.YinYang; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey1.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey1.cs index 948979505c..31f52610e9 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey1.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey1.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; + namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModKey1 : ManiaKeyMod @@ -8,6 +10,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 1; public override string Name => "One Key"; public override string Acronym => "1K"; - public override string Description => @"Play with one key."; + public override LocalisableString Description => @"Play with one key."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey10.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey10.cs index 684370fc3d..67e65b887a 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey10.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey10.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; + namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModKey10 : ManiaKeyMod @@ -8,6 +10,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 10; public override string Name => "Ten Keys"; public override string Acronym => "10K"; - public override string Description => @"Play with ten keys."; + public override LocalisableString Description => @"Play with ten keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey2.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey2.cs index de91902ca8..0f8148d252 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey2.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey2.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; + namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModKey2 : ManiaKeyMod @@ -8,6 +10,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 2; public override string Name => "Two Keys"; public override string Acronym => "2K"; - public override string Description => @"Play with two keys."; + public override LocalisableString Description => @"Play with two keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey3.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey3.cs index 8575a96bde..0f8af7940c 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey3.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey3.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; + namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModKey3 : ManiaKeyMod @@ -8,6 +10,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 3; public override string Name => "Three Keys"; public override string Acronym => "3K"; - public override string Description => @"Play with three keys."; + public override LocalisableString Description => @"Play with three keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey4.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey4.cs index 54ea3afa07..d3a4546dce 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey4.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey4.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; + namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModKey4 : ManiaKeyMod @@ -8,6 +10,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 4; public override string Name => "Four Keys"; public override string Acronym => "4K"; - public override string Description => @"Play with four keys."; + public override LocalisableString Description => @"Play with four keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey5.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey5.cs index e9a9bba5bd..693182a952 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey5.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey5.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; + namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModKey5 : ManiaKeyMod @@ -8,6 +10,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 5; public override string Name => "Five Keys"; public override string Acronym => "5K"; - public override string Description => @"Play with five keys."; + public override LocalisableString Description => @"Play with five keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey6.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey6.cs index b9606d1cb5..ab911292f7 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey6.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey6.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; + namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModKey6 : ManiaKeyMod @@ -8,6 +10,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 6; public override string Name => "Six Keys"; public override string Acronym => "6K"; - public override string Description => @"Play with six keys."; + public override LocalisableString Description => @"Play with six keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey7.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey7.cs index b80d794085..ab401ef1d0 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey7.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey7.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; + namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModKey7 : ManiaKeyMod @@ -8,6 +10,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 7; public override string Name => "Seven Keys"; public override string Acronym => "7K"; - public override string Description => @"Play with seven keys."; + public override LocalisableString Description => @"Play with seven keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey8.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey8.cs index 3462d634a4..b3e8a45dda 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey8.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey8.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; + namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModKey8 : ManiaKeyMod @@ -8,6 +10,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 8; public override string Name => "Eight Keys"; public override string Acronym => "8K"; - public override string Description => @"Play with eight keys."; + public override LocalisableString Description => @"Play with eight keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey9.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey9.cs index 83c505c048..5972cbf0fe 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey9.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey9.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; + namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModKey9 : ManiaKeyMod @@ -8,6 +10,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 9; public override string Name => "Nine Keys"; public override string Acronym => "9K"; - public override string Description => @"Play with nine keys."; + public override LocalisableString Description => @"Play with nine keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs index 9c3744ea98..f9690b4298 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs @@ -5,6 +5,7 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mods; using System.Linq; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; @@ -12,7 +13,7 @@ namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModMirror : ModMirror, IApplicableToBeatmap { - public override string Description => "Notes are flipped horizontally."; + public override LocalisableString Description => "Notes are flipped horizontally."; public void ApplyToBeatmap(IBeatmap beatmap) { 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/Mods/ManiaModPlayfieldCover.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModPlayfieldCover.cs index 3c24e91d54..09abe8d7f4 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModPlayfieldCover.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModPlayfieldCover.cs @@ -3,8 +3,10 @@ using System; using System.Linq; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Configuration; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mods; @@ -22,6 +24,9 @@ namespace osu.Game.Rulesets.Mania.Mods /// protected abstract CoverExpandDirection ExpandDirection { get; } + [SettingSource("Coverage", "The proportion of playfield height that notes will be hidden for.")] + public abstract BindableNumber Coverage { get; } + public virtual void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { ManiaPlayfield maniaPlayfield = (ManiaPlayfield)drawableRuleset.Playfield; @@ -31,12 +36,12 @@ namespace osu.Game.Rulesets.Mania.Mods HitObjectContainer hoc = column.HitObjectArea.HitObjectContainer; Container hocParent = (Container)hoc.Parent; - hocParent.Remove(hoc); + hocParent.Remove(hoc, false); hocParent.Add(new PlayfieldCoveringWrapper(hoc).With(c => { c.RelativeSizeAxes = Axes.Both; c.Direction = ExpandDirection; - c.Coverage = 0.5f; + c.Coverage = Coverage.Value; })); } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs index f5d1a34d73..6ff070d703 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; @@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModRandom : ModRandom, IApplicableToBeatmap { - public override string Description => @"Shuffle around the keys!"; + public override LocalisableString Description => @"Shuffle around the keys!"; public void ApplyToBeatmap(IBeatmap beatmap) { @@ -22,7 +23,7 @@ namespace osu.Game.Rulesets.Mania.Mods var rng = new Random((int)Seed.Value); int availableColumns = ((ManiaBeatmap)beatmap).TotalColumns; - var shuffledColumns = Enumerable.Range(0, availableColumns).OrderBy(item => rng.Next()).ToList(); + var shuffledColumns = Enumerable.Range(0, availableColumns).OrderBy(_ => rng.Next()).ToList(); beatmap.HitObjects.OfType().ForEach(h => h.Column = shuffledColumns[h.Column]); } diff --git a/osu.Game.Rulesets.Mania/Objects/BarLine.cs b/osu.Game.Rulesets.Mania/Objects/BarLine.cs index 09a746042b..3f04a4fafe 100644 --- a/osu.Game.Rulesets.Mania/Objects/BarLine.cs +++ b/osu.Game.Rulesets.Mania/Objects/BarLine.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs index 074cbf6bd6..8381b8b24b 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs @@ -4,7 +4,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osuTK; -using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Objects.Drawables { @@ -12,23 +11,13 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables /// Visualises a . Although this derives DrawableManiaHitObject, /// this does not handle input/sound like a normal hit object. /// - public class DrawableBarLine : DrawableManiaHitObject + public partial class DrawableBarLine : DrawableManiaHitObject { - /// - /// Height of major bar line triangles. - /// - private const float triangle_height = 12; - - /// - /// Offset of the major bar line triangles from the sides of the bar line. - /// - private const float triangle_offset = 9; - public DrawableBarLine(BarLine barLine) : base(barLine) { RelativeSizeAxes = Axes.X; - Height = 2f; + Height = barLine.Major ? 1.7f : 1.2f; AddInternal(new Box { @@ -36,38 +25,33 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, RelativeSizeAxes = Axes.Both, - Colour = new Color4(255, 204, 33, 255), + Alpha = barLine.Major ? 0.5f : 0.2f }); if (barLine.Major) { - AddInternal(new EquilateralTriangle + Vector2 size = new Vector2(22, 6); + const float line_offset = 4; + + AddInternal(new Circle { - Name = "Left triangle", - Anchor = Anchor.BottomLeft, - Origin = Anchor.TopCentre, - Size = new Vector2(triangle_height), - X = -triangle_offset, - Rotation = 90 + Name = "Left line", + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreRight, + + Size = size, + X = -line_offset, }); - AddInternal(new EquilateralTriangle + AddInternal(new Circle { - Name = "Right triangle", - Anchor = Anchor.BottomRight, - Origin = Anchor.TopCentre, - Size = new Vector2(triangle_height), - X = triangle_offset, - Rotation = -90 + Name = "Right line", + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreLeft, + Size = size, + X = line_offset, }); } - - if (!barLine.Major) - Alpha = 0.2f; - } - - protected override void UpdateInitialTransforms() - { } protected override void UpdateStartTimeStateTransforms() => this.FadeOut(150); diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs index 4e9781f336..6e1c6cf80f 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs @@ -1,13 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#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.Bindings; using osu.Framework.Input.Events; +using osu.Game.Audio; using osu.Game.Rulesets.Mania.Skinning.Default; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -21,7 +25,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables /// /// Visualises a hit object. /// - public class DrawableHoldNote : DrawableManiaHitObject, IKeyBindingHandler + public partial class DrawableHoldNote : DrawableManiaHitObject, IKeyBindingHandler { public override bool DisplayResult => false; @@ -36,6 +40,8 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables private Container tailContainer; private Container tickContainer; + private PausableSkinnableSound slidingSample; + /// /// Contains the size of the hold note covering the whole head/tail bounds. The size of this container changes as the hold note is being pressed. /// @@ -97,7 +103,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables headContainer = new Container { RelativeSizeAxes = Axes.Both } } }, - bodyPiece = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HoldNoteBody), _ => new DefaultBodyPiece + bodyPiece = new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.HoldNoteBody), _ => new DefaultBodyPiece { RelativeSizeAxes = Axes.Both, }) @@ -106,6 +112,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables }, tickContainer = new Container { RelativeSizeAxes = Axes.Both }, tailContainer = new Container { RelativeSizeAxes = Axes.Both }, + slidingSample = new PausableSkinnableSound { Looping = true } }); maskedContents.AddRange(new[] @@ -116,6 +123,13 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables }); } + protected override void LoadComplete() + { + base.LoadComplete(); + + isHitting.BindValueChanged(updateSlidingSample, true); + } + protected override void OnApply() { base.OnApply(); @@ -222,6 +236,7 @@ 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; @@ -246,14 +261,24 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables tick.MissForcefully(); } - ApplyResult(r => r.Type = r.Judgement.MaxResult); - endHold(); + if (Tail.IsHit) + ApplyResult(r => r.Type = r.Judgement.MaxResult); + else + MissForcefully(); } if (Tail.Judged && !Tail.IsHit) HoldBrokenTime = Time.Current; } + public override void MissForcefully() + { + base.MissForcefully(); + + // Important that this is always called when a result is applied. + endHold(); + } + public bool OnPressed(KeyBindingPressEvent e) { if (AllJudged) @@ -320,5 +345,38 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables HoldStartTime = null; isHitting.Value = false; } + + protected override void LoadSamples() + { + // Note: base.LoadSamples() isn't called since the slider plays the tail's hitsounds for the time being. + + 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(); + } + + public override void StopAllSamples() + { + base.StopAllSamples(); + slidingSample?.Stop(); + } + + private void updateSlidingSample(ValueChangedEvent tracking) + { + if (tracking.NewValue) + slidingSample?.Play(); + else + slidingSample?.Stop(); + } + + protected override void OnFree() + { + slidingSample.ClearSamples(); + base.OnFree(); + } } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs index 6722ad8ab8..1aa6ee2507 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Input.Events; using osu.Game.Rulesets.Objects.Drawables; @@ -10,7 +12,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables /// /// The head of a . /// - public class DrawableHoldNoteHead : DrawableNote + public partial class DrawableHoldNoteHead : DrawableNote { protected override ManiaSkinComponents Component => ManiaSkinComponents.HoldNoteHead; @@ -28,20 +30,15 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables public bool UpdateResult() => base.UpdateResult(true); - protected override void UpdateInitialTransforms() - { - base.UpdateInitialTransforms(); - - // This hitobject should never expire, so this is just a safe maximum. - LifetimeEnd = LifetimeStart + 30000; - } - protected override void UpdateHitStateTransforms(ArmedState state) { // suppress the base call explicitly. // the hold note head should never change its visual state on its own due to the "freezing" mechanic // (when hit, it remains visible in place at the judgement line; when dropped, it will scroll past the line). // it will be hidden along with its parenting hold note when required. + + // Set `LifetimeEnd` explicitly to a non-`double.MaxValue` because otherwise this DHO is automatically expired. + LifetimeEnd = double.PositiveInfinity; } public override bool OnPressed(KeyBindingPressEvent e) => false; // Handled by the hold note diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs index 803685363c..e7326df07d 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Diagnostics; using osu.Framework.Graphics; using osu.Framework.Input.Events; @@ -11,18 +13,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables /// /// The tail of a . /// - public class DrawableHoldNoteTail : DrawableNote + public partial class DrawableHoldNoteTail : DrawableNote { - /// - /// Lenience of release hit windows. This is to make cases where the hold note release - /// is timed alongside presses of other hit objects less awkward. - /// Todo: This shouldn't exist for non-LegacyBeatmapDecoder beatmaps - /// - private const double release_window_lenience = 1.5; - protected override ManiaSkinComponents Component => ManiaSkinComponents.HoldNoteTail; - protected DrawableHoldNote HoldNote => (DrawableHoldNote)ParentHitObject; + protected internal DrawableHoldNote HoldNote => (DrawableHoldNote)ParentHitObject; public DrawableHoldNoteTail() : this(null) @@ -38,14 +33,12 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables public void UpdateResult() => base.UpdateResult(true); - protected override double MaximumJudgementOffset => base.MaximumJudgementOffset * release_window_lenience; - protected override void CheckForResult(bool userTriggered, double timeOffset) { Debug.Assert(HitObject.HitWindows != null); // Factor in the release lenience - timeOffset /= release_window_lenience; + timeOffset /= TailNote.RELEASE_WINDOW_LENIENCE; if (!userTriggered) { diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTick.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTick.cs index cffa79322e..ce6a83f79f 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTick.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTick.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using osu.Framework.Allocation; @@ -15,7 +17,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables /// /// Visualises a hit object. /// - public class DrawableHoldNoteTick : DrawableManiaHitObject + public partial class DrawableHoldNoteTick : DrawableManiaHitObject { /// /// References the time at which the user started holding the hold note. diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs index 9ac223a0d7..8498fd36de 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -12,7 +14,7 @@ using osu.Game.Rulesets.Mania.UI; namespace osu.Game.Rulesets.Mania.Objects.Drawables { - public abstract class DrawableManiaHitObject : DrawableHitObject + public abstract partial class DrawableManiaHitObject : DrawableHitObject { /// /// The which causes this to be hit. @@ -21,10 +23,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables protected readonly IBindable Direction = new Bindable(); - // Leaving the default (10s) makes hitobjects not appear, as this offset is used for the initial state transforms. - // Calculated as DrawableManiaRuleset.MAX_TIME_RANGE + some additional allowance for velocity < 1. - protected override double InitialLifetimeOffset => 30000; - [Resolved(canBeNull: true)] private ManiaPlayfield playfield { get; set; } @@ -67,22 +65,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables Direction.BindValueChanged(OnDirectionChanged, true); } - protected override void OnApply() - { - base.OnApply(); - - if (ParentHitObject != null) - AccentColour.BindTo(ParentHitObject.AccentColour); - } - - protected override void OnFree() - { - base.OnFree(); - - if (ParentHitObject != null) - AccentColour.UnbindFrom(ParentHitObject.AccentColour); - } - protected virtual void OnDirectionChanged(ValueChangedEvent e) { Anchor = Origin = e.NewValue == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre; @@ -105,10 +87,10 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables /// /// Causes this to get missed, disregarding all conditions in implementations of . /// - public void MissForcefully() => ApplyResult(r => r.Type = r.Judgement.MinResult); + public virtual void MissForcefully() => ApplyResult(r => r.Type = r.Judgement.MinResult); } - public abstract class DrawableManiaHitObject : DrawableManiaHitObject + public abstract partial class DrawableManiaHitObject : DrawableManiaHitObject where TObject : ManiaHitObject { public new TObject HitObject => (TObject)base.HitObject; diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs index 51727908c9..0819e8401c 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -22,7 +24,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables /// /// Visualises a hit object. /// - public class DrawableNote : DrawableManiaHitObject, IKeyBindingHandler + public partial class DrawableNote : DrawableManiaHitObject, IKeyBindingHandler { [Resolved] private OsuColour colours { get; set; } @@ -52,7 +54,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables { rulesetConfig?.BindWith(ManiaRulesetSetting.TimingBasedNoteColouring, configTimingBasedNoteColouring); - AddInternal(headPiece = new SkinnableDrawable(new ManiaSkinComponent(Component), _ => new DefaultNotePiece()) + AddInternal(headPiece = new SkinnableDrawable(new ManiaSkinComponentLookup(Component), _ => new DefaultNotePiece()) { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y diff --git a/osu.Game.Rulesets.Mania/Objects/HeadNote.cs b/osu.Game.Rulesets.Mania/Objects/HeadNote.cs index e69cc62aed..fb5c7b4ddd 100644 --- a/osu.Game.Rulesets.Mania/Objects/HeadNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/HeadNote.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Mania.Objects { public class HeadNote : Note diff --git a/osu.Game.Rulesets.Mania/Objects/HoldNote.cs b/osu.Game.Rulesets.Mania/Objects/HoldNote.cs index db0d3e2c5a..c367886efe 100644 --- a/osu.Game.Rulesets.Mania/Objects/HoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/HoldNote.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Threading; using osu.Game.Audio; @@ -79,6 +81,8 @@ namespace osu.Game.Rulesets.Mania.Objects /// public TailNote Tail { get; private set; } + public override double MaximumJudgementOffset => Tail.MaximumJudgementOffset; + /// /// The time between ticks of this hold. /// diff --git a/osu.Game.Rulesets.Mania/Objects/HoldNoteTick.cs b/osu.Game.Rulesets.Mania/Objects/HoldNoteTick.cs index ac6697a6dc..9117c60dcd 100644 --- a/osu.Game.Rulesets.Mania/Objects/HoldNoteTick.cs +++ b/osu.Game.Rulesets.Mania/Objects/HoldNoteTick.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mania.Judgements; using osu.Game.Rulesets.Scoring; diff --git a/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs index 6289744df1..ebff5cf4e9 100644 --- a/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs +++ b/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Game.Rulesets.Mania.Scoring; using osu.Game.Rulesets.Objects; @@ -11,12 +13,14 @@ namespace osu.Game.Rulesets.Mania.Objects { public abstract class ManiaHitObject : HitObject, IHasColumn, IHasXPosition { - public readonly Bindable ColumnBindable = new Bindable(); + private HitObjectProperty column; + + public Bindable ColumnBindable => column.Bindable; public virtual int Column { - get => ColumnBindable.Value; - set => ColumnBindable.Value = value; + get => column.Value; + set => column.Value = value; } protected override HitWindows CreateHitWindows() => new ManiaHitWindows(); diff --git a/osu.Game.Rulesets.Mania/Objects/Note.cs b/osu.Game.Rulesets.Mania/Objects/Note.cs index 0035960c63..578b46a7aa 100644 --- a/osu.Game.Rulesets.Mania/Objects/Note.cs +++ b/osu.Game.Rulesets.Mania/Objects/Note.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mania.Judgements; diff --git a/osu.Game.Rulesets.Mania/Objects/TailNote.cs b/osu.Game.Rulesets.Mania/Objects/TailNote.cs index 5a30fd6a12..d6dc25079a 100644 --- a/osu.Game.Rulesets.Mania/Objects/TailNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/TailNote.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mania.Judgements; @@ -8,6 +10,15 @@ namespace osu.Game.Rulesets.Mania.Objects { public class TailNote : Note { + /// + /// Lenience of release hit windows. This is to make cases where the hold note release + /// is timed alongside presses of other hit objects less awkward. + /// Todo: This shouldn't exist for non-LegacyBeatmapDecoder beatmaps + /// + public const double RELEASE_WINDOW_LENIENCE = 1.5; + public override Judgement CreateJudgement() => new ManiaJudgement(); + + public override double MaximumJudgementOffset => base.MaximumJudgementOffset * RELEASE_WINDOW_LENIENCE; } } diff --git a/osu.Game.Rulesets.Mania/Properties/AssemblyInfo.cs b/osu.Game.Rulesets.Mania/Properties/AssemblyInfo.cs index ca1f7036c7..1bc20f7ef3 100644 --- a/osu.Game.Rulesets.Mania/Properties/AssemblyInfo.cs +++ b/osu.Game.Rulesets.Mania/Properties/AssemblyInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Runtime.CompilerServices; // We publish our internal attributes to other sub-projects of the framework. diff --git a/osu.Game.Rulesets.Mania/Replays/ManiaAutoGenerator.cs b/osu.Game.Rulesets.Mania/Replays/ManiaAutoGenerator.cs index efe144ac03..7c8afdff12 100644 --- a/osu.Game.Rulesets.Mania/Replays/ManiaAutoGenerator.cs +++ b/osu.Game.Rulesets.Mania/Replays/ManiaAutoGenerator.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; +using osu.Framework.Extensions.ObjectExtensions; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Objects; @@ -55,11 +56,11 @@ namespace osu.Game.Rulesets.Mania.Replays { switch (point) { - case HitPoint _: + case HitPoint: actions.Add(columnActions[point.Column]); break; - case ReleasePoint _: + case ReleasePoint: actions.Remove(columnActions[point.Column]); break; } @@ -83,7 +84,7 @@ namespace osu.Game.Rulesets.Mania.Replays } } - private double calculateReleaseTime(HitObject currentObject, HitObject nextObject) + private double calculateReleaseTime(HitObject currentObject, HitObject? nextObject) { double endTime = currentObject.GetEndTime(); @@ -94,10 +95,10 @@ namespace osu.Game.Rulesets.Mania.Replays bool canDelayKeyUpFully = nextObject == null || nextObject.StartTime > endTime + RELEASE_DELAY; - return endTime + (canDelayKeyUpFully ? RELEASE_DELAY : (nextObject.StartTime - endTime) * 0.9); + return endTime + (canDelayKeyUpFully ? RELEASE_DELAY : (nextObject.AsNonNull().StartTime - endTime) * 0.9); } - protected override HitObject GetNextObject(int currentIndex) + protected override HitObject? GetNextObject(int currentIndex) { int desiredColumn = Beatmap.HitObjects[currentIndex].Column; diff --git a/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs b/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs index dbab54d1d0..29249ba474 100644 --- a/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs +++ b/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs @@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Mania.Replays Actions.AddRange(actions); } - public void FromLegacy(LegacyReplayFrame legacyFrame, IBeatmap beatmap, ReplayFrame lastFrame = null) + public void FromLegacy(LegacyReplayFrame legacyFrame, IBeatmap beatmap, ReplayFrame? lastFrame = null) { var maniaBeatmap = (ManiaBeatmap)beatmap; diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaHealthProcessor.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaHealthProcessor.cs index 57c2ba9c6d..16f7af0d0a 100644 --- a/osu.Game.Rulesets.Mania/Scoring/ManiaHealthProcessor.cs +++ b/osu.Game.Rulesets.Mania/Scoring/ManiaHealthProcessor.cs @@ -1,16 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Scoring { - public class ManiaHealthProcessor : DrainingHealthProcessor + public partial class ManiaHealthProcessor : DrainingHealthProcessor { /// - public ManiaHealthProcessor(double drainStartTime, double drainLenience = 0) - : base(drainStartTime, drainLenience) + public ManiaHealthProcessor(double drainStartTime) + : base(drainStartTime, 1.0) { } diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs index 289f8a00ef..c46a1b5ab6 100644 --- a/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs +++ b/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Scoring diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs index 02d62a090b..f724972a29 100644 --- a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs +++ b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Scoring { - internal class ManiaScoreProcessor : ScoreProcessor + internal partial class ManiaScoreProcessor : ScoreProcessor { public ManiaScoreProcessor() : base(new ManiaRuleset()) diff --git a/osu.Game.Rulesets.Mania/SingleStageVariantGenerator.cs b/osu.Game.Rulesets.Mania/SingleStageVariantGenerator.cs index 2069329d9a..765fd11dd5 100644 --- a/osu.Game.Rulesets.Mania/SingleStageVariantGenerator.cs +++ b/osu.Game.Rulesets.Mania/SingleStageVariantGenerator.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Input.Bindings; diff --git a/osu.Game.Rulesets.Mania/UI/Components/ColumnBackground.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonColumnBackground.cs similarity index 50% rename from osu.Game.Rulesets.Mania/UI/Components/ColumnBackground.cs rename to osu.Game.Rulesets.Mania/Skinning/Argon/ArgonColumnBackground.cs index 77ddc6fbbf..52e6079877 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/ColumnBackground.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonColumnBackground.cs @@ -1,4 +1,4 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; @@ -10,26 +10,38 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; -using osu.Game.Graphics; +using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.UI.Scrolling; using osuTK.Graphics; -namespace osu.Game.Rulesets.Mania.UI.Components +namespace osu.Game.Rulesets.Mania.Skinning.Argon { - public class ColumnBackground : CompositeDrawable, IKeyBindingHandler, IHasAccentColour + public partial class ArgonColumnBackground : CompositeDrawable, IKeyBindingHandler { - private readonly IBindable action = new Bindable(); - - private Box background; - private Box backgroundOverlay; - private readonly IBindable direction = new Bindable(); - [BackgroundDependencyLoader] - private void load(IBindable action, IScrollingInfo scrollingInfo) - { - this.action.BindTo(action); + private Color4 brightColour; + private Color4 dimColour; + private Box background = null!; + private Box backgroundOverlay = null!; + + [Resolved] + private Column column { get; set; } = null!; + + private Bindable accentColour = null!; + + public ArgonColumnBackground() + { + RelativeSizeAxes = Axes.Both; + + Masking = true; + CornerRadius = ArgonNotePiece.CORNER_RADIUS; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { InternalChildren = new[] { background = new Box @@ -47,61 +59,42 @@ namespace osu.Game.Rulesets.Mania.UI.Components } }; - direction.BindTo(scrollingInfo.Direction); - direction.BindValueChanged(dir => + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => { - backgroundOverlay.Anchor = backgroundOverlay.Origin = dir.NewValue == ScrollingDirection.Up ? Anchor.TopLeft : Anchor.BottomLeft; - updateColours(); + background.Colour = colour.NewValue.Darken(3).Opacity(0.8f); + brightColour = colour.NewValue.Opacity(0.6f); + dimColour = colour.NewValue.Opacity(0); }, true); + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); } - protected override void LoadComplete() + private void onDirectionChanged(ValueChangedEvent direction) { - base.LoadComplete(); - updateColours(); - } - - private Color4 accentColour; - - public Color4 AccentColour - { - get => accentColour; - set + if (direction.NewValue == ScrollingDirection.Up) { - if (accentColour == value) - return; - - accentColour = value; - - updateColours(); + backgroundOverlay.Anchor = backgroundOverlay.Origin = Anchor.TopLeft; + backgroundOverlay.Colour = ColourInfo.GradientVertical(brightColour, dimColour); + } + else + { + backgroundOverlay.Anchor = backgroundOverlay.Origin = Anchor.BottomLeft; + backgroundOverlay.Colour = ColourInfo.GradientVertical(dimColour, brightColour); } - } - - private void updateColours() - { - if (!IsLoaded) - return; - - background.Colour = AccentColour.Darken(5); - - var brightPoint = AccentColour.Opacity(0.6f); - var dimPoint = AccentColour.Opacity(0); - - backgroundOverlay.Colour = ColourInfo.GradientVertical( - direction.Value == ScrollingDirection.Up ? brightPoint : dimPoint, - direction.Value == ScrollingDirection.Up ? dimPoint : brightPoint); } public bool OnPressed(KeyBindingPressEvent e) { - if (e.Action == action.Value) + if (e.Action == column.Action.Value) backgroundOverlay.FadeTo(1, 50, Easing.OutQuint).Then().FadeTo(0.5f, 250, Easing.OutQuint); return false; } public void OnReleased(KeyBindingReleaseEvent e) { - if (e.Action == action.Value) + if (e.Action == column.Action.Value) backgroundOverlay.FadeTo(0, 250, Easing.OutQuint); } } diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitExplosion.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitExplosion.cs new file mode 100644 index 0000000000..d490d3f944 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitExplosion.cs @@ -0,0 +1,100 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + public partial class ArgonHitExplosion : CompositeDrawable, IHitExplosion + { + public override bool RemoveWhenNotAlive => true; + + [Resolved] + private Column column { get; set; } = null!; + + private readonly IBindable direction = new Bindable(); + + private Container largeFaint = null!; + + private Bindable accentColour = null!; + + public ArgonHitExplosion() + { + Origin = Anchor.Centre; + + RelativeSizeAxes = Axes.X; + Height = ArgonNotePiece.NOTE_HEIGHT; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + InternalChildren = new Drawable[] + { + largeFaint = new Container + { + RelativeSizeAxes = Axes.Both, + Height = ArgonNotePiece.NOTE_ACCENT_RATIO, + Masking = true, + CornerRadius = ArgonNotePiece.CORNER_RADIUS, + Blending = BlendingParameters.Additive, + Child = new Box + { + Colour = Color4.White, + RelativeSizeAxes = Axes.Both, + }, + }, + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => + { + largeFaint.Colour = Interpolation.ValueAt(0.8f, colour.NewValue, Color4.White, 0, 1); + + largeFaint.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = colour.NewValue, + Roundness = 40, + Radius = 60, + }; + }, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + 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; + } + } + + public void Animate(JudgementResult result) + { + this.FadeOutFromOne(PoolableHitExplosion.DURATION, Easing.Out); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitTarget.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitTarget.cs new file mode 100644 index 0000000000..cf5931231c --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHitTarget.cs @@ -0,0 +1,47 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + public partial class ArgonHitTarget : CompositeDrawable + { + private readonly IBindable direction = new Bindable(); + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + RelativeSizeAxes = Axes.X; + Height = ArgonNotePiece.NOTE_HEIGHT * ArgonNotePiece.NOTE_ACCENT_RATIO; + + Masking = true; + CornerRadius = ArgonNotePiece.CORNER_RADIUS; + + InternalChildren = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.3f, + Blending = BlendingParameters.Additive, + Colour = Color4.White + }, + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + Anchor = Origin = direction.NewValue == ScrollingDirection.Up ? Anchor.TopLeft : Anchor.BottomLeft; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldBodyPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldBodyPiece.cs new file mode 100644 index 0000000000..57fa1c10ae --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldBodyPiece.cs @@ -0,0 +1,65 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Skinning.Default; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + /// + /// Represents length-wise portion of a hold note. + /// + public partial class ArgonHoldBodyPiece : CompositeDrawable, IHoldNoteBody + { + protected readonly Bindable AccentColour = new Bindable(); + + private Drawable background = null!; + private ArgonHoldNoteHittingLayer hittingLayer = null!; + + public ArgonHoldBodyPiece() + { + RelativeSizeAxes = Axes.Both; + + // Without this, the width of the body will be slightly larger than the head/tail. + Masking = true; + CornerRadius = ArgonNotePiece.CORNER_RADIUS; + } + + [BackgroundDependencyLoader(true)] + private void load(DrawableHitObject? drawableObject) + { + InternalChildren = new[] + { + background = new Box { RelativeSizeAxes = Axes.Both }, + hittingLayer = new ArgonHoldNoteHittingLayer() + }; + + if (drawableObject != null) + { + var holdNote = (DrawableHoldNote)drawableObject; + + AccentColour.BindTo(holdNote.AccentColour); + hittingLayer.AccentColour.BindTo(holdNote.AccentColour); + ((IBindable)hittingLayer.IsHitting).BindTo(holdNote.IsHitting); + } + + AccentColour.BindValueChanged(colour => + { + background.Colour = colour.NewValue.Darken(0.6f); + }, true); + } + + public void Recycle() + { + 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 new file mode 100644 index 0000000000..efd7f4f280 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonHoldNoteTailPiece.cs @@ -0,0 +1,132 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.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; +using osuTK.Graphics; + +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 foreground; + private readonly ArgonHoldNoteHittingLayer hittingLayer; + private readonly Box foregroundAdditive; + + public ArgonHoldNoteTailPiece() + { + RelativeSizeAxes = Axes.X; + Height = ArgonNotePiece.NOTE_HEIGHT; + + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + Height = ArgonNotePiece.NOTE_HEIGHT, + CornerRadius = ArgonNotePiece.CORNER_RADIUS, + Masking = true, + Children = new Drawable[] + { + 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) + { + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + + if (drawableObject != null) + { + 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); + } + + private void onAccentChanged(ValueChangedEvent accent) + { + 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/ArgonJudgementPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonJudgementPiece.cs new file mode 100644 index 0000000000..4ce3c50f7c --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonJudgementPiece.cs @@ -0,0 +1,184 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + public partial class ArgonJudgementPiece : JudgementPiece, IAnimatableJudgement + { + private RingExplosion? ringExplosion; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public ArgonJudgementPiece(HitResult result) + : base(result) + { + AutoSizeAxes = Axes.Both; + + Origin = Anchor.Centre; + Y = 160; + } + + [BackgroundDependencyLoader] + private void load() + { + if (Result.IsHit()) + { + AddInternal(ringExplosion = new RingExplosion(Result) + { + Colour = colours.ForHitResult(Result), + }); + } + } + + protected override SpriteText CreateJudgementText() => + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Blending = BlendingParameters.Additive, + Spacing = new Vector2(10, 0), + Font = OsuFont.Default.With(size: 28, weight: FontWeight.Regular), + }; + + /// + /// Plays the default animation for this judgement piece. + /// + /// + /// The base implementation only handles fade (for all result types) and misses. + /// Individual rulesets are recommended to implement their appropriate hit animations. + /// + public virtual void PlayAnimation() + { + switch (Result) + { + default: + JudgementText + .ScaleTo(Vector2.One) + .ScaleTo(new Vector2(1.4f), 1800, Easing.OutQuint); + break; + + case HitResult.Miss: + this.ScaleTo(1.6f); + this.ScaleTo(1, 100, Easing.In); + + this.MoveTo(Vector2.Zero); + this.MoveToOffset(new Vector2(0, 100), 800, Easing.InQuint); + + this.RotateTo(0); + this.RotateTo(40, 800, Easing.InQuint); + break; + } + + this.FadeOutFromOne(800); + + ringExplosion?.PlayAnimation(); + } + + public Drawable? GetAboveHitObjectsProxiedContent() => null; + + private partial class RingExplosion : CompositeDrawable + { + private readonly float travel = 52; + + public RingExplosion(HitResult result) + { + const float thickness = 4; + + const float small_size = 9; + const float large_size = 14; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Blending = BlendingParameters.Additive; + + int countSmall = 0; + int countLarge = 0; + + switch (result) + { + case HitResult.Meh: + countSmall = 3; + travel *= 0.3f; + break; + + case HitResult.Ok: + case HitResult.Good: + countSmall = 4; + travel *= 0.6f; + break; + + case HitResult.Great: + case HitResult.Perfect: + countSmall = 4; + countLarge = 4; + break; + } + + for (int i = 0; i < countSmall; i++) + AddInternal(new RingPiece(thickness) { Size = new Vector2(small_size) }); + + for (int i = 0; i < countLarge; i++) + AddInternal(new RingPiece(thickness) { Size = new Vector2(large_size) }); + } + + public void PlayAnimation() + { + foreach (var c in InternalChildren) + { + const float start_position_ratio = 0.3f; + + float direction = RNG.NextSingle(0, 360); + float distance = RNG.NextSingle(travel / 2, travel); + + c.MoveTo(new Vector2( + MathF.Cos(direction) * distance * start_position_ratio, + MathF.Sin(direction) * distance * start_position_ratio + )); + + c.MoveTo(new Vector2( + MathF.Cos(direction) * distance, + MathF.Sin(direction) * distance + ), 600, Easing.OutQuint); + } + + this.FadeOutFromOne(1000, Easing.OutQuint); + } + + public partial class RingPiece : CircularContainer + { + public RingPiece(float thickness = 9) + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Masking = true; + BorderThickness = thickness; + BorderColour = Color4.White; + + Child = new Box + { + AlwaysPresent = true, + Alpha = 0, + RelativeSizeAxes = Axes.Both + }; + } + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonKeyArea.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonKeyArea.cs new file mode 100644 index 0000000000..95e24f8811 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonKeyArea.cs @@ -0,0 +1,272 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + public partial class ArgonKeyArea : CompositeDrawable, IKeyBindingHandler + { + private readonly IBindable direction = new Bindable(); + + private Container directionContainer = null!; + private Drawable background = null!; + + private Circle hitTargetLine = null!; + + private Container bottomIcon = null!; + private CircularContainer topIcon = null!; + + private Bindable accentColour = null!; + + [Resolved] + private Column column { get; set; } = null!; + + public ArgonKeyArea() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + const float icon_circle_size = 8; + const float icon_spacing = 7; + const float icon_vertical_offset = -30; + + InternalChild = directionContainer = new Container + { + RelativeSizeAxes = Axes.X, + // Ensure the area is tall enough to put the target line in the correct location. + // This is to also allow the main background component to overlap the target line + // and avoid an inner corner radius being shown below the target line. + Height = Stage.HIT_TARGET_POSITION + ArgonNotePiece.CORNER_RADIUS * 2, + Children = new[] + { + new Container + { + Masking = true, + RelativeSizeAxes = Axes.Both, + CornerRadius = ArgonNotePiece.CORNER_RADIUS, + Child = background = new Box + { + Name = "Key gradient", + Alpha = 0, + RelativeSizeAxes = Axes.Both, + }, + }, + hitTargetLine = new Circle + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Colour = OsuColour.Gray(196 / 255f), + Height = ArgonNotePiece.CORNER_RADIUS * 2, + Masking = true, + EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow }, + }, + new Container + { + Name = "Icons", + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Children = new Drawable[] + { + bottomIcon = new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + Blending = BlendingParameters.Additive, + Y = icon_vertical_offset, + Children = new[] + { + new Circle + { + Size = new Vector2(icon_circle_size), + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow }, + }, + new Circle + { + X = -icon_spacing, + Y = icon_spacing * 1.2f, + Size = new Vector2(icon_circle_size), + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow }, + }, + new Circle + { + X = icon_spacing, + Y = icon_spacing * 1.2f, + Size = new Vector2(icon_circle_size), + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow }, + }, + } + }, + topIcon = new CircularContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + Y = -icon_vertical_offset, + Size = new Vector2(22, 14), + Masking = true, + BorderThickness = 4, + BorderColour = Color4.White, + EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow }, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + }, + }, + } + } + }, + } + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => + { + background.Colour = colour.NewValue.Darken(0.2f); + bottomIcon.Colour = colour.NewValue; + }, + true); + + // Yes, proxy everything. + column.TopLevelContainer.Add(CreateProxy()); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + switch (direction.NewValue) + { + case ScrollingDirection.Up: + directionContainer.Scale = new Vector2(1, -1); + directionContainer.Anchor = Anchor.TopLeft; + directionContainer.Origin = Anchor.BottomLeft; + break; + + case ScrollingDirection.Down: + directionContainer.Scale = new Vector2(1, 1); + directionContainer.Anchor = Anchor.BottomLeft; + directionContainer.Origin = Anchor.BottomLeft; + break; + } + } + + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action != column.Action.Value) return false; + + const double lighting_fade_in_duration = 70; + Color4 lightingColour = getLightingColour(); + + background + .FlashColour(accentColour.Value.Lighten(0.8f), 200, Easing.OutQuint) + .FadeTo(1, lighting_fade_in_duration, Easing.OutQuint) + .Then() + .FadeTo(0.8f, 500); + + hitTargetLine.FadeColour(Color4.White, lighting_fade_in_duration, Easing.OutQuint); + hitTargetLine.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour.Opacity(0.4f), + Radius = 20, + }, lighting_fade_in_duration, Easing.OutQuint); + + topIcon.ScaleTo(0.9f, lighting_fade_in_duration, Easing.OutQuint); + topIcon.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour.Opacity(0.1f), + Radius = 20, + }, lighting_fade_in_duration, Easing.OutQuint); + + bottomIcon.FadeColour(Color4.White, lighting_fade_in_duration, Easing.OutQuint); + + foreach (var circle in bottomIcon) + { + circle.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour.Opacity(0.2f), + Radius = 60, + }, lighting_fade_in_duration, Easing.OutQuint); + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + if (e.Action != column.Action.Value) return; + + const double lighting_fade_out_duration = 800; + + Color4 lightingColour = getLightingColour().Opacity(0); + + // background fades out faster than lighting elements to give better definition to the player. + background.FadeTo(0.3f, 50, Easing.OutQuint) + .Then() + .FadeOut(lighting_fade_out_duration, Easing.OutQuint); + + topIcon.ScaleTo(1f, 200, Easing.OutQuint); + topIcon.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour, + Radius = 20, + }, lighting_fade_out_duration, Easing.OutQuint); + + hitTargetLine.FadeColour(OsuColour.Gray(196 / 255f), lighting_fade_out_duration, Easing.OutQuint); + hitTargetLine.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour, + Radius = 25, + }, lighting_fade_out_duration, Easing.OutQuint); + + bottomIcon.FadeColour(accentColour.Value, lighting_fade_out_duration, Easing.OutQuint); + + foreach (var circle in bottomIcon) + { + circle.TransformTo(nameof(EdgeEffect), new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = lightingColour, + Radius = 30, + }, lighting_fade_out_duration, Easing.OutQuint); + } + } + + private Color4 getLightingColour() => Interpolation.ValueAt(0.2f, accentColour.Value, Color4.White, 0, 1); + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonNotePiece.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonNotePiece.cs new file mode 100644 index 0000000000..3a519283f1 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonNotePiece.cs @@ -0,0 +1,114 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + internal partial class ArgonNotePiece : CompositeDrawable + { + public const float NOTE_HEIGHT = 42; + public const float NOTE_ACCENT_RATIO = 0.82f; + public const float CORNER_RADIUS = 3.4f; + + private readonly IBindable direction = new Bindable(); + private readonly IBindable accentColour = new Bindable(); + + private readonly Box colouredBox; + + public ArgonNotePiece() + { + RelativeSizeAxes = Axes.X; + Height = NOTE_HEIGHT; + + CornerRadius = CORNER_RADIUS; + Masking = true; + + InternalChildren = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientVertical(Color4.Black.Opacity(0), Colour4.Black) + }, + new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.Both, + Height = NOTE_ACCENT_RATIO, + Masking = true, + CornerRadius = CORNER_RADIUS, + Children = new Drawable[] + { + colouredBox = new Box + { + RelativeSizeAxes = Axes.Both, + } + } + }, + new Circle + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Height = CORNER_RADIUS * 2, + }, + 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) + { + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + + if (drawableObject != null) + { + accentColour.BindTo(drawableObject.AccentColour); + accentColour.BindValueChanged(onAccentChanged, true); + } + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + colouredBox.Anchor = colouredBox.Origin = direction.NewValue == ScrollingDirection.Up + ? Anchor.TopCentre + : Anchor.BottomCentre; + + Scale = new Vector2(1, direction.NewValue == ScrollingDirection.Up ? -1 : 1); + } + + private void onAccentChanged(ValueChangedEvent accent) + { + colouredBox.Colour = ColourInfo.GradientVertical( + accent.NewValue.Lighten(0.1f), + accent.NewValue + ); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonStageBackground.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonStageBackground.cs new file mode 100644 index 0000000000..ec99177f98 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ArgonStageBackground.cs @@ -0,0 +1,16 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + public partial class ArgonStageBackground : CompositeDrawable + { + public ArgonStageBackground() + { + RelativeSizeAxes = Axes.Both; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs new file mode 100644 index 0000000000..007d02400a --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs @@ -0,0 +1,315 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Argon +{ + public class ManiaArgonSkinTransformer : SkinTransformer + { + private readonly ManiaBeatmap beatmap; + + public ManiaArgonSkinTransformer(ISkin skin, IBeatmap beatmap) + : base(skin) + { + this.beatmap = (ManiaBeatmap)beatmap; + } + + public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) + { + switch (lookup) + { + case GameplaySkinComponentLookup resultComponent: + // This should eventually be moved to a skin setting, when supported. + if (Skin is ArgonProSkin && resultComponent.Component >= HitResult.Great) + return Drawable.Empty(); + + return new ArgonJudgementPiece(resultComponent.Component); + + case ManiaSkinComponentLookup maniaComponent: + // TODO: Once everything is finalised, consider throwing UnsupportedSkinComponentException on missing entries. + switch (maniaComponent.Component) + { + case ManiaSkinComponents.StageBackground: + return new ArgonStageBackground(); + + case ManiaSkinComponents.ColumnBackground: + return new ArgonColumnBackground(); + + case ManiaSkinComponents.HoldNoteBody: + return new ArgonHoldBodyPiece(); + + case ManiaSkinComponents.HoldNoteTail: + return new ArgonHoldNoteTailPiece(); + + case ManiaSkinComponents.HoldNoteHead: + return new ArgonHoldNoteHeadPiece(); + + case ManiaSkinComponents.Note: + return new ArgonNotePiece(); + + case ManiaSkinComponents.HitTarget: + return new ArgonHitTarget(); + + case ManiaSkinComponents.KeyArea: + return new ArgonKeyArea(); + + case ManiaSkinComponents.HitExplosion: + return new ArgonHitExplosion(); + } + + break; + } + + 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 columnIndex = maniaLookup.ColumnIndex ?? 0; + var stage = beatmap.GetStageForColumnIndex(columnIndex); + + switch (maniaLookup.Lookup) + { + case LegacyManiaSkinConfigurationLookups.ColumnSpacing: + return SkinUtils.As(new Bindable(2)); + + case LegacyManiaSkinConfigurationLookups.StagePaddingBottom: + case LegacyManiaSkinConfigurationLookups.StagePaddingTop: + return SkinUtils.As(new Bindable(30)); + + case LegacyManiaSkinConfigurationLookups.ColumnWidth: + return SkinUtils.As(new Bindable( + stage.IsSpecialColumn(columnIndex) ? 120 : 60 + )); + + case LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour: + + var colour = getColourForLayout(columnIndex, stage); + + return SkinUtils.As(new Bindable(colour)); + } + } + + 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/Skinning/Default/DefaultBodyPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Default/DefaultBodyPiece.cs index 7138421b91..9f5ee0846f 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Default/DefaultBodyPiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Default/DefaultBodyPiece.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -20,13 +19,13 @@ namespace osu.Game.Rulesets.Mania.Skinning.Default /// /// Represents length-wise portion of a hold note. /// - public class DefaultBodyPiece : CompositeDrawable, IHoldNoteBody + public partial class DefaultBodyPiece : CompositeDrawable, IHoldNoteBody { protected readonly Bindable AccentColour = new Bindable(); protected readonly IBindable IsHitting = new Bindable(); - protected Drawable Background { get; private set; } - private Container foregroundContainer; + protected Drawable Background { get; private set; } = null!; + private Container foregroundContainer = null!; public DefaultBodyPiece() { @@ -34,7 +33,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Default } [BackgroundDependencyLoader(true)] - private void load([CanBeNull] DrawableHitObject drawableObject) + private void load(DrawableHitObject? drawableObject) { InternalChildren = new[] { @@ -65,16 +64,16 @@ namespace osu.Game.Rulesets.Mania.Skinning.Default private void onAccentChanged(ValueChangedEvent accent) => Background.Colour = accent.NewValue.Opacity(0.7f); - private class ForegroundPiece : CompositeDrawable + private partial class ForegroundPiece : CompositeDrawable { public readonly Bindable AccentColour = new Bindable(); public readonly IBindable IsHitting = new Bindable(); private readonly LayoutValue subtractionCache = new LayoutValue(Invalidation.DrawSize); - private BufferedContainer foregroundBuffer; - private BufferedContainer subtractionBuffer; - private Container subtractionLayer; + private BufferedContainer foregroundBuffer = null!; + private BufferedContainer subtractionBuffer = null!; + private Container subtractionLayer = null!; public ForegroundPiece() { diff --git a/osu.Game.Rulesets.Mania/Skinning/Default/DefaultNotePiece.cs b/osu.Game.Rulesets.Mania/Skinning/Default/DefaultNotePiece.cs index c9c3cff799..e789335584 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Default/DefaultNotePiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Default/DefaultNotePiece.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 JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -18,7 +17,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Default /// /// Represents the static hit markers of notes. /// - internal class DefaultNotePiece : CompositeDrawable + internal partial class DefaultNotePiece : CompositeDrawable { public const float NOTE_HEIGHT = 12; @@ -51,7 +50,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Default } [BackgroundDependencyLoader(true)] - private void load([NotNull] IScrollingInfo scrollingInfo, [CanBeNull] DrawableHitObject drawableObject) + private void load(IScrollingInfo scrollingInfo, DrawableHitObject? drawableObject) { direction.BindTo(scrollingInfo.Direction); direction.BindValueChanged(onDirectionChanged, true); diff --git a/osu.Game.Rulesets.Mania/Skinning/Default/ManiaTrianglesSkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Default/ManiaTrianglesSkinTransformer.cs new file mode 100644 index 0000000000..eb51179cea --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Default/ManiaTrianglesSkinTransformer.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Default +{ + public class ManiaTrianglesSkinTransformer : SkinTransformer + { + private readonly ManiaBeatmap beatmap; + + public ManiaTrianglesSkinTransformer(ISkin skin, IBeatmap beatmap) + : base(skin) + { + this.beatmap = (ManiaBeatmap)beatmap; + } + + private readonly Color4 colourEven = new Color4(6, 84, 0, 255); + private readonly Color4 colourOdd = new Color4(94, 0, 57, 255); + private readonly Color4 colourSpecial = new Color4(0, 48, 63, 255); + + public override IBindable? GetConfig(TLookup lookup) + { + if (lookup is ManiaSkinConfigurationLookup maniaLookup) + { + switch (maniaLookup.Lookup) + { + case LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour: + int column = maniaLookup.ColumnIndex ?? 0; + + var stage = beatmap.GetStageForColumnIndex(column); + + if (stage.IsSpecialColumn(column)) + return SkinUtils.As(new Bindable(colourSpecial)); + + int distanceToEdge = Math.Min(column, (stage.Columns - 1) - column); + return SkinUtils.As(new Bindable(distanceToEdge % 2 == 0 ? colourOdd : colourEven)); + } + } + + return base.GetConfig(lookup); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/HitTargetInsetContainer.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/HitTargetInsetContainer.cs index 3c89e2c04a..608cde7272 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/HitTargetInsetContainer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/HitTargetInsetContainer.cs @@ -11,7 +11,7 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.Skinning.Legacy { - public class HitTargetInsetContainer : Container + public partial class HitTargetInsetContainer : Container { private readonly IBindable direction = new Bindable(); diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs index 31db08ce2f..ef4810c40d 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyBodyPiece.cs @@ -2,12 +2,15 @@ // See the LICENCE file in the repository root for full licence text. using System; -using JetBrains.Annotations; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Testing; using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI.Scrolling; @@ -16,9 +19,9 @@ using osuTK; namespace osu.Game.Rulesets.Mania.Skinning.Legacy { - public class LegacyBodyPiece : LegacyManiaColumnElement + public partial class LegacyBodyPiece : LegacyManiaColumnElement { - private DrawableHoldNote holdNote; + private DrawableHoldNote holdNote = null!; private readonly IBindable direction = new Bindable(); private readonly IBindable isHitting = new Bindable(); @@ -29,14 +32,12 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy /// private readonly Bindable missFadeTime = new Bindable(); - [CanBeNull] - private Drawable bodySprite; + private Drawable? bodySprite; - [CanBeNull] - private Drawable lightContainer; + private Drawable? lightContainer; - [CanBeNull] - private Drawable light; + private Drawable? light; + private LegacyNoteBodyStyle? bodyStyle; public LegacyBodyPiece() { @@ -83,7 +84,14 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy }; } - bodySprite = skin.GetAnimation(imageName, WrapMode.ClampToEdge, WrapMode.ClampToEdge, true, true).With(d => + bodyStyle = skin.GetConfig(new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.NoteBodyStyle))?.Value; + + var wrapMode = bodyStyle == LegacyNoteBodyStyle.Stretch ? WrapMode.ClampToEdge : WrapMode.Repeat; + + direction.BindTo(scrollingInfo.Direction); + isHitting.BindTo(holdNote.IsHitting); + + bodySprite = skin.GetAnimation(imageName, wrapMode, wrapMode, true, true).With(d => { if (d == null) return; @@ -94,15 +102,11 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy d.Anchor = Anchor.TopCentre; d.RelativeSizeAxes = Axes.Both; d.Size = Vector2.One; - d.FillMode = FillMode.Stretch; - // Todo: Wrap + // Todo: Wrap? }); if (bodySprite != null) InternalChild = bodySprite; - - direction.BindTo(scrollingInfo.Direction); - isHitting.BindTo(holdNote.IsHitting); } protected override void LoadComplete() @@ -153,7 +157,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy else { lightContainer.FadeOut(120) - .OnComplete(d => Column.TopLevelContainer.Remove(d)); + .OnComplete(d => Column.TopLevelContainer.Remove(d, false)); } } @@ -163,8 +167,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy { if (bodySprite != null) { - bodySprite.Origin = Anchor.BottomCentre; - bodySprite.Scale = new Vector2(1, -1); + bodySprite.Origin = Anchor.TopCentre; + bodySprite.Anchor = Anchor.BottomCentre; // needs to be flipped due to scale flip in Update. } if (light != null) @@ -175,7 +179,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy if (bodySprite != null) { bodySprite.Origin = Anchor.TopCentre; - bodySprite.Scale = Vector2.One; + bodySprite.Anchor = Anchor.TopCentre; } if (light != null) @@ -206,13 +210,40 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy { base.Update(); missFadeTime.Value ??= holdNote.HoldBrokenTime; + + int scaleDirection = (direction.Value == ScrollingDirection.Down ? 1 : -1); + + // here we go... + switch (bodyStyle) + { + case LegacyNoteBodyStyle.Stretch: + // this is how lazer works by default. nothing required. + if (bodySprite != null) + bodySprite.Scale = new Vector2(1, scaleDirection); + break; + + default: + // this is where things get fucked up. + // honestly there's three modes to handle here but they seem really pointless? + // let's wait to see if anyone actually uses them in skins. + if (bodySprite != null) + { + var sprite = bodySprite as Sprite ?? bodySprite.ChildrenOfType().Single(); + + bodySprite.FillMode = FillMode.Stretch; + // i dunno this looks about right?? + bodySprite.Scale = new Vector2(1, scaleDirection * 32800 / sprite.DrawHeight); + } + + break; + } } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - if (holdNote != null) + if (holdNote.IsNotNull()) holdNote.ApplyCustomUpdateState -= applyCustomUpdateState; lightContainer?.Expire(); diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyColumnBackground.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyColumnBackground.cs index 54ddcbd5fe..ab996519a7 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyColumnBackground.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyColumnBackground.cs @@ -15,12 +15,12 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Skinning.Legacy { - public class LegacyColumnBackground : LegacyManiaColumnElement, IKeyBindingHandler + public partial class LegacyColumnBackground : LegacyManiaColumnElement, IKeyBindingHandler { private readonly IBindable direction = new Bindable(); - private Container lightContainer; - private Sprite light; + private Container lightContainer = null!; + private Sprite light = null!; public LegacyColumnBackground() { diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHitExplosion.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHitExplosion.cs index e4d466dca5..6c56db613c 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHitExplosion.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHitExplosion.cs @@ -15,13 +15,13 @@ using osuTK; namespace osu.Game.Rulesets.Mania.Skinning.Legacy { - public class LegacyHitExplosion : LegacyManiaColumnElement, IHitExplosion + public partial class LegacyHitExplosion : LegacyManiaColumnElement, IHitExplosion { public const double FADE_IN_DURATION = 80; private readonly IBindable direction = new Bindable(); - private Drawable explosion; + private Drawable? explosion; public LegacyHitExplosion() { diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHitTarget.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHitTarget.cs index 490a03d11a..3a08a9d818 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHitTarget.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHitTarget.cs @@ -14,11 +14,11 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Skinning.Legacy { - public class LegacyHitTarget : CompositeDrawable + public partial class LegacyHitTarget : CompositeDrawable { private readonly IBindable direction = new Bindable(); - private Container directionContainer; + private Container directionContainer = null!; [BackgroundDependencyLoader] private void load(ISkinSource skin, IScrollingInfo scrollingInfo) diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHoldNoteHeadPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHoldNoteHeadPiece.cs index 1e75533442..19c3d8e7f8 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHoldNoteHeadPiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHoldNoteHeadPiece.cs @@ -6,9 +6,9 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.Skinning.Legacy { - public class LegacyHoldNoteHeadPiece : LegacyNotePiece + public partial class LegacyHoldNoteHeadPiece : LegacyNotePiece { - protected override Drawable GetAnimation(ISkinSource skin) + protected override Drawable? GetAnimation(ISkinSource skin) { // TODO: Should fallback to the head from default legacy skin instead of note. return GetAnimationFromLookup(skin, LegacyManiaSkinConfigurationLookups.HoldNoteHeadImage) diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHoldNoteTailPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHoldNoteTailPiece.cs index e6d4291d79..387c5b393a 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHoldNoteTailPiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHoldNoteTailPiece.cs @@ -8,7 +8,7 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.Skinning.Legacy { - public class LegacyHoldNoteTailPiece : LegacyNotePiece + public partial class LegacyHoldNoteTailPiece : LegacyNotePiece { protected override void OnDirectionChanged(ValueChangedEvent direction) { @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy : new ValueChangedEvent(ScrollingDirection.Up, ScrollingDirection.Up)); } - protected override Drawable GetAnimation(ISkinSource skin) + protected override Drawable? GetAnimation(ISkinSource skin) { // TODO: Should fallback to the head from default legacy skin instead of note. return GetAnimationFromLookup(skin, LegacyManiaSkinConfigurationLookups.HoldNoteTailImage) diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyKeyArea.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyKeyArea.cs index 9c339345c4..48b92a8486 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyKeyArea.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyKeyArea.cs @@ -15,16 +15,16 @@ using osuTK; namespace osu.Game.Rulesets.Mania.Skinning.Legacy { - public class LegacyKeyArea : LegacyManiaColumnElement, IKeyBindingHandler + public partial class LegacyKeyArea : LegacyManiaColumnElement, IKeyBindingHandler { private readonly IBindable direction = new Bindable(); - private Container directionContainer; - private Sprite upSprite; - private Sprite downSprite; + private Container directionContainer = null!; + private Sprite upSprite = null!; + private Sprite downSprite = null!; [Resolved] - private Column column { get; set; } + private Column column { get; set; } = null!; public LegacyKeyArea() { diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaColumnElement.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaColumnElement.cs index eb5514ba43..7e3fb0438c 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaColumnElement.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaColumnElement.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; @@ -13,36 +14,32 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy /// /// A which is placed somewhere within a . /// - public class LegacyManiaColumnElement : CompositeDrawable + public partial class LegacyManiaColumnElement : CompositeDrawable { [Resolved] - protected Column Column { get; private set; } + protected Column Column { get; private set; } = null!; + + [Resolved] + private StageDefinition stage { get; set; } = null!; /// /// The column type identifier to use for texture lookups, in the case of no user-provided configuration. /// - protected string FallbackColumnIndex { get; private set; } + protected string FallbackColumnIndex { get; private set; } = null!; [BackgroundDependencyLoader] private void load() { - switch (Column.ColumnType) + if (Column.IsSpecial) + FallbackColumnIndex = "S"; + else { - case ColumnType.Special: - FallbackColumnIndex = "S"; - break; - - case ColumnType.Odd: - FallbackColumnIndex = "1"; - break; - - case ColumnType.Even: - FallbackColumnIndex = "2"; - break; + int distanceToEdge = Math.Min(Column.Index, (stage.Columns - 1) - Column.Index); + FallbackColumnIndex = distanceToEdge % 2 == 0 ? "1" : "2"; } } - protected IBindable GetColumnSkinConfig(ISkin skin, LegacyManiaSkinConfigurationLookups lookup) + protected IBindable? GetColumnSkinConfig(ISkin skin, LegacyManiaSkinConfigurationLookups lookup) where T : notnull => skin.GetManiaSkinConfig(lookup, Column.Index); } } diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaJudgementPiece.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaJudgementPiece.cs index 5d662c18d3..d21a8cd140 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaJudgementPiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyManiaJudgementPiece.cs @@ -13,7 +13,7 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.Skinning.Legacy { - public class LegacyManiaJudgementPiece : CompositeDrawable, IAnimatableJudgement + public partial class LegacyManiaJudgementPiece : CompositeDrawable, IAnimatableJudgement { private readonly HitResult result; private readonly Drawable animation; @@ -39,21 +39,15 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy Y = scorePosition ?? 0; - if (animation != null) + InternalChild = animation.With(d => { - InternalChild = animation.With(d => - { - d.Anchor = Anchor.Centre; - d.Origin = Anchor.Centre; - }); - } + d.Anchor = Anchor.Centre; + d.Origin = Anchor.Centre; + }); } public void PlayAnimation() { - if (animation == null) - return; - (animation as IFramedAnimation)?.GotoFrame(0); this.FadeInFromZero(20, Easing.Out) @@ -84,6 +78,6 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy } } - public Drawable GetAboveHitObjectsProxiedContent() => null; + public Drawable? GetAboveHitObjectsProxiedContent() => null; } } diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyNotePiece.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyNotePiece.cs index 321a87f8b1..4291ec3c13 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyNotePiece.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyNotePiece.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 JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Rulesets.UI.Scrolling; @@ -16,14 +14,13 @@ using osuTK; namespace osu.Game.Rulesets.Mania.Skinning.Legacy { - public class LegacyNotePiece : LegacyManiaColumnElement + public partial class LegacyNotePiece : LegacyManiaColumnElement { private readonly IBindable direction = new Bindable(); - private Container directionContainer; + private Container directionContainer = null!; - [CanBeNull] - private Drawable noteAnimation; + private Drawable noteAnimation = null!; private float? minimumColumnWidth; @@ -54,7 +51,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy { base.Update(); - Texture texture = null; + Texture? texture = null; if (noteAnimation is Sprite sprite) texture = sprite.Texture; @@ -83,11 +80,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy } } - [CanBeNull] - protected virtual Drawable GetAnimation(ISkinSource skin) => GetAnimationFromLookup(skin, LegacyManiaSkinConfigurationLookups.NoteImage); + protected virtual Drawable? GetAnimation(ISkinSource skin) => GetAnimationFromLookup(skin, LegacyManiaSkinConfigurationLookups.NoteImage); - [CanBeNull] - protected Drawable GetAnimationFromLookup(ISkin skin, LegacyManiaSkinConfigurationLookups lookup) + protected Drawable? GetAnimationFromLookup(ISkin skin, LegacyManiaSkinConfigurationLookups lookup) { string suffix = string.Empty; diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs index fdacc75c92..758c8dd347 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageBackground.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -14,22 +16,19 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Skinning.Legacy { - public class LegacyStageBackground : CompositeDrawable + public partial class LegacyStageBackground : CompositeDrawable { - private readonly StageDefinition stageDefinition; - private Drawable leftSprite; private Drawable rightSprite; private ColumnFlow columnBackgrounds; - public LegacyStageBackground(StageDefinition stageDefinition) + public LegacyStageBackground() { - this.stageDefinition = stageDefinition; RelativeSizeAxes = Axes.Both; } [BackgroundDependencyLoader] - private void load(ISkinSource skin) + private void load(ISkinSource skin, StageDefinition stageDefinition) { string leftImage = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.LeftStageImage)?.Value ?? "mania-stage-left"; @@ -78,7 +77,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy rightSprite.Scale = new Vector2(1, DrawHeight / rightSprite.Height); } - private class ColumnBackground : CompositeDrawable + private partial class ColumnBackground : CompositeDrawable { private readonly int columnIndex; private readonly bool isLastColumn; diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageForeground.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageForeground.cs index 4e1952a670..1a47fe5076 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageForeground.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyStageForeground.cs @@ -11,11 +11,11 @@ using osuTK; namespace osu.Game.Rulesets.Mania.Skinning.Legacy { - public class LegacyStageForeground : CompositeDrawable + public partial class LegacyStageForeground : CompositeDrawable { private readonly IBindable direction = new Bindable(); - private Drawable sprite; + private Drawable? sprite; public LegacyStageForeground() { diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaClassicSkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaClassicSkinTransformer.cs new file mode 100644 index 0000000000..be3372fe58 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaClassicSkinTransformer.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning.Legacy +{ + public class ManiaClassicSkinTransformer : ManiaLegacySkinTransformer + { + public ManiaClassicSkinTransformer(ISkin skin, IBeatmap beatmap) + : base(skin, beatmap) + { + } + + public override IBindable? GetConfig(TLookup lookup) + { + if (lookup is ManiaSkinConfigurationLookup maniaLookup) + { + var baseLookup = base.GetConfig(lookup); + + if (baseLookup != null) + return baseLookup; + + // default provisioning. + switch (maniaLookup.Lookup) + { + case LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour: + return SkinUtils.As(new Bindable(Color4.Black)); + } + } + + return base.GetConfig(lookup); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs index 315b4444c2..f8519beb22 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs @@ -1,9 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; -using System.Diagnostics; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -18,7 +19,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy { public class ManiaLegacySkinTransformer : LegacySkinTransformer { - private readonly ManiaBeatmap beatmap; + public override bool IsProvidingLegacyResources => base.IsProvidingLegacyResources || hasKeyTexture.Value; /// /// Mapping of to their corresponding @@ -58,6 +59,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy /// private readonly Lazy hasKeyTexture; + private readonly ManiaBeatmap beatmap; + public ManiaLegacySkinTransformer(ISkin skin, IBeatmap beatmap) : base(skin) { @@ -71,14 +74,14 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy }); } - public override Drawable GetDrawableComponent(ISkinComponent component) + public override Drawable GetDrawableComponent(ISkinComponentLookup lookup) { - switch (component) + switch (lookup) { - case GameplaySkinComponent resultComponent: + case GameplaySkinComponentLookup resultComponent: return getResult(resultComponent.Component); - case ManiaSkinComponent maniaComponent: + case ManiaSkinComponentLookup maniaComponent: if (!isLegacySkin.Value || !hasKeyTexture.Value) return null; @@ -111,18 +114,17 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy return new LegacyHitExplosion(); case ManiaSkinComponents.StageBackground: - Debug.Assert(maniaComponent.StageDefinition != null); - return new LegacyStageBackground(maniaComponent.StageDefinition.Value); + return new LegacyStageBackground(); case ManiaSkinComponents.StageForeground: return new LegacyStageForeground(); default: - throw new UnsupportedSkinComponentException(component); + throw new UnsupportedSkinComponentException(lookup); } } - return base.GetDrawableComponent(component); + return base.GetDrawableComponent(lookup); } private Drawable getResult(HitResult result) @@ -149,7 +151,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy public override IBindable GetConfig(TLookup lookup) { if (lookup is ManiaSkinConfigurationLookup maniaLookup) - return base.GetConfig(new LegacyManiaSkinConfigurationLookup(beatmap.TotalColumns, maniaLookup.Lookup, maniaLookup.TargetColumn)); + { + return base.GetConfig(new LegacyManiaSkinConfigurationLookup(beatmap.TotalColumns, maniaLookup.Lookup, maniaLookup.ColumnIndex)); + } return base.GetConfig(lookup); } diff --git a/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigExtensions.cs b/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigExtensions.cs index 2e17a6bef1..0f15bfe12b 100644 --- a/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigExtensions.cs +++ b/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigExtensions.cs @@ -13,9 +13,9 @@ namespace osu.Game.Rulesets.Mania.Skinning /// /// The skin from which configuration is retrieved. /// The value to retrieve. - /// If not null, denotes the index of the column to which the entry applies. - public static IBindable GetManiaSkinConfig(this ISkin skin, LegacyManiaSkinConfigurationLookups lookup, int? index = null) - => skin.GetConfig( - new ManiaSkinConfigurationLookup(lookup, index)); + /// If not null, denotes the index of the column to which the entry applies. + public static IBindable? GetManiaSkinConfig(this ISkin skin, LegacyManiaSkinConfigurationLookups lookup, int? columnIndex = null) + where T : notnull + => skin.GetConfig(new ManiaSkinConfigurationLookup(lookup, columnIndex)); } } diff --git a/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigurationLookup.cs b/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigurationLookup.cs index f07a5518b7..6c39ffdcc3 100644 --- a/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigurationLookup.cs +++ b/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigurationLookup.cs @@ -14,20 +14,21 @@ namespace osu.Game.Rulesets.Mania.Skinning public readonly LegacyManiaSkinConfigurationLookups Lookup; /// - /// The intended index for the configuration. + /// The column which is being looked up. /// May be null if the configuration does not apply to a . + /// Note that this is the absolute index across all stages. /// - public readonly int? TargetColumn; + public readonly int? ColumnIndex; /// /// Creates a new . /// /// The lookup value. - /// The intended index for the configuration. May be null if the configuration does not apply to a . - public ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups lookup, int? targetColumn = null) + /// The intended index for the configuration. May be null if the configuration does not apply to a . + public ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups lookup, int? columnIndex = null) { Lookup = lookup; - TargetColumn = targetColumn; + ColumnIndex = columnIndex; } } } diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs index a04f5ef98e..6ca830a82f 100644 --- a/osu.Game.Rulesets.Mania/UI/Column.cs +++ b/osu.Game.Rulesets.Mania/UI/Column.cs @@ -1,30 +1,32 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osuTK.Graphics; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Game.Rulesets.Objects.Drawables; +#nullable disable + 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.Framework.Platform; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Skinning; using osu.Game.Rulesets.Mania.UI.Components; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Skinning; using osuTK; -using osu.Game.Rulesets.Mania.Beatmaps; -using osu.Game.Rulesets.Mania.Objects; -using osu.Game.Rulesets.Mania.Objects.Drawables; -using osu.Game.Rulesets.UI; +using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.UI { [Cached] - public class Column : ScrollingPlayfield, IKeyBindingHandler, IHasAccentColour + public partial class Column : ScrollingPlayfield, IKeyBindingHandler { public const float COLUMN_WIDTH = 80; public const float SPECIAL_COLUMN_WIDTH = 70; @@ -37,23 +39,46 @@ namespace osu.Game.Rulesets.Mania.UI public readonly Bindable Action = new Bindable(); public readonly ColumnHitObjectArea HitObjectArea; - internal readonly Container TopLevelContainer; - private readonly DrawablePool hitExplosionPool; + internal readonly Container TopLevelContainer = new Container { RelativeSizeAxes = Axes.Both }; + private DrawablePool hitExplosionPool; private readonly OrderedHitPolicy hitPolicy; public Container UnderlayElements => HitObjectArea.UnderlayElements; - private readonly GameplaySampleTriggerSource sampleTriggerSource; + private GameplaySampleTriggerSource sampleTriggerSource; - public Column(int index) + /// + /// Whether this is a special (ie. scratch) column. + /// + public readonly bool IsSpecial; + + public readonly Bindable AccentColour = new Bindable(Color4.Black); + + public Column(int index, bool isSpecial) { Index = index; + IsSpecial = isSpecial; RelativeSizeAxes = Axes.Y; Width = COLUMN_WIDTH; - Drawable background = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground), _ => new DefaultColumnBackground()) + hitPolicy = new OrderedHitPolicy(HitObjectContainer); + HitObjectArea = new ColumnHitObjectArea(HitObjectContainer) { RelativeSizeAxes = Axes.Both }; + } + + [Resolved] + private ISkinSource skin { get; set; } + + [BackgroundDependencyLoader] + private void load(GameHost host) + { + SkinnableDrawable keyArea; + + skin.SourceChanged += onSourceChanged; + onSourceChanged(); + + Drawable background = new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.ColumnBackground), _ => new DefaultColumnBackground()) { - RelativeSizeAxes = Axes.Both + RelativeSizeAxes = Axes.Both, }; InternalChildren = new[] @@ -62,17 +87,18 @@ namespace osu.Game.Rulesets.Mania.UI sampleTriggerSource = new GameplaySampleTriggerSource(HitObjectContainer), // For input purposes, the background is added at the highest depth, but is then proxied back below all other elements background.CreateProxy(), - HitObjectArea = new ColumnHitObjectArea(HitObjectContainer) { RelativeSizeAxes = Axes.Both }, - new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea), _ => new DefaultKeyArea()) + HitObjectArea, + keyArea = new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.KeyArea), _ => new DefaultKeyArea()) { - RelativeSizeAxes = Axes.Both + RelativeSizeAxes = Axes.Both, }, background, - TopLevelContainer = new Container { RelativeSizeAxes = Axes.Both }, + TopLevelContainer, new ColumnTouchInputArea(this) }; - hitPolicy = new OrderedHitPolicy(HitObjectContainer); + applyGameWideClock(background); + applyGameWideClock(keyArea); TopLevelContainer.Add(HitObjectArea.Explosions.CreateProxy()); @@ -81,20 +107,41 @@ namespace osu.Game.Rulesets.Mania.UI RegisterPool(10, 50); RegisterPool(10, 50); RegisterPool(50, 250); + + // Some elements don't handle rewind correctly and fixing them is non-trivial. + // In the future we need a better solution to this, but as a temporary work-around, give these components the game-wide + // clock so they don't need to worry about rewind. + // This only works because they handle OnPressed/OnReleased which results in a correct state while rewinding. + // + // This is kinda dodgy (and will cause weirdness when pausing gameplay) but is better than completely broken rewind. + void applyGameWideClock(Drawable drawable) + { + drawable.Clock = host.UpdateThread.Clock; + drawable.ProcessCustomClock = false; + } + } + + private void onSourceChanged() + { + AccentColour.Value = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour, Index)?.Value ?? Color4.Black; } protected override void LoadComplete() { base.LoadComplete(); - NewResult += OnNewResult; } - public ColumnType ColumnType { get; set; } + protected override void Dispose(bool isDisposing) + { + // must happen before children are disposed in base call to prevent illegal accesses to the hit explosion pool. + NewResult -= OnNewResult; - public bool IsSpecial => ColumnType == ColumnType.Special; + base.Dispose(isDisposing); - public Color4 AccentColour { get; set; } + if (skin != null) + skin.SourceChanged -= onSourceChanged; + } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { @@ -109,7 +156,7 @@ namespace osu.Game.Rulesets.Mania.UI DrawableManiaHitObject maniaObject = (DrawableManiaHitObject)drawableHitObject; - maniaObject.AccentColour.Value = AccentColour; + maniaObject.AccentColour.BindTo(AccentColour); maniaObject.CheckHittable = hitPolicy.IsHittable; } @@ -141,7 +188,7 @@ namespace osu.Game.Rulesets.Mania.UI // This probably shouldn't exist as is, but the columns in the stage are separated by a 1px border => DrawRectangle.Inflate(new Vector2(Stage.COLUMN_SPACING / 2, 0)).Contains(ToLocalSpace(screenSpacePos)); - public class ColumnTouchInputArea : Drawable + public partial class ColumnTouchInputArea : Drawable { private readonly Column column; @@ -162,18 +209,6 @@ namespace osu.Game.Rulesets.Mania.UI keyBindingContainer = maniaInputManager?.KeyBindingContainer; } - protected override bool OnMouseDown(MouseDownEvent e) - { - keyBindingContainer?.TriggerPressed(column.Action.Value); - return base.OnMouseDown(e); - } - - protected override void OnMouseUp(MouseUpEvent e) - { - keyBindingContainer?.TriggerReleased(column.Action.Value); - base.OnMouseUp(e); - } - protected override bool OnTouchDown(TouchDownEvent e) { keyBindingContainer?.TriggerPressed(column.Action.Value); diff --git a/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs b/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs index aef82d4c08..0bc0bf4caf 100644 --- a/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs +++ b/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -17,7 +19,7 @@ namespace osu.Game.Rulesets.Mania.UI /// Content can be added to individual columns via . /// /// The type of content in each column. - public class ColumnFlow : CompositeDrawable + public partial class ColumnFlow : CompositeDrawable where TContent : Drawable { /// @@ -34,6 +36,8 @@ namespace osu.Game.Rulesets.Mania.UI AutoSizeAxes = Axes.X; + Masking = true; + InternalChild = columns = new FillFlowContainer { RelativeSizeAxes = Axes.Y, diff --git a/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs b/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs index 51c138f5e1..c93be91a84 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.UI; @@ -9,7 +11,7 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.UI.Components { - public class ColumnHitObjectArea : HitObjectArea + public partial class ColumnHitObjectArea : HitObjectArea { public readonly Container Explosions; @@ -27,7 +29,7 @@ namespace osu.Game.Rulesets.Mania.UI.Components RelativeSizeAxes = Axes.Both, Depth = 2, }, - hitTarget = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitTarget), _ => new DefaultHitTarget()) + hitTarget = new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.HitTarget), _ => new DefaultHitTarget()) { RelativeSizeAxes = Axes.X, Depth = 1 diff --git a/osu.Game.Rulesets.Mania/UI/Components/DefaultColumnBackground.cs b/osu.Game.Rulesets.Mania/UI/Components/DefaultColumnBackground.cs index 807f6a77d9..fe34712c68 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/DefaultColumnBackground.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/DefaultColumnBackground.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -15,7 +17,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.UI.Components { - public class DefaultColumnBackground : CompositeDrawable, IKeyBindingHandler + public partial class DefaultColumnBackground : CompositeDrawable, IKeyBindingHandler { private readonly IBindable direction = new Bindable(); @@ -28,6 +30,8 @@ namespace osu.Game.Rulesets.Mania.UI.Components [Resolved] private Column column { get; set; } + private Bindable accentColour; + public DefaultColumnBackground() { RelativeSizeAxes = Axes.Both; @@ -53,9 +57,13 @@ namespace osu.Game.Rulesets.Mania.UI.Components } }; - background.Colour = column.AccentColour.Darken(5); - brightColour = column.AccentColour.Opacity(0.6f); - dimColour = column.AccentColour.Opacity(0); + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => + { + background.Colour = colour.NewValue.Darken(5); + brightColour = colour.NewValue.Opacity(0.6f); + dimColour = colour.NewValue.Opacity(0); + }, true); direction.BindTo(scrollingInfo.Direction); direction.BindValueChanged(onDirectionChanged, true); diff --git a/osu.Game.Rulesets.Mania/UI/Components/DefaultHitTarget.cs b/osu.Game.Rulesets.Mania/UI/Components/DefaultHitTarget.cs index ec6c377a2e..bfc5fbc8f7 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/DefaultHitTarget.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/DefaultHitTarget.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -14,7 +16,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.UI.Components { - public class DefaultHitTarget : CompositeDrawable + public partial class DefaultHitTarget : CompositeDrawable { private const float hit_target_bar_height = 2; @@ -23,6 +25,8 @@ namespace osu.Game.Rulesets.Mania.UI.Components private Container hitTargetLine; private Drawable hitTargetBar; + private Bindable accentColour; + [Resolved] private Column column { get; set; } @@ -52,12 +56,16 @@ namespace osu.Game.Rulesets.Mania.UI.Components }, }; - hitTargetLine.EdgeEffect = new EdgeEffectParameters + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => { - Type = EdgeEffectType.Glow, - Radius = 5, - Colour = column.AccentColour.Opacity(0.5f), - }; + hitTargetLine.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 5, + Colour = colour.NewValue.Opacity(0.5f), + }; + }, true); direction.BindTo(scrollingInfo.Direction); direction.BindValueChanged(onDirectionChanged, true); diff --git a/osu.Game.Rulesets.Mania/UI/Components/DefaultKeyArea.cs b/osu.Game.Rulesets.Mania/UI/Components/DefaultKeyArea.cs index 15018b464f..77ab9f60c1 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/DefaultKeyArea.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/DefaultKeyArea.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -17,7 +19,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.UI.Components { - public class DefaultKeyArea : CompositeDrawable, IKeyBindingHandler + public partial class DefaultKeyArea : CompositeDrawable, IKeyBindingHandler { private const float key_icon_size = 10; private const float key_icon_corner_radius = 3; @@ -28,6 +30,8 @@ namespace osu.Game.Rulesets.Mania.UI.Components private Container keyIcon; private Drawable gradient; + private Bindable accentColour; + [Resolved] private Column column { get; set; } @@ -73,15 +77,19 @@ namespace osu.Game.Rulesets.Mania.UI.Components } }; - keyIcon.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Radius = 5, - Colour = column.AccentColour.Opacity(0.5f), - }; - direction.BindTo(scrollingInfo.Direction); direction.BindValueChanged(onDirectionChanged, true); + + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => + { + keyIcon.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 5, + Colour = colour.NewValue.Opacity(0.5f), + }; + }, true); } private void onDirectionChanged(ValueChangedEvent direction) diff --git a/osu.Game.Rulesets.Mania/UI/Components/DefaultStageBackground.cs b/osu.Game.Rulesets.Mania/UI/Components/DefaultStageBackground.cs index f5b542d085..ef34fc04ee 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/DefaultStageBackground.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/DefaultStageBackground.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -9,7 +11,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.UI.Components { - public class DefaultStageBackground : CompositeDrawable + public partial class DefaultStageBackground : CompositeDrawable { public DefaultStageBackground() { diff --git a/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.cs b/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.cs index b75b586ecf..41b2dba173 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.UI.Components { - public class HitObjectArea : SkinReloadableDrawable + public partial class HitObjectArea : SkinReloadableDrawable { protected readonly IBindable Direction = new Bindable(); public readonly HitObjectContainer HitObjectContainer; diff --git a/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs b/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs index 562d7b04c4..e0663e9878 100644 --- a/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs +++ b/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -17,7 +19,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.UI { - public class DefaultHitExplosion : CompositeDrawable, IHitExplosion + public partial class DefaultHitExplosion : CompositeDrawable, IHitExplosion { private const float default_large_faint_size = 0.8f; @@ -30,6 +32,10 @@ namespace osu.Game.Rulesets.Mania.UI private CircularContainer largeFaint; private CircularContainer mainGlow1; + private CircularContainer mainGlow2; + private CircularContainer mainGlow3; + + private Bindable accentColour; public DefaultHitExplosion() { @@ -46,8 +52,6 @@ namespace osu.Game.Rulesets.Mania.UI const float roundness = 80; const float initial_height = 10; - var colour = Interpolation.ValueAt(0.4f, column.AccentColour, Color4.White, 0, 1); - InternalChildren = new Drawable[] { largeFaint = new CircularContainer @@ -59,13 +63,6 @@ namespace osu.Game.Rulesets.Mania.UI // we want our size to be very small so the glow dominates it. Size = new Vector2(default_large_faint_size), Blending = BlendingParameters.Additive, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = Interpolation.ValueAt(0.1f, column.AccentColour, Color4.White, 0, 1).Opacity(0.3f), - Roundness = 160, - Radius = 200, - }, }, mainGlow1 = new CircularContainer { @@ -74,15 +71,8 @@ namespace osu.Game.Rulesets.Mania.UI RelativeSizeAxes = Axes.Both, Masking = true, Blending = BlendingParameters.Additive, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = Interpolation.ValueAt(0.6f, column.AccentColour, Color4.White, 0, 1), - Roundness = 20, - Radius = 50, - }, }, - new CircularContainer + mainGlow2 = new CircularContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -91,15 +81,8 @@ namespace osu.Game.Rulesets.Mania.UI Size = new Vector2(0.01f, initial_height), Blending = BlendingParameters.Additive, Rotation = RNG.NextSingle(-angle_variance, angle_variance), - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = colour, - Roundness = roundness, - Radius = 40, - }, }, - new CircularContainer + mainGlow3 = new CircularContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -108,18 +91,44 @@ namespace osu.Game.Rulesets.Mania.UI Size = new Vector2(0.01f, initial_height), Blending = BlendingParameters.Additive, Rotation = RNG.NextSingle(-angle_variance, angle_variance), - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = colour, - Roundness = roundness, - Radius = 40, - }, } }; direction.BindTo(scrollingInfo.Direction); direction.BindValueChanged(onDirectionChanged, true); + + accentColour = column.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(colour => + { + largeFaint.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.1f, colour.NewValue, Color4.White, 0, 1).Opacity(0.3f), + Roundness = 160, + Radius = 200, + }; + mainGlow1.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.6f, colour.NewValue, Color4.White, 0, 1), + Roundness = 20, + Radius = 50, + }; + mainGlow2.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.4f, colour.NewValue, Color4.White, 0, 1), + Roundness = roundness, + Radius = 40, + }; + mainGlow3.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.4f, colour.NewValue, Color4.White, 0, 1), + Roundness = roundness, + Radius = 40, + }; + }, true); } private void onDirectionChanged(ValueChangedEvent direction) diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs index 8581f016b1..896dfb2b23 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; @@ -8,7 +10,7 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.UI { - public class DrawableManiaJudgement : DrawableJudgement + public partial class DrawableManiaJudgement : DrawableJudgement { public DrawableManiaJudgement(JudgementResult result, DrawableHitObject judgedObject) : base(result, judgedObject) @@ -21,7 +23,7 @@ namespace osu.Game.Rulesets.Mania.UI protected override Drawable CreateDefaultJudgement(HitResult result) => new DefaultManiaJudgementPiece(result); - private class DefaultManiaJudgementPiece : DefaultJudgementPiece + private partial class DefaultManiaJudgementPiece : DefaultJudgementPiece { public DefaultManiaJudgementPiece(HitResult result) : base(result) diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs index 28e970f397..af8758fb5e 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -28,7 +30,7 @@ using osu.Game.Scoring; namespace osu.Game.Rulesets.Mania.UI { - public class DrawableManiaRuleset : DrawableScrollingRuleset + public partial class DrawableManiaRuleset : DrawableScrollingRuleset { /// /// The minimum time range. This occurs at a of 40. diff --git a/osu.Game.Rulesets.Mania/UI/IHitExplosion.cs b/osu.Game.Rulesets.Mania/UI/IHitExplosion.cs index 3252dcc276..74ddceeeef 100644 --- a/osu.Game.Rulesets.Mania/UI/IHitExplosion.cs +++ b/osu.Game.Rulesets.Mania/UI/IHitExplosion.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; namespace osu.Game.Rulesets.Mania.UI diff --git a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs index 4cd6624ac6..e3ebadc836 100644 --- a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs +++ b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using System; @@ -17,7 +19,7 @@ using osuTK; namespace osu.Game.Rulesets.Mania.UI { [Cached] - public class ManiaPlayfield : ScrollingPlayfield + public partial class ManiaPlayfield : ScrollingPlayfield { public IReadOnlyList Stages => stages; @@ -27,8 +29,7 @@ namespace osu.Game.Rulesets.Mania.UI public ManiaPlayfield(List stageDefinitions) { - if (stageDefinitions == null) - throw new ArgumentNullException(nameof(stageDefinitions)); + ArgumentNullException.ThrowIfNull(stageDefinitions); if (stageDefinitions.Count <= 0) throw new ArgumentException("Can't have zero or fewer stages."); diff --git a/osu.Game.Rulesets.Mania/UI/ManiaPlayfieldAdjustmentContainer.cs b/osu.Game.Rulesets.Mania/UI/ManiaPlayfieldAdjustmentContainer.cs index 30e0aafb7d..d4621ab8f3 100644 --- a/osu.Game.Rulesets.Mania/UI/ManiaPlayfieldAdjustmentContainer.cs +++ b/osu.Game.Rulesets.Mania/UI/ManiaPlayfieldAdjustmentContainer.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Mania.UI { - public class ManiaPlayfieldAdjustmentContainer : PlayfieldAdjustmentContainer + public partial class ManiaPlayfieldAdjustmentContainer : PlayfieldAdjustmentContainer { public ManiaPlayfieldAdjustmentContainer() { diff --git a/osu.Game.Rulesets.Mania/UI/ManiaReplayRecorder.cs b/osu.Game.Rulesets.Mania/UI/ManiaReplayRecorder.cs index b502d1f9e5..56ac38a737 100644 --- a/osu.Game.Rulesets.Mania/UI/ManiaReplayRecorder.cs +++ b/osu.Game.Rulesets.Mania/UI/ManiaReplayRecorder.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Game.Rulesets.Mania.Replays; using osu.Game.Rulesets.Replays; @@ -10,7 +12,7 @@ using osuTK; namespace osu.Game.Rulesets.Mania.UI { - public class ManiaReplayRecorder : ReplayRecorder + public partial class ManiaReplayRecorder : ReplayRecorder { public ManiaReplayRecorder(Score score) : base(score) diff --git a/osu.Game.Rulesets.Mania/UI/ManiaScrollingDirection.cs b/osu.Game.Rulesets.Mania/UI/ManiaScrollingDirection.cs index 98165fedeb..4a8843c999 100644 --- a/osu.Game.Rulesets.Mania/UI/ManiaScrollingDirection.cs +++ b/osu.Game.Rulesets.Mania/UI/ManiaScrollingDirection.cs @@ -1,13 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; +using osu.Game.Localisation; using osu.Game.Rulesets.UI.Scrolling; namespace osu.Game.Rulesets.Mania.UI { public enum ManiaScrollingDirection { + [LocalisableDescription(typeof(RulesetSettingsStrings), nameof(RulesetSettingsStrings.ScrollingDirectionUp))] Up = ScrollingDirection.Up, + + [LocalisableDescription(typeof(RulesetSettingsStrings), nameof(RulesetSettingsStrings.ScrollingDirectionDown))] Down = ScrollingDirection.Down } } diff --git a/osu.Game.Rulesets.Mania/UI/OrderedHitPolicy.cs b/osu.Game.Rulesets.Mania/UI/OrderedHitPolicy.cs index 961858b62b..c39e21bace 100644 --- a/osu.Game.Rulesets.Mania/UI/OrderedHitPolicy.cs +++ b/osu.Game.Rulesets.Mania/UI/OrderedHitPolicy.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Rulesets.Mania.Objects.Drawables; diff --git a/osu.Game.Rulesets.Mania/UI/PlayfieldCoveringWrapper.cs b/osu.Game.Rulesets.Mania/UI/PlayfieldCoveringWrapper.cs index 15d216e8c5..46cba01771 100644 --- a/osu.Game.Rulesets.Mania/UI/PlayfieldCoveringWrapper.cs +++ b/osu.Game.Rulesets.Mania/UI/PlayfieldCoveringWrapper.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -17,7 +19,7 @@ namespace osu.Game.Rulesets.Mania.UI /// /// A that has its contents partially hidden by an adjustable "cover". This is intended to be used in a playfield. /// - public class PlayfieldCoveringWrapper : CompositeDrawable + public partial class PlayfieldCoveringWrapper : CompositeDrawable { /// /// The complete cover, including gradient and fill. diff --git a/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs b/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs index 9f4963b022..4f6a84c60b 100644 --- a/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs +++ b/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Pooling; @@ -9,7 +11,7 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.UI { - public class PoolableHitExplosion : PoolableDrawable + public partial class PoolableHitExplosion : PoolableDrawable { public const double DURATION = 200; @@ -25,7 +27,7 @@ namespace osu.Game.Rulesets.Mania.UI [BackgroundDependencyLoader] private void load() { - InternalChild = skinnableExplosion = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitExplosion), _ => new DefaultHitExplosion()) + InternalChild = skinnableExplosion = new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.HitExplosion), _ => new DefaultHitExplosion()) { RelativeSizeAxes = Axes.Both }; @@ -40,6 +42,8 @@ namespace osu.Game.Rulesets.Mania.UI { base.PrepareForUse(); + LifetimeStart = Time.Current; + (skinnableExplosion?.Drawable as IHitExplosion)?.Animate(Result); this.Delay(DURATION).Then().Expire(); diff --git a/osu.Game.Rulesets.Mania/UI/Stage.cs b/osu.Game.Rulesets.Mania/UI/Stage.cs index 94910bb410..c1d3e85bf1 100644 --- a/osu.Game.Rulesets.Mania/UI/Stage.cs +++ b/osu.Game.Rulesets.Mania/UI/Stage.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; @@ -10,6 +13,7 @@ using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Skinning; using osu.Game.Rulesets.Mania.UI.Components; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -17,15 +21,17 @@ using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Skinning; using osuTK; -using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.UI { /// /// A collection of s. /// - public class Stage : ScrollingPlayfield + public partial class Stage : ScrollingPlayfield { + [Cached] + public readonly StageDefinition Definition; + public const float COLUMN_SPACING = 1; public const float HIT_TARGET_POSITION = 110; @@ -38,13 +44,6 @@ namespace osu.Game.Rulesets.Mania.UI private readonly Drawable barLineContainer; - private readonly Dictionary columnColours = new Dictionary - { - { ColumnType.Even, new Color4(6, 84, 0, 255) }, - { ColumnType.Odd, new Color4(94, 0, 57, 255) }, - { ColumnType.Special, new Color4(0, 48, 63, 255) } - }; - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Columns.Any(c => c.ReceivePositionalInputAt(screenSpacePos)); private readonly int firstColumnIndex; @@ -52,6 +51,7 @@ namespace osu.Game.Rulesets.Mania.UI public Stage(int firstColumnIndex, StageDefinition definition, ref ManiaAction normalColumnStartAction, ref ManiaAction specialColumnStartAction) { this.firstColumnIndex = firstColumnIndex; + Definition = definition; Name = "Stage"; @@ -73,7 +73,7 @@ namespace osu.Game.Rulesets.Mania.UI AutoSizeAxes = Axes.X, Children = new Drawable[] { - new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageBackground, stageDefinition: definition), _ => new DefaultStageBackground()) + new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.StageBackground), _ => new DefaultStageBackground()) { RelativeSizeAxes = Axes.Both }, @@ -98,7 +98,7 @@ namespace osu.Game.Rulesets.Mania.UI RelativeSizeAxes = Axes.Y, } }, - new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageForeground, stageDefinition: definition), _ => null) + new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.StageForeground), _ => null) { RelativeSizeAxes = Axes.Both }, @@ -116,15 +116,13 @@ namespace osu.Game.Rulesets.Mania.UI for (int i = 0; i < definition.Columns; i++) { - var columnType = definition.GetTypeOfColumn(i); + bool isSpecial = definition.IsSpecialColumn(i); - var column = new Column(firstColumnIndex + i) + var column = new Column(firstColumnIndex + i, isSpecial) { RelativeSizeAxes = Axes.Both, Width = 1, - ColumnType = columnType, - AccentColour = columnColours[columnType], - Action = { Value = columnType == ColumnType.Special ? specialColumnStartAction++ : normalColumnStartAction++ } + Action = { Value = isSpecial ? specialColumnStartAction++ : normalColumnStartAction++ } }; topLevelContainer.Add(column.TopLevelContainer.CreateProxy()); @@ -133,6 +131,40 @@ namespace osu.Game.Rulesets.Mania.UI } } + private ISkinSource currentSkin; + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + currentSkin = skin; + + skin.SourceChanged += onSkinChanged; + onSkinChanged(); + } + + private void onSkinChanged() + { + float paddingTop = currentSkin.GetConfig(new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.StagePaddingTop))?.Value ?? 0; + float paddingBottom = currentSkin.GetConfig(new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.StagePaddingBottom))?.Value ?? 0; + + Padding = new MarginPadding + { + Top = paddingTop, + Bottom = paddingBottom, + }; + } + + protected override void Dispose(bool isDisposing) + { + // must happen before children are disposed in base call to prevent illegal accesses to the judgement pool. + NewResult -= OnNewResult; + + base.Dispose(isDisposing); + + if (currentSkin != null) + currentSkin.SourceChanged -= onSkinChanged; + } + protected override void LoadComplete() { base.LoadComplete(); diff --git a/osu.Game.Rulesets.Mania/VariantMappingGenerator.cs b/osu.Game.Rulesets.Mania/VariantMappingGenerator.cs index 878d1088a6..2742ee087b 100644 --- a/osu.Game.Rulesets.Mania/VariantMappingGenerator.cs +++ b/osu.Game.Rulesets.Mania/VariantMappingGenerator.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Input.Bindings; diff --git a/osu.Game.Rulesets.Mania/osu.Game.Rulesets.Mania.csproj b/osu.Game.Rulesets.Mania/osu.Game.Rulesets.Mania.csproj index 4f6840f9ca..72f172188e 100644 --- a/osu.Game.Rulesets.Mania/osu.Game.Rulesets.Mania.csproj +++ b/osu.Game.Rulesets.Mania/osu.Game.Rulesets.Mania.csproj @@ -1,6 +1,6 @@  - netstandard2.1 + net6.0 Library true smash the keys. to the beat. @@ -15,4 +15,4 @@ - \ No newline at end of file + diff --git a/osu.Game.Rulesets.Osu.Tests.Android/Properties/AndroidManifest.xml b/osu.Game.Rulesets.Osu.Tests.Android/AndroidManifest.xml similarity index 95% rename from osu.Game.Rulesets.Osu.Tests.Android/Properties/AndroidManifest.xml rename to osu.Game.Rulesets.Osu.Tests.Android/AndroidManifest.xml index 3ce17ccc27..45d27dda70 100644 --- a/osu.Game.Rulesets.Osu.Tests.Android/Properties/AndroidManifest.xml +++ b/osu.Game.Rulesets.Osu.Tests.Android/AndroidManifest.xml @@ -1,5 +1,5 @@  - + \ No newline at end of file diff --git a/osu.Game.Rulesets.Osu.Tests.Android/MainActivity.cs b/osu.Game.Rulesets.Osu.Tests.Android/MainActivity.cs index 46c60f06a5..9b4226d5b6 100644 --- a/osu.Game.Rulesets.Osu.Tests.Android/MainActivity.cs +++ b/osu.Game.Rulesets.Osu.Tests.Android/MainActivity.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Android.App; using osu.Framework.Android; using osu.Game.Tests; diff --git a/osu.Game.Rulesets.Osu.Tests.Android/osu.Game.Rulesets.Osu.Tests.Android.csproj b/osu.Game.Rulesets.Osu.Tests.Android/osu.Game.Rulesets.Osu.Tests.Android.csproj index f4b673f10b..e8a46a9828 100644 --- a/osu.Game.Rulesets.Osu.Tests.Android/osu.Game.Rulesets.Osu.Tests.Android.csproj +++ b/osu.Game.Rulesets.Osu.Tests.Android/osu.Game.Rulesets.Osu.Tests.Android.csproj @@ -1,49 +1,27 @@ - - + - Debug - AnyCPU - 8.0.30703 - 2.0 - {90CAB706-39CB-4B93-9629-3218A6FF8E9B} - {EFBA0AD7-5A72-4C68-AF49-83D382785DCF};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - {122416d6-6b49-4ee2-a1e8-b825f31c79fe} + net6.0-android + Exe osu.Game.Rulesets.Osu.Tests osu.Game.Rulesets.Osu.Tests.Android - Properties\AndroidManifest.xml - armeabi-v7a;x86;arm64-v8a - - - None - cjk;mideast;other;rare;west - true - - - - - - - + %(RecursiveDir)%(Filename)%(Extension) + + + %(RecursiveDir)%(Filename)%(Extension) + Android\%(RecursiveDir)%(Filename)%(Extension) + - - {c92a607b-1fdd-4954-9f92-03ff547d9080} - osu.Game.Rulesets.Osu - - - {2a66dd92-adb1-4994-89e2-c94e04acda0d} - osu.Game - + + - - 5.0.0 - + - \ No newline at end of file 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 01e635f09c..0000000000 --- a/osu.Game.Rulesets.Osu.Tests.iOS/AppDelegate.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using 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 be38a883d7..f9059014a5 100644 --- a/osu.Game.Rulesets.Osu.Tests.iOS/Application.cs +++ b/osu.Game.Rulesets.Osu.Tests.iOS/Application.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.iOS; -using UIKit; +using osu.Game.Tests; namespace osu.Game.Rulesets.Osu.Tests.iOS { @@ -10,7 +10,7 @@ namespace osu.Game.Rulesets.Osu.Tests.iOS { public static void Main(string[] args) { - UIApplication.Main(args, typeof(GameUIApplication), typeof(AppDelegate)); + GameApplication.Main(new OsuTestBrowser()); } } } diff --git a/osu.Game.Rulesets.Osu.Tests.iOS/Info.plist b/osu.Game.Rulesets.Osu.Tests.iOS/Info.plist index a88b74695c..1e33f2ff16 100644 --- a/osu.Game.Rulesets.Osu.Tests.iOS/Info.plist +++ b/osu.Game.Rulesets.Osu.Tests.iOS/Info.plist @@ -13,7 +13,7 @@ LSRequiresIPhoneOS MinimumOSVersion - 10.0 + 13.4 UIDeviceFamily 1 diff --git a/osu.Game.Rulesets.Osu.Tests.iOS/osu.Game.Rulesets.Osu.Tests.iOS.csproj b/osu.Game.Rulesets.Osu.Tests.iOS/osu.Game.Rulesets.Osu.Tests.iOS.csproj index 545abcec6c..7d50deb8ba 100644 --- a/osu.Game.Rulesets.Osu.Tests.iOS/osu.Game.Rulesets.Osu.Tests.iOS.csproj +++ b/osu.Game.Rulesets.Osu.Tests.iOS/osu.Game.Rulesets.Osu.Tests.iOS.csproj @@ -1,35 +1,20 @@ - - + - Debug - iPhoneSimulator - {6653CA6F-DB06-4604-A3FD-762E25C2AF96} + Exe + net6.0-ios + 13.4 Exe osu.Game.Rulesets.Osu.Tests osu.Game.Rulesets.Osu.Tests.iOS - - - - Linker.xml - - - %(RecursiveDir)%(Filename)%(Extension) - - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D} - osu.Game - - - {C92A607B-1FDD-4954-9F92-03FF547D9080} - osu.Game.Rulesets.Osu - + + - - \ No newline at end of file + diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckLowDiffOverlapsTest.cs b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckLowDiffOverlapsTest.cs index fd17d11d10..d035d2bc17 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckLowDiffOverlapsTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckLowDiffOverlapsTest.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks [TestFixture] public class CheckLowDiffOverlapsTest { - private CheckLowDiffOverlaps check; + private CheckLowDiffOverlaps check = null!; [SetUp] public void Setup() diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckOffscreenObjectsTest.cs b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckOffscreenObjectsTest.cs index a6873c6de9..a72aaa966c 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckOffscreenObjectsTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckOffscreenObjectsTest.cs @@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks { private static readonly Vector2 playfield_centre = OsuPlayfield.BASE_SIZE * 0.5f; - private CheckOffscreenObjects check; + private CheckOffscreenObjects check = null!; [SetUp] public void Setup() diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTimeDistanceEqualityTest.cs b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTimeDistanceEqualityTest.cs index 49a6fd12fa..348243326d 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTimeDistanceEqualityTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTimeDistanceEqualityTest.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks [TestFixture] public class CheckTimeDistanceEqualityTest { - private CheckTimeDistanceEquality check; + private CheckTimeDistanceEquality check = null!; [SetUp] public void Setup() diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSlidersTest.cs b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSlidersTest.cs index 2eab5a4ce6..2ec3637bb7 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSlidersTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSlidersTest.cs @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks [TestFixture] public class CheckTooShortSlidersTest { - private CheckTooShortSliders check; + private CheckTooShortSliders check = null!; [SetUp] public void Setup() diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSpinnersTest.cs b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSpinnersTest.cs index 1f3d4297f1..f215255bab 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSpinnersTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSpinnersTest.cs @@ -17,8 +17,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks [TestFixture] public class CheckTooShortSpinnersTest { - private CheckTooShortSpinners check; - private IBeatmapDifficultyInfo difficulty; + private CheckTooShortSpinners check = null!; + private IBeatmapDifficultyInfo difficulty = null!; [SetUp] public void Setup() diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCirclePlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCirclePlacementBlueprint.cs index 7bccec6c97..587bd2de44 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCirclePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCirclePlacementBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -11,7 +13,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Osu.Tests.Editor { - public class TestSceneHitCirclePlacementBlueprint : PlacementBlueprintTestScene + public partial class TestSceneHitCirclePlacementBlueprint : PlacementBlueprintTestScene { protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableHitCircle((HitCircle)hitObject); protected override PlacementBlueprint CreateBlueprint() => new HitCirclePlacementBlueprint(); diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCircleSelectionBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCircleSelectionBlueprint.cs index 315493318d..9ffbd25a40 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCircleSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCircleSelectionBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -13,7 +15,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests.Editor { - public class TestSceneHitCircleSelectionBlueprint : SelectionBlueprintTestScene + public partial class TestSceneHitCircleSelectionBlueprint : SelectionBlueprintTestScene { private HitCircle hitCircle; private DrawableHitCircle drawableObject; @@ -59,7 +61,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor AddAssert("blueprint positioned over hitobject", () => blueprint.CirclePiece.Position == hitCircle.StackedPosition); } - private class TestBlueprint : HitCircleSelectionBlueprint + private partial class TestBlueprint : HitCircleSelectionBlueprint { public new HitCirclePiece CirclePiece => base.CirclePiece; diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectBeatSnap.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectBeatSnap.cs index a652fb32f4..56894242c8 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectBeatSnap.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectBeatSnap.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Testing; @@ -12,7 +14,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Osu.Tests.Editor { [TestFixture] - public class TestSceneObjectBeatSnap : TestSceneOsuEditor + public partial class TestSceneObjectBeatSnap : TestSceneOsuEditor { private OsuPlayfield playfield; diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs new file mode 100644 index 0000000000..e7ac38c20e --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs @@ -0,0 +1,299 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Edit; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + public partial class TestSceneObjectMerging : TestSceneOsuEditor + { + private OsuSelectionHandler selectionHandler => Editor.ChildrenOfType().First(); + + [Test] + public void TestSimpleMerge() + { + HitCircle? circle1 = null; + HitCircle? circle2 = null; + + AddStep("select first two circles", () => + { + circle1 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle); + circle2 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle && h != circle1); + EditorClock.Seek(circle1.StartTime); + EditorBeatmap.SelectedHitObjects.Add(circle1); + EditorBeatmap.SelectedHitObjects.Add(circle2); + }); + + moveMouseToHitObject(1); + AddAssert("merge option available", () => selectionHandler.ContextMenuItems.Any(o => o.Text.Value == "Merge selection")); + + mergeSelection(); + + AddAssert("slider created", () => circle1 is not null && circle2 is not null && sliderCreatedFor( + (pos: circle1.Position, pathType: PathType.Linear), + (pos: circle2.Position, pathType: null))); + + AddStep("undo", () => Editor.Undo()); + AddAssert("merged objects restored", () => circle1 is not null && circle2 is not null && objectsRestored(circle1, circle2)); + } + + [Test] + public void TestMergeCircleSlider() + { + HitCircle? circle1 = null; + Slider? slider = null; + HitCircle? circle2 = null; + + AddStep("select a circle, slider, circle", () => + { + circle1 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle); + slider = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider && h.StartTime > circle1.StartTime); + circle2 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle && h.StartTime > slider.StartTime); + EditorClock.Seek(circle1.StartTime); + EditorBeatmap.SelectedHitObjects.Add(circle1); + EditorBeatmap.SelectedHitObjects.Add(slider); + EditorBeatmap.SelectedHitObjects.Add(circle2); + }); + + mergeSelection(); + + AddAssert("slider created", () => + { + if (circle1 is null || circle2 is null || slider is null) + return false; + + var controlPoints = slider.Path.ControlPoints; + (Vector2, PathType?)[] args = new (Vector2, PathType?)[controlPoints.Count + 2]; + args[0] = (circle1.Position, PathType.Linear); + + for (int i = 0; i < controlPoints.Count; i++) + { + args[i + 1] = (controlPoints[i].Position + slider.Position, i == controlPoints.Count - 1 ? PathType.Linear : controlPoints[i].Type); + } + + args[^1] = (circle2.Position, null); + return sliderCreatedFor(args); + }); + + AddAssert("samples exist", sliderSampleExist); + + AddStep("undo", () => Editor.Undo()); + AddAssert("merged objects restored", () => circle1 is not null && circle2 is not null && slider is not null && objectsRestored(circle1, slider, circle2)); + } + + [Test] + public void TestMergeSliderSlider() + { + Slider? slider1 = null; + SliderPath? slider1Path = null; + Slider? slider2 = null; + + AddStep("select two sliders", () => + { + slider1 = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider); + slider1Path = new SliderPath(slider1.Path.ControlPoints.Select(p => new PathControlPoint(p.Position, p.Type)).ToArray(), slider1.Path.ExpectedDistance.Value); + slider2 = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider && h.StartTime > slider1.StartTime); + EditorClock.Seek(slider1.StartTime); + EditorBeatmap.SelectedHitObjects.Add(slider1); + EditorBeatmap.SelectedHitObjects.Add(slider2); + }); + + mergeSelection(); + + AddAssert("slider created", () => + { + if (slider1 is null || slider2 is null || slider1Path is null) + return false; + + var controlPoints1 = slider1Path.ControlPoints; + var controlPoints2 = slider2.Path.ControlPoints; + (Vector2, PathType?)[] args = new (Vector2, PathType?)[controlPoints1.Count + controlPoints2.Count - 1]; + + for (int i = 0; i < controlPoints1.Count - 1; i++) + { + args[i] = (controlPoints1[i].Position + slider1.Position, controlPoints1[i].Type); + } + + for (int i = 0; i < controlPoints2.Count; i++) + { + args[i + controlPoints1.Count - 1] = (controlPoints2[i].Position + controlPoints1[^1].Position + slider1.Position, controlPoints2[i].Type); + } + + return sliderCreatedFor(args); + }); + + AddAssert("samples exist", sliderSampleExist); + + AddAssert("merged slider matches first slider", () => + { + 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); + }); + + AddAssert("slider end is at same completion for last slider", () => + { + if (slider1Path is null || slider2 is null) + return false; + + var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First(); + return Precision.AlmostEquals(mergedSlider.Path.Distance, slider1Path.CalculatedDistance + slider2.Path.Distance); + }); + } + + [Test] + public void TestNonMerge() + { + HitCircle? circle1 = null; + HitCircle? circle2 = null; + Spinner? spinner = null; + + AddStep("select first two circles and spinner", () => + { + circle1 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle); + circle2 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle && h != circle1); + spinner = (Spinner)EditorBeatmap.HitObjects.First(h => h is Spinner); + EditorClock.Seek(spinner.StartTime); + EditorBeatmap.SelectedHitObjects.Add(circle1); + EditorBeatmap.SelectedHitObjects.Add(circle2); + EditorBeatmap.SelectedHitObjects.Add(spinner); + }); + + mergeSelection(); + + AddAssert("slider created", () => circle1 is not null && circle2 is not null && sliderCreatedFor( + (pos: circle1.Position, pathType: PathType.Linear), + (pos: circle2.Position, pathType: null))); + + AddAssert("samples exist", sliderSampleExist); + + AddAssert("spinner not merged", () => EditorBeatmap.HitObjects.Contains(spinner)); + } + + [Test] + public void TestIllegalMerge() + { + HitCircle? circle1 = null; + HitCircle? circle2 = null; + + AddStep("add two circles on the same position", () => + { + circle1 = new HitCircle(); + circle2 = new HitCircle { Position = circle1.Position + Vector2.UnitX }; + EditorClock.Seek(0); + EditorBeatmap.Add(circle1); + EditorBeatmap.Add(circle2); + EditorBeatmap.SelectedHitObjects.Add(circle1); + EditorBeatmap.SelectedHitObjects.Add(circle2); + }); + + moveMouseToHitObject(1); + AddAssert("merge option not available", () => selectionHandler.ContextMenuItems.Length > 0 && selectionHandler.ContextMenuItems.All(o => o.Text.Value != "Merge selection")); + mergeSelection(); + AddAssert("circles not merged", () => circle1 is not null && circle2 is not null + && EditorBeatmap.HitObjects.Contains(circle1) && EditorBeatmap.HitObjects.Contains(circle2)); + } + + [Test] + public void TestSameStartTimeMerge() + { + HitCircle? circle1 = null; + HitCircle? circle2 = null; + + AddStep("add two circles at the same time", () => + { + circle1 = new HitCircle(); + circle2 = new HitCircle { Position = circle1.Position + 100 * Vector2.UnitX }; + EditorClock.Seek(0); + EditorBeatmap.Add(circle1); + EditorBeatmap.Add(circle2); + EditorBeatmap.SelectedHitObjects.Add(circle1); + EditorBeatmap.SelectedHitObjects.Add(circle2); + }); + + moveMouseToHitObject(1); + AddAssert("merge option available", () => selectionHandler.ContextMenuItems.Any(o => o.Text.Value == "Merge selection")); + + mergeSelection(); + + AddAssert("slider created", () => circle1 is not null && circle2 is not null && sliderCreatedFor( + (pos: circle1.Position, pathType: PathType.Linear), + (pos: circle2.Position, pathType: null))); + } + + private void mergeSelection() + { + AddStep("merge selection", () => + { + InputManager.PressKey(Key.LControl); + InputManager.PressKey(Key.LShift); + InputManager.Key(Key.M); + InputManager.ReleaseKey(Key.LShift); + InputManager.ReleaseKey(Key.LControl); + }); + } + + private bool sliderCreatedFor(params (Vector2 pos, PathType? pathType)[] expectedControlPoints) + { + if (EditorBeatmap.SelectedHitObjects.Count != 1) + return false; + + var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First(); + int i = 0; + + foreach ((Vector2 pos, PathType? pathType) in expectedControlPoints) + { + var controlPoint = mergedSlider.Path.ControlPoints[i++]; + + if (!Precision.AlmostEquals(controlPoint.Position + mergedSlider.Position, pos) || controlPoint.Type != pathType) + return false; + } + + return true; + } + + private bool objectsRestored(params HitObject[] objects) + { + foreach (var hitObject in objects) + { + if (EditorBeatmap.HitObjects.Contains(hitObject)) + return false; + } + + return true; + } + + private bool sliderSampleExist() + { + if (EditorBeatmap.SelectedHitObjects.Count != 1) + return false; + + var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First(); + + return mergedSlider.Samples[0] is not null; + } + + private void moveMouseToHitObject(int index) + { + AddStep($"hover mouse over hit object {index}", () => + { + if (EditorBeatmap.HitObjects.Count <= index) + return; + + Vector2 position = ((OsuHitObject)EditorBeatmap.HitObjects[index]).Position; + InputManager.MoveMouseTo(selectionHandler.ToScreenSpace(position)); + }); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectObjectSnap.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectObjectSnap.cs index 7bdf131e0d..3b8a5a90a5 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectObjectSnap.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectObjectSnap.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Testing; @@ -15,7 +17,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Osu.Tests.Editor { [TestFixture] - public class TestSceneObjectObjectSnap : TestSceneOsuEditor + public partial class TestSceneObjectObjectSnap : TestSceneOsuEditor { private OsuPlayfield playfield; diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuComposerSelection.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuComposerSelection.cs new file mode 100644 index 0000000000..8641663ce8 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuComposerSelection.cs @@ -0,0 +1,117 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Tests.Beatmaps; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + [TestFixture] + public partial class TestSceneOsuComposerSelection : TestSceneOsuEditor + { + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + [Test] + public void TestContextMenuShownCorrectlyForSelectedSlider() + { + var slider = new Slider + { + StartTime = 0, + Position = new Vector2(100, 100), + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint(), + new PathControlPoint(new Vector2(100)) + } + } + }; + AddStep("add slider", () => EditorBeatmap.Add(slider)); + + moveMouseToObject(() => slider); + AddStep("left click", () => InputManager.Click(MouseButton.Left)); + AddUntilStep("slider selected", () => EditorBeatmap.SelectedHitObjects.Single() == slider); + + AddStep("move mouse to centre", () => InputManager.MoveMouseTo(blueprintContainer.ChildrenOfType().Single().ScreenSpaceDrawQuad.Centre)); + AddStep("right click", () => InputManager.Click(MouseButton.Right)); + AddUntilStep("context menu is visible", () => contextMenuContainer.ChildrenOfType().Single().State == MenuState.Open); + } + + [Test] + public void TestSelectionIncludingSliderPreservedOnClick() + { + var firstSlider = new Slider + { + StartTime = 0, + Position = new Vector2(0, 0), + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint(), + new PathControlPoint(new Vector2(100)) + } + } + }; + var secondSlider = new Slider + { + StartTime = 1000, + Position = new Vector2(100, 100), + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint(), + new PathControlPoint(new Vector2(100, -100)) + } + } + }; + var hitCircle = new HitCircle + { + StartTime = 200, + Position = new Vector2(300, 0) + }; + + AddStep("add objects", () => EditorBeatmap.AddRange(new HitObject[] { firstSlider, secondSlider, hitCircle })); + AddStep("select last 2 objects", () => EditorBeatmap.SelectedHitObjects.AddRange(new HitObject[] { secondSlider, hitCircle })); + + moveMouseToObject(() => secondSlider); + AddStep("click left mouse", () => InputManager.Click(MouseButton.Left)); + AddAssert("selection preserved", () => EditorBeatmap.SelectedHitObjects.Count == 2); + } + + private ComposeBlueprintContainer blueprintContainer + => Editor.ChildrenOfType().First(); + + private ContextMenuContainer contextMenuContainer + => Editor.ChildrenOfType().First(); + + private void moveMouseToObject(Func targetFunc) + { + AddStep("move mouse to object", () => + { + var pos = blueprintContainer.SelectionBlueprints + .First(s => s.Item == targetFunc()) + .ChildrenOfType() + .First().ScreenSpaceDrawQuad.Centre; + + InputManager.MoveMouseTo(pos); + }); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs index c50aec40a5..7579e8077b 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs @@ -1,15 +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 System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input; +using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Overlays; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Edit; @@ -21,7 +27,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Tests.Editor { - public class TestSceneOsuDistanceSnapGrid : OsuManualInputManagerTestScene + public partial class TestSceneOsuDistanceSnapGrid : OsuManualInputManagerTestScene { private const float beat_length = 100; @@ -31,6 +37,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor [Cached(typeof(IBeatSnapProvider))] private readonly EditorBeatmap editorBeatmap; + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + [Cached] private readonly EditorClock editorClock; @@ -46,6 +55,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor }; private OsuDistanceSnapGrid grid; + private SnappingCursorContainer cursor; public TestSceneOsuDistanceSnapGrid() { @@ -57,10 +67,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor } }); - editorClock = new EditorClock(editorBeatmap); - base.Content.Children = new Drawable[] { + editorClock = new EditorClock(editorBeatmap), snapProvider, Content }; @@ -83,8 +92,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor RelativeSizeAxes = Axes.Both, Colour = Color4.SlateGray }, + cursor = new SnappingCursorContainer { GetSnapPosition = v => grid.GetSnappedPosition(grid.ToLocalSpace(v)).position }, grid = new OsuDistanceSnapGrid(new HitCircle { Position = grid_position }), - new SnappingCursorContainer { GetSnapPosition = v => grid.GetSnappedPosition(grid.ToLocalSpace(v)).position } }; }); @@ -149,6 +158,37 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertSnappedDistance(expectedDistance); } + [Test] + public void TestReferenceObjectNotOnSnapGrid() + { + AddStep("create grid", () => + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.SlateGray + }, + cursor = new SnappingCursorContainer { GetSnapPosition = v => grid.GetSnappedPosition(grid.ToLocalSpace(v)).position }, + grid = new OsuDistanceSnapGrid(new HitCircle + { + Position = grid_position, + // This is important. It sets the reference object to a point in time that isn't on the current snap divisor's grid. + // We are testing that the grid's display is offset correctly. + StartTime = 40, + }), + }; + }); + + AddStep("move mouse to point", () => InputManager.MoveMouseTo(grid.ToScreenSpace(grid_position + new Vector2(beat_length, 0) * 2))); + + AddAssert("Ensure cursor is on a grid line", () => + { + return grid.ChildrenOfType().Any(p => Precision.AlmostEquals(p.ScreenSpaceDrawQuad.TopRight.X, grid.ToScreenSpace(cursor.LastSnappedPosition).X)); + }); + } + [Test] public void TestLimitedDistance() { @@ -161,8 +201,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor RelativeSizeAxes = Axes.Both, Colour = Color4.SlateGray }, + cursor = new SnappingCursorContainer { GetSnapPosition = v => grid.GetSnappedPosition(grid.ToLocalSpace(v)).position }, grid = new OsuDistanceSnapGrid(new HitCircle { Position = grid_position }, new HitCircle { StartTime = 200 }), - new SnappingCursorContainer { GetSnapPosition = v => grid.GetSnappedPosition(grid.ToLocalSpace(v)).position } }; }); @@ -177,10 +217,12 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor return Precision.AlmostEquals(expectedDistance, Vector2.Distance(snappedPosition, grid_position)); }); - private class SnappingCursorContainer : CompositeDrawable + private partial class SnappingCursorContainer : CompositeDrawable { public Func GetSnapPosition; + public Vector2 LastSnappedPosition { get; private set; } + private readonly Drawable cursor; private InputManager inputManager; @@ -209,7 +251,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor protected override void Update() { base.Update(); - cursor.Position = GetSnapPosition.Invoke(inputManager.CurrentState.Mouse.Position); + cursor.Position = LastSnappedPosition = GetSnapPosition.Invoke(inputManager.CurrentState.Mouse.Position); } } } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditor.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditor.cs index e1ca3ddd61..41a099e6e9 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditor.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditor.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Osu.Tests.Editor { [TestFixture] - public class TestSceneOsuEditor : EditorTestScene + public partial class TestSceneOsuEditor : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs index 47b2d3a098..59146bc05e 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorGrids.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Testing; @@ -13,25 +15,54 @@ using osuTK.Input; namespace osu.Game.Rulesets.Osu.Tests.Editor { - public class TestSceneOsuEditorGrids : EditorTestScene + public partial class TestSceneOsuEditorGrids : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); [Test] - public void TestGridExclusivity() + public void TestGridToggles() { AddStep("enable distance snap grid", () => InputManager.Key(Key.T)); AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1))); + AddUntilStep("distance snap grid visible", () => this.ChildrenOfType().Any()); rectangularGridActive(false); AddStep("enable rectangular grid", () => InputManager.Key(Key.Y)); - AddUntilStep("distance snap grid hidden", () => !this.ChildrenOfType().Any()); + + AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1))); + AddUntilStep("distance snap grid still visible", () => this.ChildrenOfType().Any()); rectangularGridActive(true); - AddStep("enable distance snap grid", () => InputManager.Key(Key.T)); + AddStep("disable distance snap grid", () => InputManager.Key(Key.T)); + AddUntilStep("distance snap grid hidden", () => !this.ChildrenOfType().Any()); AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1))); + rectangularGridActive(true); + + AddStep("disable rectangular grid", () => InputManager.Key(Key.Y)); + AddUntilStep("distance snap grid still hidden", () => !this.ChildrenOfType().Any()); + rectangularGridActive(false); + } + + [Test] + public void TestDistanceSnapMomentaryToggle() + { + AddStep("select second object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.ElementAt(1))); + + AddUntilStep("distance snap grid hidden", () => !this.ChildrenOfType().Any()); + AddStep("hold alt", () => InputManager.PressKey(Key.AltLeft)); AddUntilStep("distance snap grid visible", () => this.ChildrenOfType().Any()); + AddStep("release alt", () => InputManager.ReleaseKey(Key.AltLeft)); + AddUntilStep("distance snap grid hidden", () => !this.ChildrenOfType().Any()); + } + + [Test] + public void TestGridSnapMomentaryToggle() + { + rectangularGridActive(false); + AddStep("hold shift", () => InputManager.PressKey(Key.ShiftLeft)); + rectangularGridActive(true); + AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); rectangularGridActive(false); } @@ -48,8 +79,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor AddAssert("placement blueprint at (0, 0)", () => Precision.AlmostEquals(Editor.ChildrenOfType().Single().HitObject.Position, new Vector2(0, 0))); else AddAssert("placement blueprint at (1, 1)", () => Precision.AlmostEquals(Editor.ChildrenOfType().Single().HitObject.Position, new Vector2(1, 1))); - - AddStep("choose selection tool", () => InputManager.Key(Key.Number1)); } [Test] diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorSelectInvalidPath.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorSelectInvalidPath.cs index d0348c1b6b..bb29504ec3 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorSelectInvalidPath.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorSelectInvalidPath.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects; @@ -12,7 +14,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests.Editor { - public class TestSceneOsuEditorSelectInvalidPath : EditorTestScene + public partial class TestSceneOsuEditorSelectInvalidPath : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs index 5a1aa42ed1..37561fda85 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -16,10 +18,10 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests.Editor { - public class TestScenePathControlPointVisualiser : OsuManualInputManagerTestScene + public partial class TestScenePathControlPointVisualiser : OsuManualInputManagerTestScene { private Slider slider; - private PathControlPointVisualiser visualiser; + private PathControlPointVisualiser visualiser; [SetUp] public void Setup() => Schedule(() => @@ -146,7 +148,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertControlPointPathType(3, null); } - private void createVisualiser(bool allowSelection) => AddStep("create visualiser", () => Child = visualiser = new PathControlPointVisualiser(slider, allowSelection) + private void createVisualiser(bool allowSelection) => AddStep("create visualiser", () => Child = visualiser = new PathControlPointVisualiser(slider, allowSelection) { Anchor = Anchor.Centre, Origin = Anchor.Centre @@ -180,7 +182,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor { AddStep($"click context menu item \"{contextMenuText}\"", () => { - MenuItem item = visualiser.ContextMenuItems[1].Items.FirstOrDefault(menuItem => menuItem.Text.Value == contextMenuText); + MenuItem item = visualiser.ContextMenuItems.FirstOrDefault(menuItem => menuItem.Text.Value == "Curve type")?.Items.FirstOrDefault(menuItem => menuItem.Text.Value == contextMenuText); item?.Action?.Value(); }); diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderControlPointPiece.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderControlPointPiece.cs index e7bcd2cadc..db9eea4127 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderControlPointPiece.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderControlPointPiece.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using Humanizer; using NUnit.Framework; @@ -21,7 +23,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Osu.Tests.Editor { - public class TestSceneSliderControlPointPiece : SelectionBlueprintTestScene + public partial class TestSceneSliderControlPointPiece : SelectionBlueprintTestScene { private Slider slider; private DrawableSlider drawableObject; @@ -157,11 +159,11 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor } private void assertSelectionCount(int count) => - AddAssert($"{count} control point pieces selected", () => this.ChildrenOfType().Count(piece => piece.IsSelected.Value) == count); + AddAssert($"{count} control point pieces selected", () => this.ChildrenOfType>().Count(piece => piece.IsSelected.Value) == count); private void assertSelected(int index) => AddAssert($"{(index + 1).ToOrdinalWords()} control point piece selected", - () => this.ChildrenOfType().Single(piece => piece.ControlPoint == slider.Path.ControlPoints[index]).IsSelected.Value); + () => this.ChildrenOfType>().Single(piece => piece.ControlPoint == slider.Path.ControlPoints[index]).IsSelected.Value); private void moveMouseToRelativePosition(Vector2 relativePosition) => AddStep($"move mouse to {relativePosition}", () => @@ -200,12 +202,12 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor moveMouseToControlPoint(2); AddStep("hold left mouse", () => InputManager.PressButton(MouseButton.Left)); - AddAssert("three control point pieces selected", () => this.ChildrenOfType().Count(piece => piece.IsSelected.Value) == 3); + AddAssert("three control point pieces selected", () => this.ChildrenOfType>().Count(piece => piece.IsSelected.Value) == 3); addMovementStep(new Vector2(450, 50)); AddStep("release left mouse", () => InputManager.ReleaseButton(MouseButton.Left)); - AddAssert("three control point pieces selected", () => this.ChildrenOfType().Count(piece => piece.IsSelected.Value) == 3); + AddAssert("three control point pieces selected", () => this.ChildrenOfType>().Count(piece => piece.IsSelected.Value) == 3); assertControlPointPosition(2, new Vector2(450, 50)); assertControlPointType(2, PathType.PerfectCurve); @@ -234,12 +236,12 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor moveMouseToControlPoint(3); AddStep("hold left mouse", () => InputManager.PressButton(MouseButton.Left)); - AddAssert("three control point pieces selected", () => this.ChildrenOfType().Count(piece => piece.IsSelected.Value) == 3); + AddAssert("three control point pieces selected", () => this.ChildrenOfType>().Count(piece => piece.IsSelected.Value) == 3); addMovementStep(new Vector2(550, 50)); AddStep("release left mouse", () => InputManager.ReleaseButton(MouseButton.Left)); - AddAssert("three control point pieces selected", () => this.ChildrenOfType().Count(piece => piece.IsSelected.Value) == 3); + AddAssert("three control point pieces selected", () => this.ChildrenOfType>().Count(piece => piece.IsSelected.Value) == 3); // note: if the head is part of the selection being moved, the entire slider is moved. // the unselected nodes will therefore change position relative to the slider head. @@ -347,12 +349,12 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor private void assertControlPointPosition(int index, Vector2 position) => AddAssert($"control point {index} at {position}", () => Precision.AlmostEquals(position, slider.Path.ControlPoints[index].Position, 1)); - private class TestSliderBlueprint : SliderSelectionBlueprint + private partial class TestSliderBlueprint : SliderSelectionBlueprint { public new SliderBodyPiece BodyPiece => base.BodyPiece; public new TestSliderCircleOverlay HeadOverlay => (TestSliderCircleOverlay)base.HeadOverlay; public new TestSliderCircleOverlay TailOverlay => (TestSliderCircleOverlay)base.TailOverlay; - public new PathControlPointVisualiser ControlPointVisualiser => base.ControlPointVisualiser; + public new PathControlPointVisualiser ControlPointVisualiser => base.ControlPointVisualiser; public TestSliderBlueprint(Slider slider) : base(slider) @@ -362,7 +364,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor protected override SliderCircleOverlay CreateCircleOverlay(Slider slider, SliderPosition position) => new TestSliderCircleOverlay(slider, position); } - private class TestSliderCircleOverlay : SliderCircleOverlay + private partial class TestSliderCircleOverlay : SliderCircleOverlay { public new HitCirclePiece CirclePiece => base.CirclePiece; diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderLengthValidity.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderLengthValidity.cs index ce529f2a88..77e828e80a 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderLengthValidity.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderLengthValidity.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Testing; @@ -17,7 +19,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Osu.Tests.Editor { [TestFixture] - public class TestSceneSliderLengthValidity : TestSceneOsuEditor + public partial class TestSceneSliderLengthValidity : TestSceneOsuEditor { private OsuPlayfield playfield; diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs index e724015905..7542e00a94 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Utils; using osu.Game.Rulesets.Edit; @@ -16,7 +18,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Osu.Tests.Editor { - public class TestSceneSliderPlacementBlueprint : PlacementBlueprintTestScene + public partial class TestSceneSliderPlacementBlueprint : PlacementBlueprintTestScene { [SetUp] public void Setup() => Schedule(() => diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs index cc43eb3852..8ed77d45d7 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Utils; using osu.Game.Beatmaps; @@ -18,7 +20,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Osu.Tests.Editor { - public class TestSceneSliderSelectionBlueprint : SelectionBlueprintTestScene + public partial class TestSceneSliderSelectionBlueprint : SelectionBlueprintTestScene { private Slider slider; private DrawableSlider drawableObject; @@ -192,12 +194,12 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor private void checkControlPointSelected(int index, bool selected) => AddAssert($"control point {index} {(selected ? "selected" : "not selected")}", () => blueprint.ControlPointVisualiser.Pieces[index].IsSelected.Value == selected); - private class TestSliderBlueprint : SliderSelectionBlueprint + private partial class TestSliderBlueprint : SliderSelectionBlueprint { public new SliderBodyPiece BodyPiece => base.BodyPiece; public new TestSliderCircleOverlay HeadOverlay => (TestSliderCircleOverlay)base.HeadOverlay; public new TestSliderCircleOverlay TailOverlay => (TestSliderCircleOverlay)base.TailOverlay; - public new PathControlPointVisualiser ControlPointVisualiser => base.ControlPointVisualiser; + public new PathControlPointVisualiser ControlPointVisualiser => base.ControlPointVisualiser; public TestSliderBlueprint(Slider slider) : base(slider) @@ -207,7 +209,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor protected override SliderCircleOverlay CreateCircleOverlay(Slider slider, SliderPosition position) => new TestSliderCircleOverlay(slider, position); } - private class TestSliderCircleOverlay : SliderCircleOverlay + private partial class TestSliderCircleOverlay : SliderCircleOverlay { public new HitCirclePiece CirclePiece => base.CirclePiece; diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSnapping.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSnapping.cs index b43b2b1461..f262a4334a 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSnapping.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSnapping.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Input.Events; @@ -10,6 +12,7 @@ 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.Rulesets.Osu.Edit; using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; using osu.Game.Rulesets.Osu.Objects; @@ -23,7 +26,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Osu.Tests.Editor { - public class TestSceneSliderSnapping : EditorTestScene + public partial class TestSceneSliderSnapping : EditorTestScene { private const double beat_length = 1000; @@ -53,9 +56,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor { ControlPoints = { - new PathControlPoint(Vector2.Zero), - new PathControlPoint(OsuPlayfield.BASE_SIZE * 2 / 5), - new PathControlPoint(OsuPlayfield.BASE_SIZE * 3 / 5) + new PathControlPoint(Vector2.Zero, PathType.PerfectCurve), + new PathControlPoint(new Vector2(136, 205)), + new PathControlPoint(new Vector2(-4, 226)) } } })); @@ -69,14 +72,14 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor [Test] public void TestMovingUnsnappedSliderNodesSnaps() { - PathControlPointPiece sliderEnd = null; + PathControlPointPiece sliderEnd = null; assertSliderSnapped(false); AddStep("select slider", () => EditorBeatmap.SelectedHitObjects.Add(slider)); AddStep("select slider end", () => { - sliderEnd = this.ChildrenOfType().Single(piece => piece.ControlPoint == slider.Path.ControlPoints.Last()); + sliderEnd = this.ChildrenOfType>().Single(piece => piece.ControlPoint == slider.Path.ControlPoints.Last()); InputManager.MoveMouseTo(sliderEnd.ScreenSpaceDrawQuad.Centre); }); AddStep("move slider end", () => @@ -96,9 +99,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor AddStep("select slider", () => EditorBeatmap.SelectedHitObjects.Add(slider)); AddStep("move mouse to new point location", () => { - var firstPiece = this.ChildrenOfType().Single(piece => piece.ControlPoint == slider.Path.ControlPoints[0]); - var secondPiece = this.ChildrenOfType().Single(piece => piece.ControlPoint == slider.Path.ControlPoints[1]); - InputManager.MoveMouseTo((firstPiece.ScreenSpaceDrawQuad.Centre + secondPiece.ScreenSpaceDrawQuad.Centre) / 2); + var firstPiece = this.ChildrenOfType>().Single(piece => piece.ControlPoint == slider.Path.ControlPoints[0]); + var pos = slider.Path.PositionAt(0.25d) + slider.Position; + InputManager.MoveMouseTo(firstPiece.Parent.ToScreenSpace(pos)); }); AddStep("move slider end", () => { @@ -117,7 +120,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor AddStep("select slider", () => EditorBeatmap.SelectedHitObjects.Add(slider)); AddStep("move mouse to second control point", () => { - var secondPiece = this.ChildrenOfType().Single(piece => piece.ControlPoint == slider.Path.ControlPoints[1]); + var secondPiece = this.ChildrenOfType>().Single(piece => piece.ControlPoint == slider.Path.ControlPoints[1]); InputManager.MoveMouseTo(secondPiece); }); AddStep("quick delete", () => @@ -173,6 +176,23 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertSliderSnapped(false); } + [Test] + public void TestRotatingSliderRetainsPerfectControlPointType() + { + OsuSelectionHandler selectionHandler; + + AddAssert("first control point perfect", () => slider.Path.ControlPoints[0].Type == PathType.PerfectCurve); + + AddStep("select slider", () => EditorBeatmap.SelectedHitObjects.Add(slider)); + AddStep("rotate 90 degrees ccw", () => + { + selectionHandler = this.ChildrenOfType().Single(); + selectionHandler.HandleRotation(-90); + }); + + AddAssert("first control point still perfect", () => slider.Path.ControlPoints[0].Type == PathType.PerfectCurve); + } + [Test] public void TestFlippingSliderDoesNotSnap() { @@ -198,6 +218,23 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertSliderSnapped(false); } + [Test] + public void TestFlippingSliderRetainsPerfectControlPointType() + { + OsuSelectionHandler selectionHandler; + + AddAssert("first control point perfect", () => slider.Path.ControlPoints[0].Type == PathType.PerfectCurve); + + AddStep("select slider", () => EditorBeatmap.SelectedHitObjects.Add(slider)); + AddStep("flip slider horizontally", () => + { + selectionHandler = this.ChildrenOfType().Single(); + selectionHandler.OnPressed(new KeyBindingPressEvent(InputManager.CurrentState, GlobalAction.EditorFlipVertically)); + }); + + AddAssert("first control point still perfect", () => slider.Path.ControlPoints[0].Type == PathType.PerfectCurve); + } + [Test] public void TestReversingSliderDoesNotSnap() { diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs new file mode 100644 index 0000000000..6cb77c7b92 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs @@ -0,0 +1,255 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + public partial class TestSceneSliderSplitting : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + private ComposeBlueprintContainer blueprintContainer + => Editor.ChildrenOfType().First(); + + private Slider? slider; + private PathControlPointVisualiser? visualiser; + + private const double split_gap = 100; + + [Test] + public void TestBasicSplit() + { + double endTime = 0; + + AddStep("add slider", () => + { + slider = new Slider + { + Position = new Vector2(0, 50), + Path = new SliderPath(new[] + { + new PathControlPoint(Vector2.Zero, PathType.PerfectCurve), + new PathControlPoint(new Vector2(150, 150)), + new PathControlPoint(new Vector2(300, 0), PathType.PerfectCurve), + new PathControlPoint(new Vector2(400, 0)), + new PathControlPoint(new Vector2(400, 150)) + }) + }; + + EditorBeatmap.Add(slider); + + endTime = slider.EndTime; + }); + + AddStep("select added slider", () => + { + EditorBeatmap.SelectedHitObjects.Add(slider); + visualiser = blueprintContainer.SelectionBlueprints.First(o => o.Item == slider).ChildrenOfType>().First(); + }); + + moveMouseToControlPoint(2); + AddStep("select control point", () => + { + if (visualiser is not null) visualiser.Pieces[2].IsSelected.Value = true; + }); + addContextMenuItemStep("Split control point"); + + AddAssert("slider split", () => slider is not null && EditorBeatmap.HitObjects.Count == 2 && + sliderCreatedFor((Slider)EditorBeatmap.HitObjects[0], 0, EditorBeatmap.HitObjects[1].StartTime - split_gap, + (new Vector2(0, 50), PathType.PerfectCurve), + (new Vector2(150, 200), null), + (new Vector2(300, 50), null) + ) && sliderCreatedFor((Slider)EditorBeatmap.HitObjects[1], slider.StartTime, endTime + split_gap, + (new Vector2(300, 50), PathType.PerfectCurve), + (new Vector2(400, 50), null), + (new Vector2(400, 200), null) + )); + + AddStep("undo", () => Editor.Undo()); + AddAssert("original slider restored", () => EditorBeatmap.HitObjects.Count == 1 && sliderCreatedFor((Slider)EditorBeatmap.HitObjects[0], 0, endTime, + (new Vector2(0, 50), PathType.PerfectCurve), + (new Vector2(150, 200), null), + (new Vector2(300, 50), PathType.PerfectCurve), + (new Vector2(400, 50), null), + (new Vector2(400, 200), null) + )); + } + + [Test] + public void TestDoubleSplit() + { + double endTime = 0; + + AddStep("add slider", () => + { + slider = new Slider + { + Position = new Vector2(0, 50), + Path = new SliderPath(new[] + { + new PathControlPoint(Vector2.Zero, PathType.PerfectCurve), + new PathControlPoint(new Vector2(150, 150)), + new PathControlPoint(new Vector2(300, 0), PathType.Bezier), + new PathControlPoint(new Vector2(400, 0)), + new PathControlPoint(new Vector2(400, 150), PathType.Catmull), + new PathControlPoint(new Vector2(300, 200)), + new PathControlPoint(new Vector2(400, 250)) + }) + }; + + EditorBeatmap.Add(slider); + + endTime = slider.EndTime; + }); + + AddStep("select added slider", () => + { + EditorBeatmap.SelectedHitObjects.Add(slider); + visualiser = blueprintContainer.SelectionBlueprints.First(o => o.Item == slider).ChildrenOfType>().First(); + }); + + moveMouseToControlPoint(2); + AddStep("select first control point", () => + { + if (visualiser is not null) visualiser.Pieces[2].IsSelected.Value = true; + }); + moveMouseToControlPoint(4); + AddStep("select second control point", () => + { + if (visualiser is not null) visualiser.Pieces[4].IsSelected.Value = true; + }); + addContextMenuItemStep("Split 2 control points"); + + AddAssert("slider split", () => slider is not null && EditorBeatmap.HitObjects.Count == 3 && + sliderCreatedFor((Slider)EditorBeatmap.HitObjects[0], 0, EditorBeatmap.HitObjects[1].StartTime - split_gap, + (new Vector2(0, 50), PathType.PerfectCurve), + (new Vector2(150, 200), null), + (new Vector2(300, 50), null) + ) && sliderCreatedFor((Slider)EditorBeatmap.HitObjects[1], EditorBeatmap.HitObjects[0].GetEndTime() + split_gap, slider.StartTime - split_gap, + (new Vector2(300, 50), PathType.Bezier), + (new Vector2(400, 50), null), + (new Vector2(400, 200), null) + ) && sliderCreatedFor((Slider)EditorBeatmap.HitObjects[2], EditorBeatmap.HitObjects[1].GetEndTime() + split_gap, endTime + split_gap * 2, + (new Vector2(400, 200), PathType.Catmull), + (new Vector2(300, 250), null), + (new Vector2(400, 300), null) + )); + } + + [Test] + public void TestSplitRetainsHitsounds() + { + HitSampleInfo? sample = null; + + AddStep("add slider", () => + { + slider = new Slider + { + Position = new Vector2(0, 50), + LegacyLastTickOffset = 36, // This is necessary for undo to retain the sample control point + Path = new SliderPath(new[] + { + new PathControlPoint(Vector2.Zero, PathType.PerfectCurve), + new PathControlPoint(new Vector2(150, 150)), + new PathControlPoint(new Vector2(300, 0), PathType.PerfectCurve), + new PathControlPoint(new Vector2(400, 0)), + new PathControlPoint(new Vector2(400, 150)) + }) + }; + + EditorBeatmap.Add(slider); + }); + + AddStep("add hitsounds", () => + { + if (slider is null) return; + + slider.SampleControlPoint.SampleBank = "soft"; + slider.SampleControlPoint.SampleVolume = 70; + sample = new HitSampleInfo("hitwhistle"); + slider.Samples.Add(sample); + }); + + AddStep("select added slider", () => + { + EditorBeatmap.SelectedHitObjects.Add(slider); + visualiser = blueprintContainer.SelectionBlueprints.First(o => o.Item == slider).ChildrenOfType>().First(); + }); + + moveMouseToControlPoint(2); + AddStep("select control point", () => + { + if (visualiser is not null) visualiser.Pieces[2].IsSelected.Value = true; + }); + addContextMenuItemStep("Split control point"); + AddAssert("sliders have hitsounds", hasHitsounds); + + AddStep("select first slider", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects[0])); + AddStep("remove first slider", () => EditorBeatmap.RemoveAt(0)); + AddStep("undo", () => Editor.Undo()); + 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)); + } + + private bool sliderCreatedFor(Slider s, double startTime, double endTime, params (Vector2 pos, PathType? pathType)[] expectedControlPoints) + { + if (!Precision.AlmostEquals(s.StartTime, startTime, 1) || !Precision.AlmostEquals(s.EndTime, endTime, 1)) return false; + + int i = 0; + + foreach ((Vector2 pos, PathType? pathType) in expectedControlPoints) + { + var controlPoint = s.Path.ControlPoints[i++]; + + if (!Precision.AlmostEquals(controlPoint.Position + s.Position, pos) || controlPoint.Type != pathType) + return false; + } + + return true; + } + + private void moveMouseToControlPoint(int index) + { + AddStep($"move mouse to control point {index}", () => + { + if (slider is null || visualiser is null) return; + + Vector2 position = slider.Path.ControlPoints[index].Position + slider.Position; + InputManager.MoveMouseTo(visualiser.Pieces[0].Parent.ToScreenSpace(position)); + }); + } + + private void addContextMenuItemStep(string contextMenuText) + { + AddStep($"click context menu item \"{contextMenuText}\"", () => + { + if (visualiser is null) return; + + MenuItem? item = visualiser.ContextMenuItems.FirstOrDefault(menuItem => menuItem.Text.Value == contextMenuText); + + item?.Action?.Value(); + }); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs index 70a9c03e65..53465d43c9 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderStreamConversion.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Utils; @@ -13,7 +15,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Osu.Tests.Editor { - public class TestSceneSliderStreamConversion : TestSceneOsuEditor + public partial class TestSceneSliderStreamConversion : TestSceneOsuEditor { private BindableBeatDivisor beatDivisor => (BindableBeatDivisor)Editor.Dependencies.Get(typeof(BindableBeatDivisor)); @@ -146,6 +148,37 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor }); } + [Test] + public void TestFloatEdgeCaseConversion() + { + Slider slider = null; + + AddStep("select first slider", () => + { + slider = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider); + EditorClock.Seek(slider.StartTime); + EditorBeatmap.SelectedHitObjects.Add(slider); + }); + + AddStep("change to these specific circumstances", () => + { + EditorBeatmap.Difficulty.SliderMultiplier = 1; + var timingPoint = EditorBeatmap.ControlPointInfo.TimingPointAt(slider.StartTime); + timingPoint.BeatLength = 352.941176470588; + slider.Path.ControlPoints[^1].Position = new Vector2(-110, 16); + slider.Path.ExpectedDistance.Value = 100; + }); + + convertToStream(); + + AddAssert("stream created", () => streamCreatedFor(slider, + (time: 0, pathPosition: 0), + (time: 0.25, pathPosition: 0.25), + (time: 0.5, pathPosition: 0.5), + (time: 0.75, pathPosition: 0.75), + (time: 1, pathPosition: 1))); + } + private bool streamCreatedFor(Slider slider, params (double time, double pathPosition)[] expectedCircles) { if (EditorBeatmap.HitObjects.Contains(slider)) diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderVelocityAdjust.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderVelocityAdjust.cs index 4750c97566..bb8c52bdfc 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderVelocityAdjust.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderVelocityAdjust.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Diagnostics; using System.Linq; using NUnit.Framework; @@ -8,7 +10,6 @@ using osu.Framework.Input; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; -using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.UI; using osu.Game.Screens.Edit; @@ -21,7 +22,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Osu.Tests.Editor { - public class TestSceneSliderVelocityAdjust : OsuGameTestScene + public partial class TestSceneSliderVelocityAdjust : OsuGameTestScene { private Screens.Edit.Editor editor => Game.ScreenStack.CurrentScreen as Screens.Edit.Editor; @@ -39,10 +40,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); - private bool editorComponentsReady => editor.ChildrenOfType().FirstOrDefault()?.IsLoaded == true - && editor.ChildrenOfType().FirstOrDefault()?.IsLoaded == true - && editor?.ChildrenOfType().FirstOrDefault()?.IsLoaded == true; - [TestCase(true)] [TestCase(false)] public void TestVelocityChangeSavesCorrectly(bool adjustVelocity) @@ -50,7 +47,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor double? velocity = null; AddStep("enter editor", () => Game.ScreenStack.Push(new EditorLoader())); - AddUntilStep("wait for editor load", () => editorComponentsReady); + AddUntilStep("wait for editor load", () => editor?.ReadyForUse == true); AddStep("seek to first control point", () => editorClock.Seek(editorBeatmap.ControlPointInfo.TimingPoints.First().Time)); AddStep("enter slider placement mode", () => InputManager.Key(Key.Number3)); @@ -89,7 +86,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor AddStep("exit", () => InputManager.Key(Key.Escape)); AddStep("enter editor (again)", () => Game.ScreenStack.Push(new EditorLoader())); - AddUntilStep("wait for editor load", () => editorComponentsReady); + AddUntilStep("wait for editor load", () => editor?.ReadyForUse == true); AddStep("seek to slider", () => editorClock.Seek(slider.StartTime)); AddAssert("slider has correct velocity", () => slider.Velocity == velocity); diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerPlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerPlacementBlueprint.cs index fa6c660b01..6378097800 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerPlacementBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -11,7 +13,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Osu.Tests.Editor { - public class TestSceneSpinnerPlacementBlueprint : PlacementBlueprintTestScene + public partial class TestSceneSpinnerPlacementBlueprint : PlacementBlueprintTestScene { protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableSpinner((Spinner)hitObject); diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerSelectionBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerSelectionBlueprint.cs index 5007841805..c899f58c5a 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerSelectionBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; @@ -13,7 +15,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests.Editor { - public class TestSceneSpinnerSelectionBlueprint : SelectionBlueprintTestScene + public partial class TestSceneSpinnerSelectionBlueprint : SelectionBlueprintTestScene { public TestSceneSpinnerSelectionBlueprint() { diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSliderScaling.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSliderScaling.cs index e29a67c770..64d23090d0 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSliderScaling.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSliderScaling.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Testing; @@ -17,7 +19,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Osu.Tests.Editor { [TestFixture] - public class TestSliderScaling : TestSceneOsuEditor + public partial class TestSliderScaling : TestSceneOsuEditor { private OsuPlayfield playfield; diff --git a/osu.Game.Rulesets.Osu.Tests/LegacyMainCirclePieceTest.cs b/osu.Game.Rulesets.Osu.Tests/LegacyMainCirclePieceTest.cs index d8c10b814d..baaa24959f 100644 --- a/osu.Game.Rulesets.Osu.Tests/LegacyMainCirclePieceTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/LegacyMainCirclePieceTest.cs @@ -1,14 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - using System; using System.Diagnostics; using System.Linq; using Moq; using NUnit.Framework; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Testing; @@ -19,8 +18,11 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Osu.Tests { [HeadlessTest] - public class LegacyMainCirclePieceTest : OsuTestScene + public partial class LegacyMainCirclePieceTest : OsuTestScene { + [Resolved] + private IRenderer renderer { get; set; } = null!; + private static readonly object?[][] texture_priority_cases = { // default priority lookup @@ -78,7 +80,12 @@ namespace osu.Game.Rulesets.Osu.Tests skin.Setup(s => s.GetTexture(It.IsAny())).CallBase(); skin.Setup(s => s.GetTexture(It.IsIn(textureFilenames), It.IsAny(), It.IsAny())) - .Returns((string componentName, WrapMode _, WrapMode __) => new Texture(1, 1) { AssetName = componentName }); + .Returns((string componentName, WrapMode _, WrapMode _) => + { + var tex = renderer.CreateTexture(1, 1); + tex.AssetName = componentName; + return tex; + }); Child = new DependencyProvidingContainer { @@ -86,7 +93,7 @@ namespace osu.Game.Rulesets.Osu.Tests Child = piece = new TestLegacyMainCirclePiece(priorityLookup), }; - var sprites = this.ChildrenOfType().Where(s => s.Texture.AssetName != null).DistinctBy(s => s.Texture.AssetName).ToArray(); + var sprites = this.ChildrenOfType().Where(s => !string.IsNullOrEmpty(s.Texture.AssetName)).DistinctBy(s => s.Texture.AssetName).ToArray(); Debug.Assert(sprites.Length <= 2); }); @@ -94,7 +101,7 @@ namespace osu.Game.Rulesets.Osu.Tests AddAssert("check overlay sprite", () => piece.OverlaySprite?.Texture?.AssetName == expectedOverlay); } - private class TestLegacyMainCirclePiece : LegacyMainCirclePiece + private partial class TestLegacyMainCirclePiece : LegacyMainCirclePiece { public new Sprite? CircleSprite => base.CircleSprite.ChildrenOfType().DistinctBy(s => s.Texture.AssetName).SingleOrDefault(); public new Sprite? OverlaySprite => base.OverlaySprite.ChildrenOfType().DistinctBy(s => s.Texture.AssetName).SingleOrDefault(); diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/OsuModTestScene.cs b/osu.Game.Rulesets.Osu.Tests/Mods/OsuModTestScene.cs index d3cb3bcf59..82d7ac6bba 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/OsuModTestScene.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/OsuModTestScene.cs @@ -5,7 +5,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Osu.Tests.Mods { - public abstract class OsuModTestScene : ModTestScene + public abstract partial class OsuModTestScene : ModTestScene { protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); } diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs index 5e46498aca..88c81c7a39 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAlternate.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Timing; @@ -14,7 +15,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests.Mods { - public class TestSceneOsuModAlternate : OsuModTestScene + public partial class TestSceneOsuModAlternate : OsuModTestScene { [Test] public void TestInputAlternating() => CreateModTest(new ModTestData @@ -125,7 +126,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods /// Ensures alternation is reset before the first hitobject after a break. /// [Test] - public void TestInputSingularWithBreak() => CreateModTest(new ModTestData + public void TestInputSingularWithBreak([Values] bool pressBeforeSecondObject) => CreateModTest(new ModTestData { Mod = new OsuModAlternate(), PassCondition = () => Player.ScoreProcessor.Combo.Value == 0 && Player.ScoreProcessor.HighestCombo.Value == 2, @@ -155,21 +156,26 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods }, } }, - ReplayFrames = new List + ReplayFrames = new ReplayFrame[] { // first press to start alternate lock. - new OsuReplayFrame(500, new Vector2(100), OsuAction.LeftButton), - new OsuReplayFrame(501, new Vector2(100)), - // press same key after break but before hit object. - new OsuReplayFrame(2250, new Vector2(300, 100), OsuAction.LeftButton), - new OsuReplayFrame(2251, new Vector2(300, 100)), + new OsuReplayFrame(450, new Vector2(100), OsuAction.LeftButton), + new OsuReplayFrame(451, new Vector2(100)), // press same key at second hitobject and ensure it has been hit. - new OsuReplayFrame(2500, new Vector2(500, 100), OsuAction.LeftButton), - new OsuReplayFrame(2501, new Vector2(500, 100)), + new OsuReplayFrame(2450, new Vector2(500, 100), OsuAction.LeftButton), + new OsuReplayFrame(2451, new Vector2(500, 100)), // press same key at third hitobject and ensure it has been missed. - new OsuReplayFrame(3000, new Vector2(500, 100), OsuAction.LeftButton), - new OsuReplayFrame(3001, new Vector2(500, 100)), - } + new OsuReplayFrame(2950, new Vector2(500, 100), OsuAction.LeftButton), + new OsuReplayFrame(2951, new Vector2(500, 100)), + }.Concat(!pressBeforeSecondObject + ? Enumerable.Empty() + : new ReplayFrame[] + { + // press same key after break but before hit object. + new OsuReplayFrame(2250, new Vector2(300, 100), OsuAction.LeftButton), + new OsuReplayFrame(2251, new Vector2(300, 100)), + } + ).ToList() }); } } diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutopilot.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutopilot.cs new file mode 100644 index 0000000000..37b31d1d1a --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutopilot.cs @@ -0,0 +1,31 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Osu.Mods; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public partial class TestSceneOsuModAutopilot : OsuModTestScene + { + [Test] + public void TestInstantResume() + { + CreateModTest(new ModTestData + { + Mod = new OsuModAutopilot(), + PassCondition = () => true, + Autoplay = false, + }); + + AddUntilStep("wait for gameplay start", () => Player.LocalUserPlaying.Value); + AddStep("press pause", () => InputManager.PressKey(Key.Escape)); + AddUntilStep("wait until paused", () => Player.GameplayClockContainer.IsPaused.Value); + AddStep("release pause", () => InputManager.ReleaseKey(Key.Escape)); + AddStep("press resume", () => InputManager.PressKey(Key.Escape)); + AddUntilStep("wait for resume", () => !Player.IsResuming); + AddAssert("resumed", () => !Player.GameplayClockContainer.IsPaused.Value); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs index 37f1a846ad..8fdab9f1f9 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs @@ -3,6 +3,7 @@ using System.Linq; using NUnit.Framework; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; @@ -14,7 +15,7 @@ using osu.Game.Rulesets.Osu.UI; namespace osu.Game.Rulesets.Osu.Tests.Mods { - public class TestSceneOsuModAutoplay : OsuModTestScene + public partial class TestSceneOsuModAutoplay : OsuModTestScene { [Test] public void TestSpmUnaffectedByRateAdjust() @@ -33,7 +34,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods private void runSpmTest(Mod mod) { - SpinnerSpmCalculator spmCalculator = null; + SpinnerSpmCalculator? spmCalculator = null; CreateModTest(new ModTestData { @@ -59,7 +60,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods return spmCalculator != null; }); - AddUntilStep("SPM is correct", () => Precision.AlmostEquals(spmCalculator.Result.Value, 477, 5)); + AddUntilStep("SPM is correct", () => Precision.AlmostEquals(spmCalculator.AsNonNull().Result.Value, 477, 5)); } } } diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDifficultyAdjust.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDifficultyAdjust.cs index 9d06ff5801..472c341bdd 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDifficultyAdjust.cs @@ -8,7 +8,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; -using osu.Game.Graphics.Containers; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; @@ -16,7 +15,7 @@ using osu.Game.Rulesets.Osu.Objects.Drawables; namespace osu.Game.Rulesets.Osu.Tests.Mods { - public class TestSceneOsuModDifficultyAdjust : OsuModTestScene + public partial class TestSceneOsuModDifficultyAdjust : OsuModTestScene { [Test] public void TestNoAdjustment() => CreateModTest(new ModTestData @@ -88,7 +87,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods if (!objects.Any()) return false; - return objects.All(o => Precision.AlmostEquals(o.ChildrenOfType().First().Children.OfType().Single().Scale.X, target)); + return objects.All(o => Precision.AlmostEquals(o.ChildrenOfType().First().Scale.X, target)); } private bool checkSomeHit() diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDoubleTime.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDoubleTime.cs index 335ef31019..f895b91151 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDoubleTime.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDoubleTime.cs @@ -7,7 +7,7 @@ using osu.Game.Rulesets.Osu.Mods; namespace osu.Game.Rulesets.Osu.Tests.Mods { - public class TestSceneOsuModDoubleTime : OsuModTestScene + public partial class TestSceneOsuModDoubleTime : OsuModTestScene { [TestCase(0.5)] [TestCase(1.01)] @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods { Mod = mod, PassCondition = () => Player.ScoreProcessor.JudgedHits >= 2 && - Precision.AlmostEquals(Player.GameplayClockContainer.GameplayClock.Rate, mod.SpeedChange.Value) + Precision.AlmostEquals(Player.GameplayClockContainer.Rate, mod.SpeedChange.Value) }); } } diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFlashlight.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFlashlight.cs new file mode 100644 index 0000000000..a353914cd5 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFlashlight.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Osu.Mods; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public partial class TestSceneOsuModFlashlight : OsuModTestScene + { + [TestCase(600)] + [TestCase(120)] + [TestCase(1200)] + public void TestFollowDelay(double followDelay) => CreateModTest(new ModTestData { Mod = new OsuModFlashlight { FollowDelay = { Value = followDelay } }, PassCondition = () => true }); + + [TestCase(1f)] + [TestCase(0.5f)] + [TestCase(1.5f)] + [TestCase(2f)] + public void TestSizeMultiplier(float sizeMultiplier) => CreateModTest(new ModTestData { Mod = new OsuModFlashlight { SizeMultiplier = { Value = sizeMultiplier } }, PassCondition = () => true }); + + [Test] + public void TestComboBasedSize([Values] bool comboBasedSize) => CreateModTest(new ModTestData { Mod = new OsuModFlashlight { ComboBasedSize = { Value = comboBasedSize } }, PassCondition = () => true }); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFreezeFrame.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFreezeFrame.cs new file mode 100644 index 0000000000..57d2b94188 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFreezeFrame.cs @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Osu.Mods; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public partial class TestSceneOsuModFreezeFrame : OsuModTestScene + { + [Test] + public void TestFreezeFrame() + { + CreateModTest(new ModTestData + { + Mod = new OsuModFreezeFrame(), + PassCondition = () => true, + Autoplay = false, + }); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs index 71b575abe2..3f84ac6935 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs @@ -13,7 +13,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests.Mods { - public class TestSceneOsuModHidden : OsuModTestScene + public partial class TestSceneOsuModHidden : OsuModTestScene { [Test] public void TestDefaultBeatmapTest() => CreateModTest(new ModTestData @@ -160,7 +160,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods private class TestOsuModHidden : OsuModHidden { - public new HitObject FirstObject => base.FirstObject; + public new HitObject? FirstObject => base.FirstObject; } } } diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMagnetised.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMagnetised.cs index 9b49e60363..8a5a7706d1 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMagnetised.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMagnetised.cs @@ -6,7 +6,7 @@ using osu.Game.Rulesets.Osu.Mods; namespace osu.Game.Rulesets.Osu.Tests.Mods { - public class TestSceneOsuModMagnetised : OsuModTestScene + public partial class TestSceneOsuModMagnetised : OsuModTestScene { [TestCase(0.1f)] [TestCase(0.5f)] diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMuted.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMuted.cs index e08d66fa31..ac341d73b8 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMuted.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModMuted.cs @@ -9,7 +9,7 @@ using osu.Game.Rulesets.Osu.Mods; namespace osu.Game.Rulesets.Osu.Tests.Mods { - public class TestSceneOsuModMuted : OsuModTestScene + public partial class TestSceneOsuModMuted : OsuModTestScene { /// /// Ensures that a final volume combo of 0 (i.e. "always muted" mode) constantly plays metronome and completely mutes track. @@ -31,7 +31,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods [Test] public void TestModCopy() { - OsuModMuted muted = null; + OsuModMuted muted = null!; AddStep("create inversed mod", () => muted = new OsuModMuted { @@ -45,8 +45,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods InverseMuting = { Value = false }, })); - AddAssert("mute combo count = 0", () => muted.MuteComboCount.Value == 0); - AddAssert("inverse muting = false", () => muted.InverseMuting.Value == false); + AddAssert("mute combo count copied", () => muted.MuteComboCount.Value, () => Is.EqualTo(0)); + AddAssert("inverse muting copied", () => muted.InverseMuting.Value, () => Is.False); } } } diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs index 44404ca245..9dfa76fc8e 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModNoScope.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Utils; using osu.Framework.Testing; using osu.Game.Beatmaps; @@ -12,11 +13,12 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.UI; using osuTK; namespace osu.Game.Rulesets.Osu.Tests.Mods { - public class TestSceneOsuModNoScope : OsuModTestScene + public partial class TestSceneOsuModNoScope : OsuModTestScene { [Test] public void TestVisibleDuringBreak() @@ -145,6 +147,10 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods private bool isBreak() => Player.IsBreakTime.Value; - private bool cursorAlphaAlmostEquals(float alpha) => Precision.AlmostEquals(Player.DrawableRuleset.Cursor.Alpha, alpha, 0.1f); + private OsuPlayfield playfield => (OsuPlayfield)Player.DrawableRuleset.Playfield; + + private bool cursorAlphaAlmostEquals(float alpha) => + Precision.AlmostEquals(playfield.Cursor.AsNonNull().Alpha, alpha, 0.1f) && + Precision.AlmostEquals(playfield.Smoke.Alpha, alpha, 0.1f); } } diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModPerfect.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModPerfect.cs index 985baa8cf5..f0496efc19 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModPerfect.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModPerfect.cs @@ -11,7 +11,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests.Mods { - public class TestSceneOsuModPerfect : ModPerfectTestScene + public partial class TestSceneOsuModPerfect : ModPerfectTestScene { protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRandom.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRandom.cs new file mode 100644 index 0000000000..060a845137 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRandom.cs @@ -0,0 +1,102 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public partial class TestSceneOsuModRandom : OsuModTestScene + { + [TestCase(1)] + [TestCase(7)] + [TestCase(10)] + public void TestDefaultBeatmap(float angleSharpness) => CreateModTest(new ModTestData + { + Mod = new OsuModRandom + { + AngleSharpness = { Value = angleSharpness } + }, + Autoplay = true, + PassCondition = () => true + }); + + [TestCase(1)] + [TestCase(7)] + [TestCase(10)] + public void TestJumpBeatmap(float angleSharpness) => CreateModTest(new ModTestData + { + Mod = new OsuModRandom + { + AngleSharpness = { Value = angleSharpness } + }, + Beatmap = jumpBeatmap, + Autoplay = true, + PassCondition = () => true + }); + + [TestCase(1)] + [TestCase(7)] + [TestCase(10)] + public void TestStreamBeatmap(float angleSharpness) => CreateModTest(new ModTestData + { + Mod = new OsuModRandom + { + AngleSharpness = { Value = angleSharpness } + }, + Beatmap = streamBeatmap, + Autoplay = true, + PassCondition = () => true + }); + + private OsuBeatmap jumpBeatmap => + createHitCircleBeatmap(new[] { 100, 200, 300, 400 }, 8, 300, 2 * 300); + + private OsuBeatmap streamBeatmap => + createHitCircleBeatmap(new[] { 10, 20, 30, 40, 50, 60, 70, 80 }, 16, 150, 4 * 150); + + private OsuBeatmap createHitCircleBeatmap(IEnumerable spacings, int objectsPerSpacing, int interval, int beatLength) + { + var controlPointInfo = new ControlPointInfo(); + controlPointInfo.Add(0, new TimingControlPoint + { + Time = 0, + BeatLength = beatLength + }); + + var beatmap = new OsuBeatmap + { + BeatmapInfo = new BeatmapInfo + { + StackLeniency = 0, + Difficulty = new BeatmapDifficulty + { + ApproachRate = 8.5f + } + }, + ControlPointInfo = controlPointInfo + }; + + foreach (int spacing in spacings) + { + for (int i = 0; i < objectsPerSpacing; i++) + { + beatmap.HitObjects.Add(new HitCircle + { + StartTime = interval * beatmap.HitObjects.Count, + Position = beatmap.HitObjects.Count % 2 == 0 ? Vector2.Zero : new Vector2(spacing, 0), + NewCombo = i == 0 + }); + } + } + + return beatmap; + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRepel.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRepel.cs new file mode 100644 index 0000000000..8a3d645b05 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModRepel.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Osu.Mods; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public partial class TestSceneOsuModRepel : OsuModTestScene + { + [TestCase(0.1f)] + [TestCase(0.5f)] + [TestCase(1)] + public void TestRepel(float strength) + { + CreateModTest(new ModTestData + { + Mod = new OsuModRepel + { + RepulsionStrength = { Value = strength }, + }, + PassCondition = () => true, + Autoplay = false, + }); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs new file mode 100644 index 0000000000..402c680b46 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSingleTap.cs @@ -0,0 +1,175 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Replays; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public partial class TestSceneOsuModSingleTap : OsuModTestScene + { + [Test] + public void TestInputSingular() => CreateModTest(new ModTestData + { + Mod = new OsuModSingleTap(), + PassCondition = () => Player.ScoreProcessor.Combo.Value == 2, + Autoplay = false, + Beatmap = new Beatmap + { + HitObjects = new List + { + new HitCircle + { + StartTime = 500, + Position = new Vector2(100), + }, + new HitCircle + { + StartTime = 1000, + Position = new Vector2(200, 100), + }, + new HitCircle + { + StartTime = 1500, + Position = new Vector2(300, 100), + }, + new HitCircle + { + StartTime = 2000, + Position = new Vector2(400, 100), + }, + }, + }, + ReplayFrames = new List + { + new OsuReplayFrame(500, new Vector2(100), OsuAction.LeftButton), + new OsuReplayFrame(501, new Vector2(100)), + new OsuReplayFrame(1000, new Vector2(200, 100), OsuAction.LeftButton), + } + }); + + [Test] + public void TestInputAlternating() => CreateModTest(new ModTestData + { + Mod = new OsuModSingleTap(), + PassCondition = () => Player.ScoreProcessor.Combo.Value == 0 && Player.ScoreProcessor.HighestCombo.Value == 1, + Autoplay = false, + Beatmap = new Beatmap + { + HitObjects = new List + { + new HitCircle + { + StartTime = 500, + Position = new Vector2(100), + }, + new HitCircle + { + StartTime = 1000, + Position = new Vector2(200, 100), + }, + }, + }, + ReplayFrames = new List + { + new OsuReplayFrame(500, new Vector2(100), OsuAction.LeftButton), + new OsuReplayFrame(501, new Vector2(100)), + new OsuReplayFrame(1000, new Vector2(200, 100), OsuAction.RightButton), + new OsuReplayFrame(1001, new Vector2(200, 100)), + new OsuReplayFrame(1500, new Vector2(300, 100), OsuAction.LeftButton), + new OsuReplayFrame(1501, new Vector2(300, 100)), + new OsuReplayFrame(2000, new Vector2(400, 100), OsuAction.RightButton), + new OsuReplayFrame(2001, new Vector2(400, 100)), + } + }); + + /// + /// Ensures singletapping is reset before the first hitobject after intro. + /// + [Test] + public void TestInputAlternatingAtIntro() => CreateModTest(new ModTestData + { + Mod = new OsuModSingleTap(), + PassCondition = () => Player.ScoreProcessor.Combo.Value == 1, + Autoplay = false, + Beatmap = new Beatmap + { + HitObjects = new List + { + new HitCircle + { + StartTime = 1000, + Position = new Vector2(100), + }, + }, + }, + ReplayFrames = new List + { + // first press during intro. + new OsuReplayFrame(500, new Vector2(200), OsuAction.LeftButton), + new OsuReplayFrame(501, new Vector2(200)), + // press different key at hitobject and ensure it has been hit. + new OsuReplayFrame(1000, new Vector2(100), OsuAction.RightButton), + } + }); + + /// + /// Ensures singletapping is reset before the first hitobject after a break. + /// + [Test] + public void TestInputAlternatingWithBreak() => CreateModTest(new ModTestData + { + Mod = new OsuModSingleTap(), + PassCondition = () => Player.ScoreProcessor.Combo.Value == 0 && Player.ScoreProcessor.HighestCombo.Value == 2, + Autoplay = false, + Beatmap = new Beatmap + { + Breaks = new List + { + new BreakPeriod(500, 2000), + }, + HitObjects = new List + { + new HitCircle + { + StartTime = 500, + Position = new Vector2(100), + }, + new HitCircle + { + StartTime = 2500, + Position = new Vector2(500, 100), + }, + new HitCircle + { + StartTime = 3000, + Position = new Vector2(500, 100), + }, + } + }, + ReplayFrames = new List + { + // first press to start singletap lock. + new OsuReplayFrame(500, new Vector2(100), OsuAction.LeftButton), + new OsuReplayFrame(501, new Vector2(100)), + // press different key after break but before hit object. + new OsuReplayFrame(2250, new Vector2(300, 100), OsuAction.RightButton), + new OsuReplayFrame(2251, new Vector2(300, 100)), + // press same key at second hitobject and ensure it has been hit. + new OsuReplayFrame(2500, new Vector2(500, 100), OsuAction.LeftButton), + new OsuReplayFrame(2501, new Vector2(500, 100)), + // press different key at third hitobject and ensure it has been missed. + new OsuReplayFrame(3000, new Vector2(500, 100), OsuAction.RightButton), + new OsuReplayFrame(3001, new Vector2(500, 100)), + } + }); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSpunOut.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSpunOut.cs index a8953c1a6f..de3ea5f148 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSpunOut.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSpunOut.cs @@ -21,15 +21,15 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests.Mods { - public class TestSceneOsuModSpunOut : OsuModTestScene + public partial class TestSceneOsuModSpunOut : OsuModTestScene { protected override bool AllowFail => true; [Test] public void TestSpinnerAutoCompleted() { - DrawableSpinner spinner = null; - JudgementResult lastResult = null; + DrawableSpinner? spinner = null; + JudgementResult? lastResult = null; CreateModTest(new ModTestData { @@ -50,7 +50,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods lastResult = null; spinner = nextSpinner; - spinner.OnNewResult += (o, result) => lastResult = result; + spinner.OnNewResult += (_, result) => lastResult = result; } return lastResult?.Type == HitResult.Great; @@ -61,11 +61,11 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods [TestCase(null)] [TestCase(typeof(OsuModDoubleTime))] [TestCase(typeof(OsuModHalfTime))] - public void TestSpinRateUnaffectedByMods(Type additionalModType) + public void TestSpinRateUnaffectedByMods(Type? additionalModType) { var mods = new List { new OsuModSpunOut() }; if (additionalModType != null) - mods.Add((Mod)Activator.CreateInstance(additionalModType)); + mods.Add((Mod)Activator.CreateInstance(additionalModType)!); CreateModTest(new ModTestData { @@ -94,7 +94,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods [Test] public void TestSpinnerGetsNoBonusScore() { - DrawableSpinner spinner = null; + DrawableSpinner? spinner = null; List results = new List(); CreateModTest(new ModTestData @@ -114,7 +114,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods return false; spinner = nextSpinner; - spinner.OnNewResult += (o, result) => results.Add(result); + spinner.OnNewResult += (_, result) => results.Add(result); results.Clear(); } diff --git a/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs index 4c11efcc7c..bee46da1ba 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using NUnit.Framework; diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index df577ea8d3..7e995f2dde 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; @@ -15,18 +17,18 @@ namespace osu.Game.Rulesets.Osu.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Osu"; - [TestCase(6.6972307565739273d, 206, "diffcalc-test")] - [TestCase(1.4484754139145539d, 45, "zero-length-sliders")] + [TestCase(6.7115569159190587d, 206, "diffcalc-test")] + [TestCase(1.4391311903612753d, 45, "zero-length-sliders")] public void Test(double expectedStarRating, int expectedMaxCombo, string name) => base.Test(expectedStarRating, expectedMaxCombo, name); - [TestCase(8.9382559208689809d, 206, "diffcalc-test")] - [TestCase(1.7548875851757628d, 45, "zero-length-sliders")] + [TestCase(8.9757300665532966d, 206, "diffcalc-test")] + [TestCase(1.7437232654020756d, 45, "zero-length-sliders")] public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime()); - [TestCase(6.6972307218715166d, 239, "diffcalc-test")] - [TestCase(1.4484754139145537d, 54, "zero-length-sliders")] + [TestCase(6.7115569159190587d, 239, "diffcalc-test")] + [TestCase(1.4391311903612753d, 54, "zero-length-sliders")] public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic()); diff --git a/osu.Game.Rulesets.Osu.Tests/OsuHitObjectGenerationUtilsTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuHitObjectGenerationUtilsTest.cs new file mode 100644 index 0000000000..daa914cac2 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/OsuHitObjectGenerationUtilsTest.cs @@ -0,0 +1,91 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Osu.Utils; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + [TestFixture] + public class OsuHitObjectGenerationUtilsTest + { + private static Slider createTestSlider() + { + var slider = new Slider + { + Position = new Vector2(128, 128), + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint(new Vector2(), PathType.Linear), + new PathControlPoint(new Vector2(-64, -128), PathType.Linear), // absolute position: (64, 0) + new PathControlPoint(new Vector2(-128, 0), PathType.Linear) // absolute position: (0, 128) + } + }, + RepeatCount = 1 + }; + slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + return slider; + } + + [Test] + public void TestReflectSliderHorizontallyAlongPlayfield() + { + var slider = createTestSlider(); + + OsuHitObjectGenerationUtils.ReflectHorizontallyAlongPlayfield(slider); + + Assert.That(slider.Position, Is.EqualTo(new Vector2(OsuPlayfield.BASE_SIZE.X - 128, 128))); + Assert.That(slider.NestedHitObjects.OfType().Single().Position, Is.EqualTo(new Vector2(OsuPlayfield.BASE_SIZE.X - 0, 128))); + Assert.That(slider.Path.ControlPoints.Select(point => point.Position), Is.EquivalentTo(new[] + { + new Vector2(), + new Vector2(64, -128), + new Vector2(128, 0) + })); + } + + [Test] + public void TestReflectSliderVerticallyAlongPlayfield() + { + var slider = createTestSlider(); + + OsuHitObjectGenerationUtils.ReflectVerticallyAlongPlayfield(slider); + + Assert.That(slider.Position, Is.EqualTo(new Vector2(128, OsuPlayfield.BASE_SIZE.Y - 128))); + Assert.That(slider.NestedHitObjects.OfType().Single().Position, Is.EqualTo(new Vector2(0, OsuPlayfield.BASE_SIZE.Y - 128))); + Assert.That(slider.Path.ControlPoints.Select(point => point.Position), Is.EquivalentTo(new[] + { + new Vector2(), + new Vector2(-64, 128), + new Vector2(-128, 0) + })); + } + + [Test] + public void TestFlipSliderInPlaceHorizontally() + { + var slider = createTestSlider(); + + OsuHitObjectGenerationUtils.FlipSliderInPlaceHorizontally(slider); + + Assert.That(slider.Position, Is.EqualTo(new Vector2(128, 128))); + Assert.That(slider.NestedHitObjects.OfType().Single().Position, Is.EqualTo(new Vector2(256, 128))); + Assert.That(slider.Path.ControlPoints.Select(point => point.Position), Is.EquivalentTo(new[] + { + new Vector2(), + new Vector2(64, -128), + new Vector2(128, 0) + })); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/OsuLegacyModConversionTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuLegacyModConversionTest.cs index 51da5b85cd..b4727b3c02 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuLegacyModConversionTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuLegacyModConversionTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Game.Beatmaps.Legacy; @@ -23,24 +25,27 @@ namespace osu.Game.Rulesets.Osu.Tests new object[] { LegacyMods.DoubleTime, new[] { typeof(OsuModDoubleTime) } }, new object[] { LegacyMods.Relax, new[] { typeof(OsuModRelax) } }, new object[] { LegacyMods.HalfTime, new[] { typeof(OsuModHalfTime) } }, - new object[] { LegacyMods.Nightcore, new[] { typeof(OsuModNightcore) } }, new object[] { LegacyMods.Flashlight, new[] { typeof(OsuModFlashlight) } }, new object[] { LegacyMods.Autoplay, new[] { typeof(OsuModAutoplay) } }, new object[] { LegacyMods.SpunOut, new[] { typeof(OsuModSpunOut) } }, new object[] { LegacyMods.Autopilot, new[] { typeof(OsuModAutopilot) } }, - new object[] { LegacyMods.Perfect, new[] { typeof(OsuModPerfect) } }, - new object[] { LegacyMods.Cinema, new[] { typeof(OsuModCinema) } }, - new object[] { LegacyMods.Target, new[] { typeof(OsuModTarget) } }, + new object[] { LegacyMods.Target, new[] { typeof(OsuModTargetPractice) } }, new object[] { LegacyMods.HardRock | LegacyMods.DoubleTime, new[] { typeof(OsuModHardRock), typeof(OsuModDoubleTime) } } }; + [TestCaseSource(nameof(osu_mod_mapping))] + [TestCase(LegacyMods.Cinema, new[] { typeof(OsuModCinema) })] + [TestCase(LegacyMods.Cinema | LegacyMods.Autoplay, new[] { typeof(OsuModCinema) })] + [TestCase(LegacyMods.Nightcore, new[] { typeof(OsuModNightcore) })] + [TestCase(LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(OsuModNightcore) })] + [TestCase(LegacyMods.Perfect, new[] { typeof(OsuModPerfect) })] + [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath, new[] { typeof(OsuModPerfect) })] + public new void TestFromLegacy(LegacyMods legacyMods, Type[] expectedMods) => base.TestFromLegacy(legacyMods, expectedMods); + [TestCaseSource(nameof(osu_mod_mapping))] [TestCase(LegacyMods.Cinema | LegacyMods.Autoplay, new[] { typeof(OsuModCinema) })] [TestCase(LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(OsuModNightcore) })] [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath, new[] { typeof(OsuModPerfect) })] - public new void TestFromLegacy(LegacyMods legacyMods, Type[] expectedMods) => base.TestFromLegacy(legacyMods, expectedMods); - - [TestCaseSource(nameof(osu_mod_mapping))] public new void TestToLegacy(LegacyMods legacyMods, Type[] givenMods) => base.TestToLegacy(legacyMods, givenMods); protected override Ruleset CreateRuleset() => new OsuRuleset(); diff --git a/osu.Game.Rulesets.Osu.Tests/OsuSkinnableTestScene.cs b/osu.Game.Rulesets.Osu.Tests/OsuSkinnableTestScene.cs index 233aaf2ed9..574618c528 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuSkinnableTestScene.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuSkinnableTestScene.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Osu.Tests { - public abstract class OsuSkinnableTestScene : SkinnableTestScene + public abstract partial class OsuSkinnableTestScene : SkinnableTestScene { private Container content; diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/cursor-smoke@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/cursor-smoke@2x.png new file mode 100644 index 0000000000..b1380a47a4 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/cursor-smoke@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursor-smoke.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursor-smoke.png new file mode 100644 index 0000000000..5f7beae4e9 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursor-smoke.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/StackingTest.cs b/osu.Game.Rulesets.Osu.Tests/StackingTest.cs index 871afdb09d..a2ab7b564c 100644 --- a/osu.Game.Rulesets.Osu.Tests/StackingTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/StackingTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.IO; using System.Linq; diff --git a/osu.Game.Rulesets.Osu.Tests/TestPlayfieldBorder.cs b/osu.Game.Rulesets.Osu.Tests/TestPlayfieldBorder.cs index 23d9d265be..5366a86bc0 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestPlayfieldBorder.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestPlayfieldBorder.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,7 +12,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests { - public class TestPlayfieldBorder : OsuTestScene + public partial class TestPlayfieldBorder : OsuTestScene { public TestPlayfieldBorder() { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneAccuracyHeatmap.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneAccuracyHeatmap.cs index 79150a1941..f99518997b 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneAccuracyHeatmap.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneAccuracyHeatmap.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -18,7 +20,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneAccuracyHeatmap : OsuManualInputManagerTestScene + public partial class TestSceneAccuracyHeatmap : OsuManualInputManagerTestScene { private Box background; private Drawable object1; @@ -89,7 +91,7 @@ namespace osu.Game.Rulesets.Osu.Tests return true; } - private class TestAccuracyHeatmap : AccuracyHeatmap + private partial class TestAccuracyHeatmap : AccuracyHeatmap { public TestAccuracyHeatmap(ScoreInfo score) : base(score, new TestBeatmap(new OsuRuleset().RulesetInfo)) @@ -100,7 +102,7 @@ namespace osu.Game.Rulesets.Osu.Tests => base.AddPoint(start, end, hitPoint, radius); } - private class BorderCircle : CircularContainer + private partial class BorderCircle : CircularContainer { public BorderCircle() { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorParticles.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorParticles.cs index bd39dead34..f6e460284b 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorParticles.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorParticles.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -20,7 +22,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneCursorParticles : TestSceneOsuPlayer + public partial class TestSceneCursorParticles : TestSceneOsuPlayer { protected override bool Autoplay => autoplay; protected override bool HasCustomSteps => true; @@ -168,7 +170,7 @@ namespace osu.Game.Rulesets.Osu.Tests }); AddStep("setup default legacy skin", () => { - skinManager.CurrentSkinInfo.Value = skinManager.DefaultLegacySkin.SkinInfo; + skinManager.CurrentSkinInfo.Value = skinManager.DefaultClassicSkin.SkinInfo; }); }); } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs index 211b0e8145..9582ee491b 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using NUnit.Framework; @@ -9,7 +11,7 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Textures; using osu.Framework.Testing.Input; using osu.Game.Audio; @@ -21,8 +23,11 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneCursorTrail : OsuTestScene + public partial class TestSceneCursorTrail : OsuTestScene { + [Resolved] + private IRenderer renderer { get; set; } + [Test] public void TestSmoothCursorTrail() { @@ -42,7 +47,7 @@ namespace osu.Game.Rulesets.Osu.Tests { createTest(() => { - var skinContainer = new LegacySkinContainer(false); + var skinContainer = new LegacySkinContainer(renderer, false); var legacyCursorTrail = new LegacyCursorTrail(skinContainer); skinContainer.Child = legacyCursorTrail; @@ -56,7 +61,7 @@ namespace osu.Game.Rulesets.Osu.Tests { createTest(() => { - var skinContainer = new LegacySkinContainer(true); + var skinContainer = new LegacySkinContainer(renderer, true); var legacyCursorTrail = new LegacyCursorTrail(skinContainer); skinContainer.Child = legacyCursorTrail; @@ -78,32 +83,34 @@ namespace osu.Game.Rulesets.Osu.Tests }); [Cached(typeof(ISkinSource))] - private class LegacySkinContainer : Container, ISkinSource + private partial class LegacySkinContainer : Container, ISkinSource { + private readonly IRenderer renderer; private readonly bool disjoint; - public LegacySkinContainer(bool disjoint) + public LegacySkinContainer(IRenderer renderer, bool disjoint) { + this.renderer = renderer; this.disjoint = disjoint; RelativeSizeAxes = Axes.Both; } - public Drawable GetDrawableComponent(ISkinComponent component) => null; + public Drawable GetDrawableComponent(ISkinComponentLookup lookup) => null; public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) { switch (componentName) { case "cursortrail": - var tex = new Texture(Texture.WhitePixel.TextureGL); + var tex = new Texture(renderer.WhitePixel); if (disjoint) tex.ScaleAdjust = 1 / 25f; return tex; case "cursormiddle": - return disjoint ? null : Texture.WhitePixel; + return disjoint ? null : renderer.WhitePixel; } return null; @@ -124,7 +131,7 @@ namespace osu.Game.Rulesets.Osu.Tests } } - private class MovingCursorInputManager : ManualInputManager + private partial class MovingCursorInputManager : ManualInputManager { public MovingCursorInputManager() { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs index 7821ae9cf0..ff71300733 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -20,7 +22,7 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneDrawableJudgement : OsuSkinnableTestScene + public partial class TestSceneDrawableJudgement : OsuSkinnableTestScene { [Resolved] private OsuConfigManager config { get; set; } @@ -104,7 +106,7 @@ namespace osu.Game.Rulesets.Osu.Tests }); } - private class TestDrawableOsuJudgement : DrawableOsuJudgement + private partial class TestDrawableOsuJudgement : DrawableOsuJudgement { public new SkinnableSprite Lighting => base.Lighting; public new SkinnableDrawable JudgementBody => base.JudgementBody; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneFollowPoints.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneFollowPoints.cs index fe67b63252..eefaa3cae3 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneFollowPoints.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneFollowPoints.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -19,7 +21,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneFollowPoints : OsuTestScene + public partial class TestSceneFollowPoints : OsuTestScene { private Container hitObjectContainer; private FollowPointRenderer followPointRenderer; @@ -193,7 +195,7 @@ namespace osu.Game.Rulesets.Osu.Tests { var drawableObject = getFunc.Invoke(); - hitObjectContainer.Remove(drawableObject); + hitObjectContainer.Remove(drawableObject, false); followPointRenderer.RemoveFollowPoints(drawableObject.HitObject); }); } @@ -210,7 +212,7 @@ namespace osu.Game.Rulesets.Osu.Tests else targetTime = getObject(hitObjectContainer.Count - 1).HitObject.StartTime + 1; - hitObjectContainer.Remove(toReorder); + hitObjectContainer.Remove(toReorder, false); toReorder.HitObject.StartTime = targetTime; hitObjectContainer.Add(toReorder); }); @@ -276,7 +278,7 @@ namespace osu.Game.Rulesets.Osu.Tests private FollowPointConnection getGroup(int index) => followPointRenderer.ChildrenOfType().Single(c => c.Entry == getEntry(index)); - private class TestHitObjectContainer : Container + private partial class TestHitObjectContainer : Container { protected override int Compare(Drawable x, Drawable y) { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs index 496d495b43..907422858e 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Framework.Allocation; @@ -8,7 +10,6 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; using osu.Framework.Input; @@ -27,7 +28,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneGameplayCursor : OsuSkinnableTestScene + public partial class TestSceneGameplayCursor : OsuSkinnableTestScene { [Cached] private GameplayState gameplayState; @@ -115,7 +116,7 @@ namespace osu.Game.Rulesets.Osu.Tests private class TopLeftCursorSkin : ISkin { - public Drawable GetDrawableComponent(ISkinComponent component) => null; + public Drawable GetDrawableComponent(ISkinComponentLookup lookup) => null; public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => null; public ISample GetSample(ISampleInfo sampleInfo) => null; @@ -134,7 +135,7 @@ namespace osu.Game.Rulesets.Osu.Tests } } - private class ClickingCursorContainer : OsuCursorContainer + private partial class ClickingCursorContainer : OsuCursorContainer { private bool pressed; @@ -160,7 +161,7 @@ namespace osu.Game.Rulesets.Osu.Tests } } - private class MovingCursorInputManager : ManualInputManager + private partial class MovingCursorInputManager : ManualInputManager { public MovingCursorInputManager() { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs index f6e8a771ed..50f9c5e775 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs @@ -1,11 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; @@ -16,10 +20,13 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneHitCircle : OsuSkinnableTestScene + public partial class TestSceneHitCircle : OsuSkinnableTestScene { private int depthIndex; + [Resolved] + private OsuConfigManager config { get; set; } + [Test] public void TestHits() { @@ -54,12 +61,20 @@ namespace osu.Game.Rulesets.Osu.Tests AddStep("Hit stream late", () => SetContents(_ => testStream(5, true, 150))); } + [Test] + public void TestHitLighting() + { + AddToggleStep("toggle hit lighting", v => config.SetValue(OsuSetting.HitLighting, v)); + AddStep("Hit Big Single", () => SetContents(_ => testSingle(2, true))); + } + private Drawable testSingle(float circleSize, bool auto = false, double timeOffset = 0, Vector2? positionOffset = null) { - var drawable = createSingle(circleSize, auto, timeOffset, positionOffset); - var playfield = new TestOsuPlayfield(); - playfield.Add(drawable); + + for (double t = timeOffset; t < timeOffset + 60000; t += 2000) + playfield.Add(createSingle(circleSize, auto, t, positionOffset)); + return playfield; } @@ -102,7 +117,7 @@ namespace osu.Game.Rulesets.Osu.Tests Depth = depthIndex++ }; - protected class TestDrawableHitCircle : DrawableHitCircle + protected partial class TestDrawableHitCircle : DrawableHitCircle { private readonly bool auto; private readonly double hitOffset; @@ -128,7 +143,7 @@ namespace osu.Game.Rulesets.Osu.Tests } } - protected class TestOsuPlayfield : OsuPlayfield + protected partial class TestOsuPlayfield : OsuPlayfield { public TestOsuPlayfield() { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleApplication.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleApplication.cs index 8b3fead366..bcbff20016 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleApplication.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleApplication.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Timing; using osu.Game.Beatmaps; @@ -12,7 +14,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneHitCircleApplication : OsuTestScene + public partial class TestSceneHitCircleApplication : OsuTestScene { [Test] public void TestApplyNewCircle() diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs index 07bbd6379a..71174e3295 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Framework.Graphics; @@ -16,7 +18,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneHitCircleArea : OsuManualInputManagerTestScene + public partial class TestSceneHitCircleArea : OsuManualInputManagerTestScene { private HitCircle hitCircle; private DrawableHitCircle drawableHitCircle; @@ -33,7 +35,7 @@ namespace osu.Game.Rulesets.Osu.Tests hitCircle.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); - Child = new SkinProvidingContainer(new DefaultSkin(null)) + Child = new SkinProvidingContainer(new TrianglesSkin(null!)) { RelativeSizeAxes = Axes.Both, Child = drawableHitCircle = new DrawableHitCircle(hitCircle) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleComboChange.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleComboChange.cs index ff600172d2..34c67e8b9c 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleComboChange.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleComboChange.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneHitCircleComboChange : TestSceneHitCircle + public partial class TestSceneHitCircleComboChange : TestSceneHitCircle { private readonly Bindable comboIndex = new Bindable(); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleHidden.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleHidden.cs index 45125204b6..b3498b9651 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleHidden.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleHidden.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Rulesets.Osu.Mods; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneHitCircleHidden : TestSceneHitCircle + public partial class TestSceneHitCircleHidden : TestSceneHitCircle { [SetUp] public void SetUp() => Schedule(() => diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleKiai.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleKiai.cs index 2bce8fa7f2..718664d649 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleKiai.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleKiai.cs @@ -1,30 +1,41 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; +using osu.Framework.Audio; +using osu.Framework.Audio.Track; +using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneHitCircleKiai : TestSceneHitCircle + public partial class TestSceneHitCircleKiai : TestSceneHitCircle, IBeatSyncProvider { + private ControlPointInfo controlPoints { get; set; } + [SetUp] public void SetUp() => Schedule(() => { - var controlPointInfo = new ControlPointInfo(); + controlPoints = new ControlPointInfo(); - controlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 }); - controlPointInfo.Add(0, new EffectControlPoint { KiaiMode = true }); + controlPoints.Add(0, new TimingControlPoint { BeatLength = 500 }); + controlPoints.Add(0, new EffectControlPoint { KiaiMode = true }); Beatmap.Value = CreateWorkingBeatmap(new Beatmap { - ControlPointInfo = controlPointInfo + ControlPointInfo = controlPoints }); // track needs to be playing for BeatSyncedContainer to work. Beatmap.Value.Track.Start(); }); + + ChannelAmplitudes IHasAmplitudes.CurrentAmplitudes => new ChannelAmplitudes(); + ControlPointInfo IBeatSyncProvider.ControlPoints => controlPoints; + IClock IBeatSyncProvider.Clock => Clock; } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleLateFade.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleLateFade.cs new file mode 100644 index 0000000000..3c32b4fa65 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleLateFade.cs @@ -0,0 +1,156 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public partial class TestSceneHitCircleLateFade : OsuTestScene + { + private float? alphaAtMiss; + + [Test] + public void TestHitCircleClassicMod() + { + AddStep("Create hit circle", () => + { + SelectedMods.Value = new Mod[] { new OsuModClassic() }; + createCircle(); + }); + + AddUntilStep("Wait until circle is missed", () => alphaAtMiss.IsNotNull()); + AddAssert("Transparent when missed", () => alphaAtMiss == 0); + } + + [Test] + public void TestHitCircleClassicAndFullHiddenMods() + { + AddStep("Create hit circle", () => + { + SelectedMods.Value = new Mod[] { new OsuModHidden(), new OsuModClassic() }; + createCircle(); + }); + + AddUntilStep("Wait until circle is missed", () => alphaAtMiss.IsNotNull()); + AddAssert("Transparent when missed", () => alphaAtMiss == 0); + } + + [Test] + public void TestHitCircleClassicAndApproachCircleOnlyHiddenMods() + { + AddStep("Create hit circle", () => + { + SelectedMods.Value = new Mod[] { new OsuModHidden { OnlyFadeApproachCircles = { Value = true } }, new OsuModClassic() }; + createCircle(); + }); + + AddUntilStep("Wait until circle is missed", () => alphaAtMiss.IsNotNull()); + AddAssert("Transparent when missed", () => alphaAtMiss == 0); + } + + [Test] + public void TestHitCircleNoMod() + { + AddStep("Create hit circle", () => + { + SelectedMods.Value = Array.Empty(); + createCircle(); + }); + + AddUntilStep("Wait until circle is missed", () => alphaAtMiss.IsNotNull()); + AddAssert("Opaque when missed", () => alphaAtMiss == 1); + } + + [Test] + public void TestSliderClassicMod() + { + AddStep("Create slider", () => + { + SelectedMods.Value = new Mod[] { new OsuModClassic() }; + createSlider(); + }); + + AddUntilStep("Wait until head circle is missed", () => alphaAtMiss.IsNotNull()); + AddAssert("Head circle transparent when missed", () => alphaAtMiss == 0); + } + + [Test] + public void TestSliderNoMod() + { + AddStep("Create slider", () => + { + SelectedMods.Value = Array.Empty(); + createSlider(); + }); + + AddUntilStep("Wait until head circle is missed", () => alphaAtMiss.IsNotNull()); + AddAssert("Head circle opaque when missed", () => alphaAtMiss == 1); + } + + private void createCircle() + { + alphaAtMiss = null; + + DrawableHitCircle drawableHitCircle = new DrawableHitCircle(new HitCircle + { + StartTime = Time.Current + 500, + Position = new Vector2(250) + }); + + foreach (var mod in SelectedMods.Value.OfType()) + mod.ApplyToDrawableHitObject(drawableHitCircle); + + drawableHitCircle.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + drawableHitCircle.OnNewResult += (_, _) => + { + alphaAtMiss = drawableHitCircle.Alpha; + }; + + Child = drawableHitCircle; + } + + private void createSlider() + { + alphaAtMiss = null; + + DrawableSlider drawableSlider = new DrawableSlider(new Slider + { + StartTime = Time.Current + 500, + Position = new Vector2(250), + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(0, 100), + }) + }); + + drawableSlider.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + drawableSlider.OnLoadComplete += _ => + { + foreach (var mod in SelectedMods.Value.OfType()) + mod.ApplyToDrawableHitObject(drawableSlider.HeadCircle); + + drawableSlider.HeadCircle.OnNewResult += (_, _) => + { + alphaAtMiss = drawableSlider.HeadCircle.Alpha; + }; + }; + Child = drawableSlider; + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleLongCombo.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleLongCombo.cs index 4e17c4c363..93f1123341 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleLongCombo.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleLongCombo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Osu.Objects; @@ -9,7 +11,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneHitCircleLongCombo : TestSceneOsuPlayer + public partial class TestSceneHitCircleLongCombo : TestSceneOsuPlayer { protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyBeatmapSkin.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyBeatmapSkin.cs index 8b51225e98..e70d45fb45 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyBeatmapSkin.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyBeatmapSkin.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -19,7 +21,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneLegacyBeatmapSkin : LegacyBeatmapSkinColourTest + public partial class TestSceneLegacyBeatmapSkin : LegacyBeatmapSkinColourTest { [Resolved] private AudioManager audio { get; set; } @@ -29,6 +31,8 @@ namespace osu.Game.Rulesets.Osu.Tests { config.BindWith(OsuSetting.BeatmapSkins, BeatmapSkins); config.BindWith(OsuSetting.BeatmapColours, BeatmapColours); + + config.SetValue(OsuSetting.ComboColourNormalisationAmount, 0f); } [TestCase(true, true)] diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs index a9325f98f7..0d3fd77568 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using NUnit.Framework; using osu.Game.Beatmaps; @@ -17,7 +19,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneMissHitWindowJudgements : ModTestScene + public partial class TestSceneMissHitWindowJudgements : ModTestScene { protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneNoSpinnerStacking.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneNoSpinnerStacking.cs index 5e92bac986..1f0e264cf7 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneNoSpinnerStacking.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneNoSpinnerStacking.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Osu.Objects; @@ -9,7 +11,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneNoSpinnerStacking : TestSceneOsuPlayer + public partial class TestSceneNoSpinnerStacking : TestSceneOsuPlayer { protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs index d31e7a31f5..5d9316a21b 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -26,7 +28,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneObjectOrderedHitPolicy : RateAdjustedBeatmapTestScene + public partial class TestSceneObjectOrderedHitPolicy : RateAdjustedBeatmapTestScene { private const double early_miss_window = 1000; // time after -1000 to -500 is considered a miss private const double late_miss_window = 500; // time after +500 is considered a miss @@ -375,7 +377,7 @@ namespace osu.Game.Rulesets.Osu.Tests private void addJudgementAssert(OsuHitObject hitObject, HitResult result) { AddAssert($"({hitObject.GetType().ReadableName()} @ {hitObject.StartTime}) judgement is {result}", - () => judgementResults.Single(r => r.HitObject == hitObject).Type == result); + () => judgementResults.Single(r => r.HitObject == hitObject).Type, () => Is.EqualTo(result)); } private void addJudgementAssert(string name, Func hitObject, HitResult result) @@ -472,7 +474,7 @@ namespace osu.Game.Rulesets.Osu.Tests protected override DifficultyRange[] GetRanges() => ranges; } - private class ScoreAccessibleReplayPlayer : ReplayPlayer + private partial class ScoreAccessibleReplayPlayer : ReplayPlayer { public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuHitObjectSamples.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuHitObjectSamples.cs index e8d98ce3b8..4d0b2cc406 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuHitObjectSamples.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuHitObjectSamples.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Reflection; using NUnit.Framework; using osu.Framework.IO.Stores; @@ -8,7 +10,7 @@ using osu.Game.Tests.Beatmaps; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneOsuHitObjectSamples : HitObjectSampleTest + public partial class TestSceneOsuHitObjectSamples : HitObjectSampleTest { protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuPlayer.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuPlayer.cs index f5b28b36c0..53c4e49807 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuPlayer.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuPlayer.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneOsuPlayer : PlayerTestScene + public partial class TestSceneOsuPlayer : PlayerTestScene { protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuTouchInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuTouchInput.cs new file mode 100644 index 0000000000..bb424eb587 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuTouchInput.cs @@ -0,0 +1,721 @@ +// 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; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Input.States; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +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.HUD; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Tests +{ + [TestFixture] + public partial class TestSceneOsuTouchInput : OsuManualInputManagerTestScene + { + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + private DefaultKeyCounter leftKeyCounter = null!; + + private DefaultKeyCounter rightKeyCounter = null!; + + private OsuInputManager osuInputManager = null!; + + private Container mainContent = null!; + + [SetUpSteps] + public void SetUpSteps() + { + releaseAllTouches(); + + AddStep("Create tests", () => + { + Children = new Drawable[] + { + osuInputManager = new OsuInputManager(new OsuRuleset().RulesetInfo) + { + Child = mainContent = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + leftKeyCounter = new DefaultKeyCounter(new TestActionKeyCounterTrigger(OsuAction.LeftButton)) + { + Anchor = Anchor.Centre, + Origin = Anchor.CentreRight, + Depth = float.MinValue, + X = -100, + }, + rightKeyCounter = new DefaultKeyCounter(new TestActionKeyCounterTrigger(OsuAction.RightButton)) + { + Anchor = Anchor.Centre, + Origin = Anchor.CentreLeft, + Depth = float.MinValue, + X = 100, + }, + new OsuCursorContainer + { + Depth = float.MinValue, + } + }, + } + }, + new TouchVisualiser(), + }; + }); + } + + [Test] + public void TestStreamInputVisual() + { + addHitCircleAt(TouchSource.Touch1); + addHitCircleAt(TouchSource.Touch2); + + beginTouch(TouchSource.Touch1); + beginTouch(TouchSource.Touch2); + + endTouch(TouchSource.Touch1); + + int i = 0; + + AddRepeatStep("Alternate", () => + { + TouchSource down = i % 2 == 0 ? TouchSource.Touch3 : TouchSource.Touch4; + TouchSource up = i % 2 == 0 ? TouchSource.Touch4 : TouchSource.Touch3; + + // sometimes the user will end the previous touch before touching again, sometimes not. + if (RNG.NextBool()) + { + InputManager.BeginTouch(new Touch(down, getSanePositionForSource(down))); + InputManager.EndTouch(new Touch(up, getSanePositionForSource(up))); + } + else + { + InputManager.EndTouch(new Touch(up, getSanePositionForSource(up))); + InputManager.BeginTouch(new Touch(down, getSanePositionForSource(down))); + } + + i++; + }, 100); + } + + [Test] + public void TestSimpleInput() + { + beginTouch(TouchSource.Touch1); + + assertKeyCounter(1, 0); + checkPressed(OsuAction.LeftButton); + checkPosition(TouchSource.Touch1); + + beginTouch(TouchSource.Touch2); + + assertKeyCounter(1, 1); + checkPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + checkPosition(TouchSource.Touch2); + + // Subsequent touches should be ignored (except position). + beginTouch(TouchSource.Touch3); + checkPosition(TouchSource.Touch3); + + beginTouch(TouchSource.Touch4); + checkPosition(TouchSource.Touch4); + + assertKeyCounter(1, 1); + + checkPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + + 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() + { + beginTouch(TouchSource.Touch1); + checkPosition(TouchSource.Touch1); + + beginTouch(TouchSource.Touch2); + checkPosition(TouchSource.Touch2); + + beginTouch(TouchSource.Touch1, Vector2.One); + checkPosition(TouchSource.Touch2); + + endTouch(TouchSource.Touch2); + checkPosition(TouchSource.Touch2); + + // note that touch1 was never ended, but is no longer valid for touch input due to touch 2 occurring. + beginTouch(TouchSource.Touch1); + checkPosition(TouchSource.Touch2); + } + + [Test] + public void TestStreamInput() + { + // In this scenario, the user is tapping on the first object in a stream, + // then using one or two fingers in empty space to continue the stream. + + addHitCircleAt(TouchSource.Touch1); + beginTouch(TouchSource.Touch1); + + // The first touch is handled as normal. + assertKeyCounter(1, 0); + checkPressed(OsuAction.LeftButton); + checkPosition(TouchSource.Touch1); + + // The second touch should release the first, and also act as a right button. + beginTouch(TouchSource.Touch2); + + assertKeyCounter(1, 1); + // Importantly, this is different from the simple case because an object was interacted with in the first touch, but not the second touch. + // left button is automatically released. + checkNotPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + // Also importantly, the positional part of the second touch is ignored. + checkPosition(TouchSource.Touch1); + + // In this scenario, a third touch should be allowed, and handled similarly to the second. + beginTouch(TouchSource.Touch3); + + assertKeyCounter(2, 1); + checkPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + // Position is still ignored. + checkPosition(TouchSource.Touch1); + + endTouch(TouchSource.Touch2); + + checkPressed(OsuAction.LeftButton); + checkNotPressed(OsuAction.RightButton); + // Position is still ignored. + checkPosition(TouchSource.Touch1); + + // User continues streaming + beginTouch(TouchSource.Touch2); + + assertKeyCounter(2, 2); + checkPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + // Position is still ignored. + checkPosition(TouchSource.Touch1); + + // In this mode a maximum of three touches should be supported. + // A fourth touch should result in no changes anywhere. + beginTouch(TouchSource.Touch4); + assertKeyCounter(2, 2); + checkPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + checkPosition(TouchSource.Touch1); + endTouch(TouchSource.Touch4); + } + + [Test] + public void TestStreamInputWithInitialTouchDownLeft() + { + // In this scenario, the user is wanting to use stream input but we start with one finger still on the screen. + // That finger is mapped to a left action. + + addHitCircleAt(TouchSource.Touch2); + + beginTouch(TouchSource.Touch1); + assertKeyCounter(1, 0); + checkPressed(OsuAction.LeftButton); + checkPosition(TouchSource.Touch1); + + // hits circle as right action + beginTouch(TouchSource.Touch2); + assertKeyCounter(1, 1); + checkPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + checkPosition(TouchSource.Touch2); + + endTouch(TouchSource.Touch1); + checkNotPressed(OsuAction.LeftButton); + + // stream using other two fingers while touch2 tracks + beginTouch(TouchSource.Touch1); + assertKeyCounter(2, 1); + checkPressed(OsuAction.LeftButton); + // right button is automatically released + checkNotPressed(OsuAction.RightButton); + checkPosition(TouchSource.Touch2); + + beginTouch(TouchSource.Touch3); + assertKeyCounter(2, 2); + checkPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + checkPosition(TouchSource.Touch2); + + endTouch(TouchSource.Touch1); + checkNotPressed(OsuAction.LeftButton); + + beginTouch(TouchSource.Touch1); + assertKeyCounter(3, 2); + checkPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + checkPosition(TouchSource.Touch2); + } + + [Test] + public void TestStreamInputWithInitialTouchDownRight() + { + // In this scenario, the user is wanting to use stream input but we start with one finger still on the screen. + // That finger is mapped to a right action. + + beginTouch(TouchSource.Touch1); + beginTouch(TouchSource.Touch2); + + assertKeyCounter(1, 1); + checkPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + + endTouch(TouchSource.Touch1); + + addHitCircleAt(TouchSource.Touch1); + + // hits circle as left action + beginTouch(TouchSource.Touch1); + assertKeyCounter(2, 1); + checkPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + checkPosition(TouchSource.Touch1); + + endTouch(TouchSource.Touch2); + + // stream using other two fingers while touch1 tracks + beginTouch(TouchSource.Touch2); + assertKeyCounter(2, 2); + checkPressed(OsuAction.RightButton); + // left button is automatically released + checkNotPressed(OsuAction.LeftButton); + checkPosition(TouchSource.Touch1); + + beginTouch(TouchSource.Touch3); + assertKeyCounter(3, 2); + checkPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + checkPosition(TouchSource.Touch1); + + endTouch(TouchSource.Touch2); + checkNotPressed(OsuAction.RightButton); + + beginTouch(TouchSource.Touch2); + assertKeyCounter(3, 3); + checkPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + checkPosition(TouchSource.Touch1); + } + + [Test] + public void TestNonStreamOverlappingDirectTouchesWithRelease() + { + // In this scenario, the user is tapping on three circles directly while correctly releasing the first touch. + // All three should be recognised. + + addHitCircleAt(TouchSource.Touch1); + addHitCircleAt(TouchSource.Touch2); + addHitCircleAt(TouchSource.Touch3); + + beginTouch(TouchSource.Touch1); + assertKeyCounter(1, 0); + checkPressed(OsuAction.LeftButton); + checkPosition(TouchSource.Touch1); + + beginTouch(TouchSource.Touch2); + assertKeyCounter(1, 1); + checkPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + checkPosition(TouchSource.Touch2); + + endTouch(TouchSource.Touch1); + + beginTouch(TouchSource.Touch3); + assertKeyCounter(2, 1); + checkPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + checkPosition(TouchSource.Touch3); + } + + [Test] + public void TestNonStreamOverlappingDirectTouchesWithoutRelease() + { + // In this scenario, the user is tapping on three circles directly without releasing any touches. + // The first two should be recognised, but a third should not (as the user already has two fingers down). + + addHitCircleAt(TouchSource.Touch1); + addHitCircleAt(TouchSource.Touch2); + addHitCircleAt(TouchSource.Touch3); + + beginTouch(TouchSource.Touch1); + assertKeyCounter(1, 0); + checkPressed(OsuAction.LeftButton); + checkPosition(TouchSource.Touch1); + + beginTouch(TouchSource.Touch2); + assertKeyCounter(1, 1); + checkPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + checkPosition(TouchSource.Touch2); + + beginTouch(TouchSource.Touch3); + assertKeyCounter(1, 1); + checkPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + checkPosition(TouchSource.Touch3); + } + + [Test] + public void TestMovementWhileDisallowed() + { + // aka "autopilot" mod + + AddStep("Disallow gameplay cursor movement", () => osuInputManager.AllowUserCursorMovement = false); + + Vector2? positionBefore = null; + + AddStep("Store cursor position", () => positionBefore = osuInputManager.CurrentState.Mouse.Position); + beginTouch(TouchSource.Touch1); + + assertKeyCounter(1, 0); + checkPressed(OsuAction.LeftButton); + AddAssert("Cursor position unchanged", () => osuInputManager.CurrentState.Mouse.Position, () => Is.EqualTo(positionBefore)); + } + + [Test] + public void TestActionWhileDisallowed() + { + // aka "relax" mod + + AddStep("Disallow gameplay actions", () => osuInputManager.AllowGameplayInputs = false); + + beginTouch(TouchSource.Touch1); + + assertKeyCounter(0, 0); + checkNotPressed(OsuAction.LeftButton); + checkPosition(TouchSource.Touch1); + } + + [Test] + public void TestInputWhileMouseButtonsDisabled() + { + AddStep("Disable mouse buttons", () => config.SetValue(OsuSetting.MouseDisableButtons, true)); + + beginTouch(TouchSource.Touch1); + + assertKeyCounter(0, 0); + checkNotPressed(OsuAction.LeftButton); + checkPosition(TouchSource.Touch1); + + beginTouch(TouchSource.Touch2); + + assertKeyCounter(0, 0); + checkNotPressed(OsuAction.LeftButton); + checkNotPressed(OsuAction.RightButton); + checkPosition(TouchSource.Touch2); + } + + [Test] + public void TestAlternatingInput() + { + beginTouch(TouchSource.Touch1); + + assertKeyCounter(1, 0); + checkPressed(OsuAction.LeftButton); + + beginTouch(TouchSource.Touch2); + + assertKeyCounter(1, 1); + checkPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + + for (int i = 0; i < 2; i++) + { + endTouch(TouchSource.Touch1); + + checkPressed(OsuAction.RightButton); + checkNotPressed(OsuAction.LeftButton); + + beginTouch(TouchSource.Touch1); + + checkPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + + endTouch(TouchSource.Touch2); + + checkPressed(OsuAction.LeftButton); + checkNotPressed(OsuAction.RightButton); + + beginTouch(TouchSource.Touch2); + + checkPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + } + } + + [Test] + public void TestPressReleaseOrder() + { + beginTouch(TouchSource.Touch1); + beginTouch(TouchSource.Touch2); + beginTouch(TouchSource.Touch3); + + assertKeyCounter(1, 1); + checkPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + + // Touch 3 was ignored, but let's ensure that if 1 or 2 are released, 3 will be handled a second attempt. + endTouch(TouchSource.Touch1); + + assertKeyCounter(1, 1); + checkPressed(OsuAction.RightButton); + + endTouch(TouchSource.Touch3); + + assertKeyCounter(1, 1); + checkPressed(OsuAction.RightButton); + + beginTouch(TouchSource.Touch3); + + assertKeyCounter(2, 1); + checkPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + } + + [Test] + public void TestWithDisallowedUserCursor() + { + beginTouch(TouchSource.Touch1); + + assertKeyCounter(1, 0); + checkPressed(OsuAction.LeftButton); + + beginTouch(TouchSource.Touch2); + + assertKeyCounter(1, 1); + checkPressed(OsuAction.RightButton); + + // Subsequent touches should be ignored. + beginTouch(TouchSource.Touch3); + beginTouch(TouchSource.Touch4); + + assertKeyCounter(1, 1); + + checkPressed(OsuAction.LeftButton); + checkPressed(OsuAction.RightButton); + + assertKeyCounter(1, 1); + } + + private void addHitCircleAt(TouchSource source) + { + AddStep($"Add circle at {source}", () => + { + var hitCircle = new HitCircle(); + + hitCircle.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + mainContent.Add(new DrawableHitCircle(hitCircle) + { + Clock = new FramedClock(new ManualClock()), + Position = mainContent.ToLocalSpace(getSanePositionForSource(source)), + }); + }); + } + + private void beginTouch(TouchSource source, Vector2? screenSpacePosition = null) => + AddStep($"Begin touch for {source}", () => InputManager.BeginTouch(new Touch(source, screenSpacePosition ??= getSanePositionForSource(source)))); + + private void endTouch(TouchSource source, Vector2? screenSpacePosition = null) => + AddStep($"Release touch for {source}", () => InputManager.EndTouch(new Touch(source, screenSpacePosition ??= getSanePositionForSource(source)))); + + private Vector2 getSanePositionForSource(TouchSource source) + { + return new Vector2( + osuInputManager.ScreenSpaceDrawQuad.Centre.X + osuInputManager.ScreenSpaceDrawQuad.Width * (-1 + (int)source) / 8, + osuInputManager.ScreenSpaceDrawQuad.Centre.Y - 100 + ); + } + + private void checkPosition(TouchSource touchSource) => + AddAssert("Cursor position is correct", () => osuInputManager.CurrentState.Mouse.Position, () => Is.EqualTo(getSanePositionForSource(touchSource))); + + private void assertKeyCounter(int left, int 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() + { + AddStep("Release all touches", () => + { + config.SetValue(OsuSetting.MouseDisableButtons, false); + foreach (TouchSource source in InputManager.CurrentState.Touch.ActiveSources) + InputManager.EndTouch(new Touch(source, osuInputManager.ScreenSpaceDrawQuad.Centre)); + }); + } + + 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 TestActionKeyCounterTrigger : InputTrigger, IKeyBindingHandler + { + public OsuAction Action { get; } + + public TestActionKeyCounterTrigger(OsuAction action) + : base(action.ToString()) + { + Action = action; + } + + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == Action) + { + Activate(); + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + if (e.Action == Action) + Deactivate(); + } + } + + public partial class TouchVisualiser : CompositeDrawable + { + private readonly Drawable?[] drawableTouches = new Drawable?[TouchState.MAX_TOUCH_COUNT]; + + public TouchVisualiser() + { + RelativeSizeAxes = Axes.Both; + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + + protected override bool OnTouchDown(TouchDownEvent e) + { + if (IsDisposed) + return false; + + var circle = new Circle + { + Alpha = 0.5f, + Origin = Anchor.Centre, + Size = new Vector2(20), + Position = e.Touch.Position, + Colour = colourFor(e.Touch.Source), + }; + + AddInternal(circle); + drawableTouches[(int)e.Touch.Source] = circle; + return false; + } + + protected override void OnTouchMove(TouchMoveEvent e) + { + if (IsDisposed) + return; + + var circle = drawableTouches[(int)e.Touch.Source]; + + Debug.Assert(circle != null); + + AddInternal(new FadingCircle(circle)); + circle.Position = e.Touch.Position; + } + + protected override void OnTouchUp(TouchUpEvent e) + { + var circle = drawableTouches[(int)e.Touch.Source]; + + Debug.Assert(circle != null); + + circle.FadeOut(200, Easing.OutQuint).Expire(); + drawableTouches[(int)e.Touch.Source] = null; + } + + private Color4 colourFor(TouchSource source) + { + return Color4.FromHsv(new Vector4((float)source / TouchState.MAX_TOUCH_COUNT, 1f, 1f, 1f)); + } + + private partial class FadingCircle : Circle + { + public FadingCircle(Drawable source) + { + Origin = Anchor.Centre; + Size = source.Size; + Position = source.Position; + Colour = source.Colour; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + this.FadeOut(200).Expire(); + } + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneResumeOverlay.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneResumeOverlay.cs index a7967c407a..b66974d4b1 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneResumeOverlay.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneResumeOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -10,7 +12,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneResumeOverlay : OsuManualInputManagerTestScene + public partial class TestSceneResumeOverlay : OsuManualInputManagerTestScene { public TestSceneResumeOverlay() { @@ -46,7 +48,7 @@ namespace osu.Game.Rulesets.Osu.Tests AddAssert("dismissed", () => resumeFired && resume.State.Value == Visibility.Hidden); } - private class ManualOsuInputManager : OsuInputManager + private partial class ManualOsuInputManager : OsuInputManager { public ManualOsuInputManager(RulesetInfo ruleset) : base(ruleset) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs index 43900c9a5c..bee7831625 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Diagnostics; using osu.Framework.Threading; @@ -11,7 +13,7 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneShaking : TestSceneHitCircle + public partial class TestSceneShaking : TestSceneHitCircle { private readonly List scheduledTasks = new List(); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs index 0f362851a9..09b906cb10 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -10,7 +12,6 @@ using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Timing; @@ -28,7 +29,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneSkinFallbacks : TestSceneOsuPlayer + public partial class TestSceneSkinFallbacks : TestSceneOsuPlayer { private readonly TestSource testUserSkin; private readonly TestSource testBeatmapSkin; @@ -118,7 +119,7 @@ namespace osu.Game.Rulesets.Osu.Tests protected override ISkin GetSkin() => skin; } - public class SkinProvidingPlayer : TestPlayer + public partial class SkinProvidingPlayer : TestPlayer { private readonly TestSource userSkin; @@ -148,11 +149,11 @@ namespace osu.Game.Rulesets.Osu.Tests this.identifier = identifier; } - public Drawable GetDrawableComponent(ISkinComponent component) + public Drawable GetDrawableComponent(ISkinComponentLookup lookup) { if (!enabled) return null; - if (component is OsuSkinComponent osuComponent && osuComponent.Component == OsuSkinComponents.SliderBody) + if (lookup is OsuSkinComponentLookup osuComponent && osuComponent.Component == OsuSkinComponents.SliderBody) return null; return new OsuSpriteText diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs index 03b4254eed..1e9f931b74 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Graphics; using osu.Game.Audio; @@ -24,7 +26,7 @@ using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneSlider : OsuSkinnableTestScene + public partial class TestSceneSlider : OsuSkinnableTestScene { private int depthIndex; @@ -158,9 +160,9 @@ namespace osu.Game.Rulesets.Osu.Tests static bool assertSamples(HitObject hitObject) => hitObject.Samples.All(s => s.Name != HitSampleInfo.HIT_CLAP && s.Name != HitSampleInfo.HIT_WHISTLE); } - private Drawable testSimpleBig(int repeats = 0) => createSlider(2, repeats: repeats); + private Drawable testSimpleBig(int repeats = 0) => createSlider(repeats: repeats); - private Drawable testSimpleBigLargeStackOffset(int repeats = 0) => createSlider(2, repeats: repeats, stackHeight: 10); + private Drawable testSimpleBigLargeStackOffset(int repeats = 0) => createSlider(repeats: repeats, stackHeight: 10); private Drawable testDistanceOverflow(int repeats = 0) { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs index a40ae611d8..88b70a8836 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -12,7 +14,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; -using osu.Game.Rulesets.Osu.Skinning.Default; +using osu.Game.Rulesets.Osu.Skinning.Legacy; using osu.Game.Skinning; using osu.Game.Tests.Visual; using osuTK; @@ -20,7 +22,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneSliderApplication : OsuTestScene + public partial class TestSceneSliderApplication : OsuTestScene { [Resolved] private SkinManager skinManager { get; set; } @@ -67,10 +69,8 @@ namespace osu.Game.Rulesets.Osu.Tests AddStep("create slider", () => { - var tintingSkin = skinManager.GetSkin(DefaultLegacySkin.CreateInfo()); - tintingSkin.Configuration.ConfigDictionary["AllowSliderBallTint"] = "1"; - - var provider = Ruleset.Value.CreateInstance().CreateLegacySkinProvider(tintingSkin, Beatmap.Value.Beatmap); + var skin = skinManager.GetSkin(DefaultLegacySkin.CreateInfo()); + var provider = Ruleset.Value.CreateInstance().CreateSkinTransformer(skin, Beatmap.Value.Beatmap); Child = new SkinProvidingContainer(provider) { @@ -91,10 +91,10 @@ namespace osu.Game.Rulesets.Osu.Tests }); AddStep("set accent white", () => dho.AccentColour.Value = Color4.White); - AddAssert("ball is white", () => dho.ChildrenOfType().Single().AccentColour == Color4.White); + AddAssert("ball is white", () => dho.ChildrenOfType().Single().BallColour == Color4.White); AddStep("set accent red", () => dho.AccentColour.Value = Color4.Red); - AddAssert("ball is red", () => dho.ChildrenOfType().Single().AccentColour == Color4.Red); + AddAssert("ball is red", () => dho.ChildrenOfType().Single().BallColour == Color4.Red); } private Slider prepareObject(Slider slider) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderComboChange.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderComboChange.cs index 13ced3019e..dc8842a20a 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderComboChange.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderComboChange.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneSliderComboChange : TestSceneSlider + public partial class TestSceneSliderComboChange : TestSceneSlider { private readonly Bindable comboIndex = new Bindable(); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderFollowCircleInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderFollowCircleInput.cs new file mode 100644 index 0000000000..a32f0a13b8 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderFollowCircleInput.cs @@ -0,0 +1,118 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.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; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + [HeadlessTest] + public partial class TestSceneSliderFollowCircleInput : RateAdjustedBeatmapTestScene + { + private List? judgementResults; + private ScoreAccessibleReplayPlayer? currentPlayer; + + [Test] + public void TestMaximumDistanceTrackingWithoutMovement( + [Values(0, 5, 10)] float circleSize, + [Values(0, 5, 10)] double velocity) + { + const double time_slider_start = 1000; + + float circleRadius = OsuHitObject.OBJECT_RADIUS * (1.0f - 0.7f * (circleSize - 5) / 5) / 2; + float followCircleRadius = circleRadius * 1.2f; + + performTest(new Beatmap + { + HitObjects = + { + new Slider + { + StartTime = time_slider_start, + Position = new Vector2(0, 0), + DifficultyControlPoint = new DifficultyControlPoint { SliderVelocity = velocity }, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(followCircleRadius, 0), + }, followCircleRadius), + }, + }, + BeatmapInfo = + { + Difficulty = new BeatmapDifficulty + { + CircleSize = circleSize, + SliderTickRate = 1 + }, + Ruleset = new OsuRuleset().RulesetInfo + }, + }, new List + { + new OsuReplayFrame { Position = new Vector2(-circleRadius + 1, 0), Actions = { OsuAction.LeftButton }, Time = time_slider_start }, + }); + + AddAssert("Tracking kept", assertMaxJudge); + } + + private bool assertMaxJudge() => judgementResults?.Any() == true && judgementResults.All(t => t.Type == t.Judgement.MaxResult); + + private void performTest(Beatmap beatmap, List frames) + { + AddStep("load player", () => + { + Beatmap.Value = CreateWorkingBeatmap(beatmap); + + var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); + + p.OnLoadComplete += _ => + { + p.ScoreProcessor.NewJudgement += result => + { + if (currentPlayer == p) judgementResults?.Add(result); + }; + }; + + LoadScreen(currentPlayer = p); + judgementResults = new List(); + }); + + AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0); + AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen()); + AddUntilStep("Wait for completion", () => currentPlayer?.ScoreProcessor.HasCompleted.Value == true); + } + + private partial class ScoreAccessibleReplayPlayer : ReplayPlayer + { + public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; + + protected override bool PauseOnFocusLost => false; + + public ScoreAccessibleReplayPlayer(Score score) + : base(score, new PlayerConfiguration + { + AllowPause = false, + ShowResults = false, + }) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderHidden.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderHidden.cs index b2bd727c6a..eb13995ad0 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderHidden.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderHidden.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Rulesets.Osu.Mods; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneSliderHidden : TestSceneSlider + public partial class TestSceneSliderHidden : TestSceneSlider { [SetUp] public void SetUp() => Schedule(() => diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs index 2368cc7365..5f27cdc191 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -22,7 +24,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneSliderInput : RateAdjustedBeatmapTestScene + public partial class TestSceneSliderInput : RateAdjustedBeatmapTestScene { private const double time_before_slider = 250; private const double time_slider_start = 1500; @@ -382,7 +384,7 @@ namespace osu.Game.Rulesets.Osu.Tests AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor.HasCompleted.Value); } - private class ScoreAccessibleReplayPlayer : ReplayPlayer + private partial class ScoreAccessibleReplayPlayer : ReplayPlayer { public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSnaking.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSnaking.cs index a3aa84d0e7..630049f408 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSnaking.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSnaking.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -22,12 +24,13 @@ using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Storyboards; +using osu.Game.Tests; using osuTK; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneSliderSnaking : TestSceneOsuPlayer + public partial class TestSceneSliderSnaking : TestSceneOsuPlayer { [Resolved] private AudioManager audioManager { get; set; } @@ -64,18 +67,25 @@ namespace osu.Game.Rulesets.Osu.Tests drawableSlider = null; }); - [SetUpSteps] - public override void SetUpSteps() - { - } + protected override bool HasCustomSteps => true; [TestCase(0)] [TestCase(1)] [TestCase(2)] + [FlakyTest] + /* + * Fail rate around 0.15% + * + * TearDown : System.TimeoutException : "wait for seek to finish" timed out + * --TearDown + * at osu.Framework.Testing.Drawables.Steps.UntilStepButton.<>c__DisplayClass11_0.<.ctor>b__0() + * at osu.Framework.Testing.Drawables.Steps.StepButton.PerformStep(Boolean userTriggered) + * at osu.Framework.Testing.TestScene.runNextStep(Action onCompletion, Action`1 onError, Func`2 stopCondition) + */ public void TestSnakingEnabled(int sliderIndex) { AddStep("enable autoplay", () => autoplay = true); - base.SetUpSteps(); + CreateTest(); AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); retrieveSlider(sliderIndex); @@ -96,10 +106,20 @@ namespace osu.Game.Rulesets.Osu.Tests [TestCase(0)] [TestCase(1)] [TestCase(2)] + [FlakyTest] + /* + * Fail rate around 0.15% + * + * TearDown : System.TimeoutException : "wait for seek to finish" timed out + * --TearDown + * at osu.Framework.Testing.Drawables.Steps.UntilStepButton.<>c__DisplayClass11_0.<.ctor>b__0() + * at osu.Framework.Testing.Drawables.Steps.StepButton.PerformStep(Boolean userTriggered) + * at osu.Framework.Testing.TestScene.runNextStep(Action onCompletion, Action`1 onError, Func`2 stopCondition) + */ public void TestSnakingDisabled(int sliderIndex) { AddStep("have autoplay", () => autoplay = true); - base.SetUpSteps(); + CreateTest(); AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); retrieveSlider(sliderIndex); @@ -119,8 +139,7 @@ namespace osu.Game.Rulesets.Osu.Tests { AddStep("enable autoplay", () => autoplay = true); setSnaking(true); - base.SetUpSteps(); - + CreateTest(); // repeat might have a chance to update its position depending on where in the frame its hit, // so some leniency is allowed here instead of checking strict equality addCheckPositionChangeSteps(() => 16600, getSliderRepeat, positionAlmostSame); @@ -131,15 +150,14 @@ namespace osu.Game.Rulesets.Osu.Tests { AddStep("disable autoplay", () => autoplay = false); setSnaking(true); - base.SetUpSteps(); - + CreateTest(); addCheckPositionChangeSteps(() => 16600, getSliderRepeat, positionDecreased); } private void retrieveSlider(int index) { AddStep("retrieve slider at index", () => slider = (Slider)beatmap.HitObjects[index]); - addSeekStep(() => slider); + addSeekStep(() => slider.StartTime); AddUntilStep("retrieve drawable slider", () => (drawableSlider = (DrawableSlider)Player.DrawableRuleset.Playfield.AllHitObjects.SingleOrDefault(d => d.HitObject == slider)) != null); } @@ -159,7 +177,7 @@ namespace osu.Game.Rulesets.Osu.Tests => addCheckPositionChangeSteps(timeAtRepeat(startTime, repeatIndex), positionAtRepeat(repeatIndex), positionRemainsSame); private Func timeAtRepeat(Func startTime, int repeatIndex) => () => startTime() + 100 + duration_of_span * repeatIndex; - private Func positionAtRepeat(int repeatIndex) => repeatIndex % 2 == 0 ? (Func)getSliderStart : getSliderEnd; + private Func positionAtRepeat(int repeatIndex) => repeatIndex % 2 == 0 ? getSliderStart : getSliderEnd; private List getSliderCurve() => ((PlaySliderBody)drawableSlider.Body.Drawable).CurrentCurve; private Vector2 getSliderStart() => getSliderCurve().First(); @@ -203,16 +221,10 @@ namespace osu.Game.Rulesets.Osu.Tests }); } - private void addSeekStep(Func slider) + private void addSeekStep(Func getTime) { - AddStep("seek to slider", () => Player.GameplayClockContainer.Seek(slider().StartTime)); - AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(slider().StartTime, Player.DrawableRuleset.FrameStableClock.CurrentTime, 100)); - } - - private void addSeekStep(Func time) - { - AddStep("seek to time", () => Player.GameplayClockContainer.Seek(time())); - AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(time(), Player.DrawableRuleset.FrameStableClock.CurrentTime, 100)); + AddStep("seek to time", () => Player.GameplayClockContainer.Seek(getTime())); + AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(getTime(), Player.DrawableRuleset.FrameStableClock.CurrentTime, 100)); } protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new Beatmap { HitObjects = createHitObjects() }; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSmoke.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSmoke.cs new file mode 100644 index 0000000000..d5d3cbb146 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSmoke.cs @@ -0,0 +1,136 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Input.Events; +using osu.Framework.Input.States; +using osu.Framework.Logging; +using osu.Framework.Testing.Input; +using osu.Game.Rulesets.Osu.UI; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public partial class TestSceneSmoke : OsuSkinnableTestScene + { + [Test] + public void TestSmoking() + { + addStep("Create short smoke", 2_000); + addStep("Create medium smoke", 5_000); + addStep("Create long smoke", 10_000); + } + + private void addStep(string stepName, double duration) + { + var smokeContainers = new List(); + + AddStep(stepName, () => + { + smokeContainers.Clear(); + SetContents(_ => + { + smokeContainers.Add(new TestSmokeContainer + { + Duration = duration, + RelativeSizeAxes = Axes.Both + }); + + return new SmokingInputManager + { + Duration = duration, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.95f), + Child = smokeContainers[^1], + }; + }); + }); + + AddUntilStep("Until skinnable expires", () => + { + if (smokeContainers.Count == 0) + return false; + + Logger.Log("How many: " + smokeContainers.Count); + + foreach (var smokeContainer in smokeContainers) + { + if (smokeContainer.Children.Count != 0) + return false; + } + + return true; + }); + } + + private partial class SmokingInputManager : ManualInputManager + { + public double Duration { get; init; } + + private double? startTime; + + public SmokingInputManager() + { + UseParentInput = false; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + MoveMouseTo(ToScreenSpace(DrawSize / 2)); + } + + protected override void Update() + { + base.Update(); + + const float spin_angle = 4 * MathF.PI; + + startTime ??= Time.Current; + + float fraction = (float)((Time.Current - startTime) / Duration); + + float angle = fraction * spin_angle; + float radius = fraction * Math.Min(DrawSize.X, DrawSize.Y) / 2; + + Vector2 pos = radius * new Vector2(MathF.Cos(angle), MathF.Sin(angle)) + DrawSize / 2; + MoveMouseTo(ToScreenSpace(pos)); + } + } + + private partial class TestSmokeContainer : SmokeContainer + { + public double Duration { get; init; } + + private bool isPressing; + private bool isFinished; + + private double? startTime; + + protected override void Update() + { + base.Update(); + + startTime ??= Time.Current + 0.1; + + if (!isPressing && !isFinished && Time.Current > startTime) + { + OnPressed(new KeyBindingPressEvent(new InputState(), OsuAction.Smoke)); + isPressing = true; + isFinished = false; + } + + if (isPressing && Time.Current > startTime + Duration) + { + OnReleased(new KeyBindingReleaseEvent(new InputState(), OsuAction.Smoke)); + isPressing = false; + isFinished = true; + } + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs index 2dea9837f3..74d0fb42a3 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -18,7 +20,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneSpinner : OsuSkinnableTestScene + public partial class TestSceneSpinner : OsuSkinnableTestScene { private int depthIndex; @@ -91,7 +93,7 @@ namespace osu.Game.Rulesets.Osu.Tests return drawableSpinner; } - private class TestDrawableSpinner : DrawableSpinner + private partial class TestDrawableSpinner : DrawableSpinner { private readonly bool auto; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs index 8c97c02049..1ae17432be 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Timing; using osu.Game.Beatmaps; @@ -12,7 +14,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneSpinnerApplication : OsuTestScene + public partial class TestSceneSpinnerApplication : OsuTestScene { [Test] public void TestApplyNewSpinner() diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerHidden.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerHidden.cs index 91b6a05fe3..1aaba23e56 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerHidden.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerHidden.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Rulesets.Osu.Mods; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneSpinnerHidden : TestSceneSpinner + public partial class TestSceneSpinnerHidden : TestSceneSpinner { [SetUp] public void SetUp() => Schedule(() => diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs index de795241bf..116c974f32 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs @@ -7,10 +7,8 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; -using osu.Framework.Graphics.Sprites; using osu.Framework.Testing; using osu.Framework.Timing; -using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Replays; using osu.Game.Rulesets.Objects; @@ -28,23 +26,22 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneSpinnerRotation : TestSceneOsuPlayer + public partial class TestSceneSpinnerRotation : TestSceneOsuPlayer { private const double spinner_start_time = 100; private const double spinner_duration = 6000; [Resolved] - private AudioManager audioManager { get; set; } + private AudioManager audioManager { get; set; } = null!; protected override bool Autoplay => true; protected override TestPlayer CreatePlayer(Ruleset ruleset) => new ScoreExposedPlayer(); - protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null) => new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audioManager); - private DrawableSpinner drawableSpinner; - private SpriteIcon spinnerSymbol => drawableSpinner.ChildrenOfType().Single(); + private DrawableSpinner drawableSpinner = null!; [SetUpSteps] public override void SetUpSteps() @@ -65,12 +62,12 @@ namespace osu.Game.Rulesets.Osu.Tests { trackerRotationTolerance = Math.Abs(drawableSpinner.RotationTracker.Rotation * 0.1f); }); - AddAssert("is disc rotation not almost 0", () => !Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, 0, 100)); - AddAssert("is disc rotation absolute not almost 0", () => !Precision.AlmostEquals(drawableSpinner.Result.RateAdjustedRotation, 0, 100)); + AddAssert("is disc rotation not almost 0", () => drawableSpinner.RotationTracker.Rotation, () => Is.Not.EqualTo(0).Within(100)); + AddAssert("is disc rotation absolute not almost 0", () => drawableSpinner.Result.RateAdjustedRotation, () => Is.Not.EqualTo(0).Within(100)); addSeekStep(0); - AddAssert("is disc rotation almost 0", () => Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, 0, trackerRotationTolerance)); - AddAssert("is disc rotation absolute almost 0", () => Precision.AlmostEquals(drawableSpinner.Result.RateAdjustedRotation, 0, 100)); + AddAssert("is disc rotation almost 0", () => drawableSpinner.RotationTracker.Rotation, () => Is.EqualTo(0).Within(trackerRotationTolerance)); + AddAssert("is disc rotation absolute almost 0", () => drawableSpinner.Result.RateAdjustedRotation, () => Is.EqualTo(0).Within(100)); } [Test] @@ -78,7 +75,6 @@ namespace osu.Game.Rulesets.Osu.Tests { double finalCumulativeTrackerRotation = 0; double finalTrackerRotation = 0, trackerRotationTolerance = 0; - double finalSpinnerSymbolRotation = 0, spinnerSymbolRotationTolerance = 0; addSeekStep(spinner_start_time + 5000); AddStep("retrieve disc rotation", () => @@ -86,11 +82,6 @@ namespace osu.Game.Rulesets.Osu.Tests finalTrackerRotation = drawableSpinner.RotationTracker.Rotation; trackerRotationTolerance = Math.Abs(finalTrackerRotation * 0.05f); }); - AddStep("retrieve spinner symbol rotation", () => - { - finalSpinnerSymbolRotation = spinnerSymbol.Rotation; - spinnerSymbolRotationTolerance = Math.Abs(finalSpinnerSymbolRotation * 0.05f); - }); AddStep("retrieve cumulative disc rotation", () => finalCumulativeTrackerRotation = drawableSpinner.Result.RateAdjustedRotation); addSeekStep(spinner_start_time + 2500); @@ -98,20 +89,16 @@ namespace osu.Game.Rulesets.Osu.Tests // we want to make sure that the rotation at time 2500 is in the same direction as at time 5000, but about half-way in. // due to the exponential damping applied we're allowing a larger margin of error of about 10% // (5% relative to the final rotation value, but we're half-way through the spin). - () => Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, finalTrackerRotation / 2, trackerRotationTolerance)); - AddAssert("symbol rotation rewound", - () => Precision.AlmostEquals(spinnerSymbol.Rotation, finalSpinnerSymbolRotation / 2, spinnerSymbolRotationTolerance)); + () => drawableSpinner.RotationTracker.Rotation, () => Is.EqualTo(finalTrackerRotation / 2).Within(trackerRotationTolerance)); AddAssert("is cumulative rotation rewound", // cumulative rotation is not damped, so we're treating it as the "ground truth" and allowing a comparatively smaller margin of error. - () => Precision.AlmostEquals(drawableSpinner.Result.RateAdjustedRotation, finalCumulativeTrackerRotation / 2, 100)); + () => drawableSpinner.Result.RateAdjustedRotation, () => Is.EqualTo(finalCumulativeTrackerRotation / 2).Within(100)); addSeekStep(spinner_start_time + 5000); AddAssert("is disc rotation almost same", - () => Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, finalTrackerRotation, trackerRotationTolerance)); - AddAssert("is symbol rotation almost same", - () => Precision.AlmostEquals(spinnerSymbol.Rotation, finalSpinnerSymbolRotation, spinnerSymbolRotationTolerance)); + () => drawableSpinner.RotationTracker.Rotation, () => Is.EqualTo(finalTrackerRotation).Within(trackerRotationTolerance)); AddAssert("is cumulative rotation almost same", - () => Precision.AlmostEquals(drawableSpinner.Result.RateAdjustedRotation, finalCumulativeTrackerRotation, 100)); + () => drawableSpinner.Result.RateAdjustedRotation, () => Is.EqualTo(finalCumulativeTrackerRotation).Within(100)); } [Test] @@ -123,7 +110,6 @@ namespace osu.Game.Rulesets.Osu.Tests addSeekStep(5000); AddAssert("disc spin direction correct", () => clockwise ? drawableSpinner.RotationTracker.Rotation > 0 : drawableSpinner.RotationTracker.Rotation < 0); - AddAssert("spinner symbol direction correct", () => clockwise ? spinnerSymbol.Rotation > 0 : spinnerSymbol.Rotation < 0); } private Replay flip(Replay scoreReplay) => new Replay @@ -148,7 +134,7 @@ namespace osu.Game.Rulesets.Osu.Tests AddAssert("player score matching expected bonus score", () => { // multipled by 2 to nullify the score multiplier. (autoplay mod selected) - double totalScore = ((ScoreExposedPlayer)Player).ScoreProcessor.TotalScore.Value * 2; + long totalScore = ((ScoreExposedPlayer)Player).ScoreProcessor.TotalScore.Value * 2; return totalScore == (int)(drawableSpinner.Result.RateAdjustedRotation / 360) * new SpinnerTick().CreateJudgement().MaxNumericResult; }); @@ -175,10 +161,10 @@ namespace osu.Game.Rulesets.Osu.Tests AddStep("retrieve spm", () => estimatedSpm = drawableSpinner.SpinsPerMinute.Value); addSeekStep(2000); - AddAssert("spm still valid", () => Precision.AlmostEquals(drawableSpinner.SpinsPerMinute.Value, estimatedSpm, 1.0)); + AddAssert("spm still valid", () => drawableSpinner.SpinsPerMinute.Value, () => Is.EqualTo(estimatedSpm).Within(1.0)); addSeekStep(1000); - AddAssert("spm still valid", () => Precision.AlmostEquals(drawableSpinner.SpinsPerMinute.Value, estimatedSpm, 1.0)); + AddAssert("spm still valid", () => drawableSpinner.SpinsPerMinute.Value, () => Is.EqualTo(estimatedSpm).Within(1.0)); } [TestCase(0.5)] @@ -200,14 +186,14 @@ namespace osu.Game.Rulesets.Osu.Tests AddStep("adjust track rate", () => ((MasterGameplayClockContainer)Player.GameplayClockContainer).UserPlaybackRate.Value = rate); addSeekStep(1000); - AddAssert("progress almost same", () => Precision.AlmostEquals(expectedProgress, drawableSpinner.Progress, 0.05)); - AddAssert("spm almost same", () => Precision.AlmostEquals(expectedSpm, drawableSpinner.SpinsPerMinute.Value, 2.0)); + AddAssert("progress almost same", () => drawableSpinner.Progress, () => Is.EqualTo(expectedProgress).Within(0.05)); + AddAssert("spm almost same", () => drawableSpinner.SpinsPerMinute.Value, () => Is.EqualTo(expectedSpm).Within(2.0)); } private void addSeekStep(double time) { AddStep($"seek to {time}", () => Player.GameplayClockContainer.Seek(time)); - AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(time, Player.DrawableRuleset.FrameStableClock.CurrentTime, 100)); + AddUntilStep("wait for seek to finish", () => Player.DrawableRuleset.FrameStableClock.CurrentTime, () => Is.EqualTo(time).Within(100)); } private void transformReplay(Func replayTransformation) => AddStep("set replay", () => @@ -235,7 +221,7 @@ namespace osu.Game.Rulesets.Osu.Tests } }; - private class ScoreExposedPlayer : TestPlayer + private partial class ScoreExposedPlayer : TestPlayer { public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs index 53fa3624b8..29e6fc4301 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -25,7 +27,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneStartTimeOrderedHitPolicy : RateAdjustedBeatmapTestScene + public partial class TestSceneStartTimeOrderedHitPolicy : RateAdjustedBeatmapTestScene { private const double early_miss_window = 1000; // time after -1000 to -500 is considered a miss private const double late_miss_window = 500; // time after +500 is considered a miss @@ -432,7 +434,7 @@ namespace osu.Game.Rulesets.Osu.Tests protected override DifficultyRange[] GetRanges() => ranges; } - private class ScoreAccessibleReplayPlayer : ReplayPlayer + private partial class ScoreAccessibleReplayPlayer : ReplayPlayer { public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneTrianglesSpinnerRotation.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneTrianglesSpinnerRotation.cs new file mode 100644 index 0000000000..1fef408088 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneTrianglesSpinnerRotation.cs @@ -0,0 +1,149 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Replays; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Skinning; +using osu.Game.Storyboards; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public partial class TestSceneTrianglesSpinnerRotation : TestSceneOsuPlayer + { + private const double spinner_start_time = 100; + private const double spinner_duration = 6000; + + [Resolved] + private SkinManager skinManager { get; set; } = null!; + + [Resolved] + private AudioManager audioManager { get; set; } = null!; + + protected override bool Autoplay => true; + + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new ScoreExposedPlayer(); + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null) + => new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audioManager); + + private DrawableSpinner drawableSpinner = null!; + private SpriteIcon spinnerSymbol => drawableSpinner.ChildrenOfType().Single(); + + [SetUpSteps] + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("set triangles skin", () => skinManager.CurrentSkinInfo.Value = TrianglesSkin.CreateInfo().ToLiveUnmanaged()); + + AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); + AddStep("retrieve spinner", () => drawableSpinner = (DrawableSpinner)Player.DrawableRuleset.Playfield.AllHitObjects.First()); + } + + [Test] + public void TestSymbolMiddleRewindingRotation() + { + double finalSpinnerSymbolRotation = 0, spinnerSymbolRotationTolerance = 0; + + addSeekStep(spinner_start_time + 5000); + AddStep("retrieve spinner symbol rotation", () => + { + finalSpinnerSymbolRotation = spinnerSymbol.Rotation; + spinnerSymbolRotationTolerance = Math.Abs(finalSpinnerSymbolRotation * 0.05f); + }); + + addSeekStep(spinner_start_time + 2500); + AddAssert("symbol rotation rewound", + () => spinnerSymbol.Rotation, () => Is.EqualTo(finalSpinnerSymbolRotation / 2).Within(spinnerSymbolRotationTolerance)); + + addSeekStep(spinner_start_time + 5000); + AddAssert("is symbol rotation almost same", + () => spinnerSymbol.Rotation, () => Is.EqualTo(finalSpinnerSymbolRotation).Within(spinnerSymbolRotationTolerance)); + } + + [Test] + public void TestSymbolRotationDirection([Values(true, false)] bool clockwise) + { + if (clockwise) + transformReplay(flip); + + addSeekStep(5000); + AddAssert("spinner symbol direction correct", () => clockwise ? spinnerSymbol.Rotation > 0 : spinnerSymbol.Rotation < 0); + } + + private Replay flip(Replay scoreReplay) => new Replay + { + Frames = scoreReplay + .Frames + .Cast() + .Select(replayFrame => + { + var flippedPosition = new Vector2(OsuPlayfield.BASE_SIZE.X - replayFrame.Position.X, replayFrame.Position.Y); + return new OsuReplayFrame(replayFrame.Time, flippedPosition, replayFrame.Actions.ToArray()); + }) + .Cast() + .ToList() + }; + + private void addSeekStep(double time) + { + AddStep($"seek to {time}", () => Player.GameplayClockContainer.Seek(time)); + AddUntilStep("wait for seek to finish", () => Player.DrawableRuleset.FrameStableClock.CurrentTime, () => Is.EqualTo(time).Within(100)); + } + + private void transformReplay(Func replayTransformation) => AddStep("set replay", () => + { + var drawableRuleset = this.ChildrenOfType().Single(); + var score = drawableRuleset.ReplayScore; + var transformedScore = new Score + { + ScoreInfo = score.ScoreInfo, + Replay = replayTransformation.Invoke(score.Replay) + }; + drawableRuleset.SetReplayScore(transformedScore); + }); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new Beatmap + { + HitObjects = new List + { + new Spinner + { + Position = new Vector2(256, 192), + StartTime = spinner_start_time, + Duration = spinner_duration + }, + } + }; + + private partial class ScoreExposedPlayer : TestPlayer + { + public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; + + public ScoreExposedPlayer() + : base(false, false) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj index 2c0d3fd937..57900bffd7 100644 --- a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj +++ b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj @@ -1,12 +1,10 @@  - - - + + - - + WinExe diff --git a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmap.cs b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmap.cs index a5282877ee..df146a9511 100644 --- a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmap.cs +++ b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmap.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; diff --git a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs index d82186fb52..e9518895be 100644 --- a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs +++ b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK; using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects; diff --git a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs index f51f04bf87..da66669550 100644 --- a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs +++ b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapProcessor.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics; using osu.Game.Beatmaps; diff --git a/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs b/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs index 9589fd576f..b8ad61e6dd 100644 --- a/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs +++ b/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Configuration; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.UI; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs new file mode 100644 index 0000000000..6d1b4d1a15 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -0,0 +1,137 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using System; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators +{ + public static class AimEvaluator + { + private const double wide_angle_multiplier = 1.5; + private const double acute_angle_multiplier = 1.95; + private const double slider_multiplier = 1.35; + private const double velocity_change_multiplier = 0.75; + + /// + /// Evaluates the difficulty of aiming the current object, based on: + /// + /// cursor velocity to the current object, + /// angle difficulty, + /// sharp velocity increases, + /// and slider difficulty. + /// + /// + public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliders) + { + if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner) + return 0; + + var osuCurrObj = (OsuDifficultyHitObject)current; + var osuLastObj = (OsuDifficultyHitObject)current.Previous(0); + var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1); + + // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle. + double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.StrainTime; + + // But if the last object is a slider, then we extend the travel velocity through the slider into the current object. + if (osuLastObj.BaseObject is Slider && withSliders) + { + double travelVelocity = osuLastObj.TravelDistance / osuLastObj.TravelTime; // calculate the slider velocity from slider head to slider end. + double movementVelocity = osuCurrObj.MinimumJumpDistance / osuCurrObj.MinimumJumpTime; // calculate the movement velocity from slider end to current object + + currVelocity = Math.Max(currVelocity, movementVelocity + travelVelocity); // take the larger total combined velocity. + } + + // As above, do the same for the previous hitobject. + double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.StrainTime; + + if (osuLastLastObj.BaseObject is Slider && withSliders) + { + double travelVelocity = osuLastLastObj.TravelDistance / osuLastLastObj.TravelTime; + double movementVelocity = osuLastObj.MinimumJumpDistance / osuLastObj.MinimumJumpTime; + + prevVelocity = Math.Max(prevVelocity, movementVelocity + travelVelocity); + } + + double wideAngleBonus = 0; + double acuteAngleBonus = 0; + double sliderBonus = 0; + double velocityChangeBonus = 0; + + double aimStrain = currVelocity; // Start strain with regular velocity. + + if (Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime) < 1.25 * Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime)) // If rhythms are the same. + { + if (osuCurrObj.Angle != null && osuLastObj.Angle != null && osuLastLastObj.Angle != null) + { + double currAngle = osuCurrObj.Angle.Value; + double lastAngle = osuLastObj.Angle.Value; + double lastLastAngle = osuLastLastObj.Angle.Value; + + // Rewarding angles, take the smaller velocity as base. + double angleBonus = Math.Min(currVelocity, prevVelocity); + + wideAngleBonus = calcWideAngleBonus(currAngle); + acuteAngleBonus = calcAcuteAngleBonus(currAngle); + + if (osuCurrObj.StrainTime > 100) // Only buff deltaTime exceeding 300 bpm 1/2. + acuteAngleBonus = 0; + else + { + acuteAngleBonus *= calcAcuteAngleBonus(lastAngle) // Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern. + * Math.Min(angleBonus, 125 / osuCurrObj.StrainTime) // The maximum velocity we buff is equal to 125 / strainTime + * Math.Pow(Math.Sin(Math.PI / 2 * Math.Min(1, (100 - osuCurrObj.StrainTime) / 25)), 2) // scale buff from 150 bpm 1/4 to 200 bpm 1/4 + * Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.LazyJumpDistance, 50, 100) - 50) / 50), 2); // Buff distance exceeding 50 (radius) up to 100 (diameter). + } + + // Penalize wide angles if they're repeated, reducing the penalty as the lastAngle gets more acute. + wideAngleBonus *= angleBonus * (1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3))); + // Penalize acute angles if they're repeated, reducing the penalty as the lastLastAngle gets more obtuse. + acuteAngleBonus *= 0.5 + 0.5 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastLastAngle), 3))); + } + } + + if (Math.Max(prevVelocity, currVelocity) != 0) + { + // We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities. + prevVelocity = (osuLastObj.LazyJumpDistance + osuLastLastObj.TravelDistance) / osuLastObj.StrainTime; + currVelocity = (osuCurrObj.LazyJumpDistance + osuLastObj.TravelDistance) / osuCurrObj.StrainTime; + + // Scale with ratio of difference compared to 0.5 * max dist. + double distRatio = Math.Pow(Math.Sin(Math.PI / 2 * Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity)), 2); + + // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing. + double overlapVelocityBuff = Math.Min(125 / Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime), Math.Abs(prevVelocity - currVelocity)); + + velocityChangeBonus = overlapVelocityBuff * distRatio; + + // Penalize for rhythm changes. + velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime) / Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime), 2); + } + + if (osuLastObj.BaseObject is Slider) + { + // Reward sliders based on velocity. + sliderBonus = osuLastObj.TravelDistance / osuLastObj.TravelTime; + } + + // Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger. + aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier + velocityChangeBonus * velocity_change_multiplier); + + // Add in additional slider velocity bonus. + if (withSliders) + aimStrain += sliderBonus * slider_multiplier; + + return aimStrain; + } + + private static double calcWideAngleBonus(double angle) => Math.Pow(Math.Sin(3.0 / 4 * (Math.Min(5.0 / 6 * Math.PI, Math.Max(Math.PI / 6, angle)) - Math.PI / 6)), 2); + + private static double calcAcuteAngleBonus(double angle) => 1 - calcWideAngleBonus(angle); + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs new file mode 100644 index 0000000000..dabbfcd2fb --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/FlashlightEvaluator.cs @@ -0,0 +1,118 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using System; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators +{ + public static class FlashlightEvaluator + { + private const double max_opacity_bonus = 0.4; + private const double hidden_bonus = 0.2; + + private const double min_velocity = 0.5; + private const double slider_multiplier = 1.3; + + private const double min_angle_multiplier = 0.2; + + /// + /// Evaluates the difficulty of memorising and hitting an object, based on: + /// + /// distance between a number of previous objects and the current object, + /// the visual opacity of the current object, + /// the angle made by the current object, + /// length and speed of the current object (for sliders), + /// and whether the hidden mod is enabled. + /// + /// + public static double EvaluateDifficultyOf(DifficultyHitObject current, bool hidden) + { + if (current.BaseObject is Spinner) + return 0; + + var osuCurrent = (OsuDifficultyHitObject)current; + var osuHitObject = (OsuHitObject)(osuCurrent.BaseObject); + + double scalingFactor = 52.0 / osuHitObject.Radius; + double smallDistNerf = 1.0; + double cumulativeStrainTime = 0.0; + + double result = 0.0; + + OsuDifficultyHitObject lastObj = osuCurrent; + + double angleRepeatCount = 0.0; + + // This is iterating backwards in time from the current object. + for (int i = 0; i < Math.Min(current.Index, 10); i++) + { + var currentObj = (OsuDifficultyHitObject)current.Previous(i); + var currentHitObject = (OsuHitObject)(currentObj.BaseObject); + + if (!(currentObj.BaseObject is Spinner)) + { + double jumpDistance = (osuHitObject.StackedPosition - currentHitObject.StackedEndPosition).Length; + + cumulativeStrainTime += lastObj.StrainTime; + + // We want to nerf objects that can be easily seen within the Flashlight circle radius. + if (i == 0) + smallDistNerf = Math.Min(1.0, jumpDistance / 75.0); + + // We also want to nerf stacks so that only the first object of the stack is accounted for. + double stackNerf = Math.Min(1.0, (currentObj.LazyJumpDistance / scalingFactor) / 25.0); + + // Bonus based on how visible the object is. + double opacityBonus = 1.0 + max_opacity_bonus * (1.0 - osuCurrent.OpacityAt(currentHitObject.StartTime, hidden)); + + result += stackNerf * opacityBonus * scalingFactor * jumpDistance / cumulativeStrainTime; + + if (currentObj.Angle != null && osuCurrent.Angle != null) + { + // Objects further back in time should count less for the nerf. + if (Math.Abs(currentObj.Angle.Value - osuCurrent.Angle.Value) < 0.02) + angleRepeatCount += Math.Max(1.0 - 0.1 * i, 0.0); + } + } + + lastObj = currentObj; + } + + result = Math.Pow(smallDistNerf * result, 2.0); + + // Additional bonus for Hidden due to there being no approach circles. + if (hidden) + result *= 1.0 + hidden_bonus; + + // Nerf patterns with repeated angles. + result *= min_angle_multiplier + (1.0 - min_angle_multiplier) / (angleRepeatCount + 1.0); + + double sliderBonus = 0.0; + + if (osuCurrent.BaseObject is Slider osuSlider) + { + // Invert the scaling factor to determine the true travel distance independent of circle size. + double pixelTravelDistance = osuSlider.LazyTravelDistance / scalingFactor; + + // Reward sliders based on velocity. + sliderBonus = Math.Pow(Math.Max(0.0, pixelTravelDistance / osuCurrent.TravelTime - min_velocity), 0.5); + + // Longer sliders require more memorisation. + sliderBonus *= pixelTravelDistance; + + // Nerf sliders with repeats, as less memorisation is required. + if (osuSlider.RepeatCount > 0) + sliderBonus /= (osuSlider.RepeatCount + 1); + } + + result += sliderBonus * slider_multiplier; + + return result; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs new file mode 100644 index 0000000000..3bec2346ce --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs @@ -0,0 +1,110 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using System; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators +{ + public static class RhythmEvaluator + { + private const int history_time_max = 5000; // 5 seconds of calculatingRhythmBonus max. + private const double rhythm_multiplier = 0.75; + + /// + /// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current . + /// + public static double EvaluateDifficultyOf(DifficultyHitObject current) + { + if (current.BaseObject is Spinner) + return 0; + + int previousIslandSize = 0; + + double rhythmComplexitySum = 0; + int islandSize = 1; + double startRatio = 0; // store the ratio of the current start of an island to buff for tighter rhythms + + bool firstDeltaSwitch = false; + + int historicalNoteCount = Math.Min(current.Index, 32); + + int rhythmStart = 0; + + while (rhythmStart < historicalNoteCount - 2 && current.StartTime - current.Previous(rhythmStart).StartTime < history_time_max) + rhythmStart++; + + for (int i = rhythmStart; i > 0; i--) + { + OsuDifficultyHitObject currObj = (OsuDifficultyHitObject)current.Previous(i - 1); + OsuDifficultyHitObject prevObj = (OsuDifficultyHitObject)current.Previous(i); + OsuDifficultyHitObject lastObj = (OsuDifficultyHitObject)current.Previous(i + 1); + + double currHistoricalDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max; // scales note 0 to 1 from history to now + + currHistoricalDecay = Math.Min((double)(historicalNoteCount - i) / historicalNoteCount, currHistoricalDecay); // either we're limited by time or limited by object count. + + double currDelta = currObj.StrainTime; + double prevDelta = prevObj.StrainTime; + double lastDelta = lastObj.StrainTime; + double currRatio = 1.0 + 6.0 * Math.Min(0.5, Math.Pow(Math.Sin(Math.PI / (Math.Min(prevDelta, currDelta) / Math.Max(prevDelta, currDelta))), 2)); // fancy function to calculate rhythmbonuses. + + double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - currObj.HitWindowGreat * 0.3) / (currObj.HitWindowGreat * 0.3)); + + windowPenalty = Math.Min(1, windowPenalty); + + double effectiveRatio = windowPenalty * currRatio; + + if (firstDeltaSwitch) + { + if (!(prevDelta > 1.25 * currDelta || prevDelta * 1.25 < currDelta)) + { + if (islandSize < 7) + islandSize++; // island is still progressing, count size. + } + else + { + if (current.Previous(i - 1).BaseObject is Slider) // bpm change is into slider, this is easy acc window + effectiveRatio *= 0.125; + + if (current.Previous(i).BaseObject is Slider) // bpm change was from a slider, this is easier typically than circle -> circle + effectiveRatio *= 0.25; + + if (previousIslandSize == islandSize) // repeated island size (ex: triplet -> triplet) + effectiveRatio *= 0.25; + + if (previousIslandSize % 2 == islandSize % 2) // repeated island polartiy (2 -> 4, 3 -> 5) + effectiveRatio *= 0.50; + + if (lastDelta > prevDelta + 10 && prevDelta > currDelta + 10) // previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. + effectiveRatio *= 0.125; + + rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay * Math.Sqrt(4 + islandSize) / 2 * Math.Sqrt(4 + previousIslandSize) / 2; + + startRatio = effectiveRatio; + + previousIslandSize = islandSize; // log the last island size. + + if (prevDelta * 1.25 < currDelta) // we're slowing down, stop counting + firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size. + + islandSize = 1; + } + } + else if (prevDelta > 1.25 * currDelta) // we want to be speeding up. + { + // Begin counting island until we change speed again. + firstDeltaSwitch = true; + startRatio = effectiveRatio; + islandSize = 1; + } + } + + return Math.Sqrt(4 + rhythmComplexitySum * rhythm_multiplier) / 2; //produces multiplier that can be applied to strain. range [1, infinity) (not really though) + } + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs new file mode 100644 index 0000000000..c98f875eb5 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs @@ -0,0 +1,67 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using System; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators +{ + public static class SpeedEvaluator + { + private const double single_spacing_threshold = 125; + private const double min_speed_bonus = 75; // ~200BPM + private const double speed_balancing_factor = 40; + + /// + /// Evaluates the difficulty of tapping the current object, based on: + /// + /// time between pressing the previous and current object, + /// distance between those objects, + /// and how easily they can be cheesed. + /// + /// + public static double EvaluateDifficultyOf(DifficultyHitObject current) + { + if (current.BaseObject is Spinner) + return 0; + + // derive strainTime for calculation + var osuCurrObj = (OsuDifficultyHitObject)current; + var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null; + var osuNextObj = (OsuDifficultyHitObject)current.Next(0); + + double strainTime = osuCurrObj.StrainTime; + double doubletapness = 1; + + // Nerf doubletappable doubles. + if (osuNextObj != null) + { + double currDeltaTime = Math.Max(1, osuCurrObj.DeltaTime); + double nextDeltaTime = Math.Max(1, osuNextObj.DeltaTime); + double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime); + double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference); + double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / osuCurrObj.HitWindowGreat), 2); + doubletapness = Math.Pow(speedRatio, 1 - windowRatio); + } + + // Cap deltatime to the OD 300 hitwindow. + // 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap. + strainTime /= Math.Clamp((strainTime / osuCurrObj.HitWindowGreat) / 0.93, 0.92, 1); + + // derive speedBonus for calculation + double speedBonus = 1.0; + + if (strainTime < min_speed_bonus) + speedBonus = 1 + 0.75 * Math.Pow((min_speed_bonus - strainTime) / speed_balancing_factor, 2); + + double travelDistance = osuPrevObj?.TravelDistance ?? 0; + double distance = Math.Min(single_spacing_threshold, travelDistance + osuCurrObj.MinimumJumpDistance); + + return (speedBonus + speedBonus * Math.Pow(distance / single_spacing_threshold, 3.5)) * doubletapness / strainTime; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs index 3deed4ea3d..03540abddb 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; using Newtonsoft.Json; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; @@ -24,6 +25,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty [JsonProperty("speed_difficulty")] public double SpeedDifficulty { get; set; } + /// + /// The number of clickable objects weighted by difficulty. + /// Related to + /// + [JsonProperty("speed_note_count")] + public double SpeedNoteCount { get; set; } + /// /// The difficulty corresponding to the flashlight skill. /// @@ -92,11 +100,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty yield return (ATTRIB_ID_FLASHLIGHT, FlashlightDifficulty); yield return (ATTRIB_ID_SLIDER_FACTOR, SliderFactor); + yield return (ATTRIB_ID_SPEED_NOTE_COUNT, SpeedNoteCount); } - public override void FromDatabaseAttributes(IReadOnlyDictionary values) + public override void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo) { - base.FromDatabaseAttributes(values); + base.FromDatabaseAttributes(values, onlineInfo); AimDifficulty = values[ATTRIB_ID_AIM]; SpeedDifficulty = values[ATTRIB_ID_SPEED]; @@ -106,6 +115,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty StarRating = values[ATTRIB_ID_DIFFICULTY]; FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT); SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR]; + SpeedNoteCount = values[ATTRIB_ID_SPEED_NOTE_COUNT]; + + DrainRate = onlineInfo.DrainRate; + HitCircleCount = onlineInfo.CircleCount; + SliderCount = onlineInfo.SliderCount; + SpinnerCount = onlineInfo.SpinnerCount; } #region Newtonsoft.Json implicit ShouldSerialize() methods diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 4eb5c79808..1e83d6d820 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -21,7 +23,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty public class OsuDifficultyCalculator : DifficultyCalculator { private const double difficulty_multiplier = 0.0675; - private double hitWindowGreat; + + public override int Version => 20220902; public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) @@ -36,12 +39,23 @@ namespace osu.Game.Rulesets.Osu.Difficulty double aimRating = Math.Sqrt(skills[0].DifficultyValue()) * difficulty_multiplier; double aimRatingNoSliders = Math.Sqrt(skills[1].DifficultyValue()) * difficulty_multiplier; double speedRating = Math.Sqrt(skills[2].DifficultyValue()) * difficulty_multiplier; + double speedNotes = ((Speed)skills[2]).RelevantNoteCount(); double flashlightRating = Math.Sqrt(skills[3].DifficultyValue()) * difficulty_multiplier; double sliderFactor = aimRating > 0 ? aimRatingNoSliders / aimRating : 1; + if (mods.Any(m => m is OsuModTouchDevice)) + { + aimRating = Math.Pow(aimRating, 0.8); + flashlightRating = Math.Pow(flashlightRating, 0.8); + } + if (mods.Any(h => h is OsuModRelax)) + { + aimRating *= 0.9; speedRating = 0.0; + flashlightRating *= 0.7; + } double baseAimPerformance = Math.Pow(5 * Math.Max(1, aimRating / 0.0675) - 4, 3) / 100000; double baseSpeedPerformance = Math.Pow(5 * Math.Max(1, speedRating / 0.0675) - 4, 3) / 100000; @@ -57,7 +71,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1 ); - double starRating = basePerformance > 0.00001 ? Math.Cbrt(1.12) * 0.027 * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4) : 0; + double starRating = basePerformance > 0.00001 ? Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * 0.027 * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4) : 0; double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate; double drainRate = beatmap.Difficulty.DrainRate; @@ -67,12 +81,18 @@ namespace osu.Game.Rulesets.Osu.Difficulty int sliderCount = beatmap.HitObjects.Count(h => h is Slider); int spinnerCount = beatmap.HitObjects.Count(h => h is Spinner); + HitWindows hitWindows = new OsuHitWindows(); + hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); + + double hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate; + return new OsuDifficultyAttributes { StarRating = starRating, Mods = mods, AimDifficulty = aimRating, SpeedDifficulty = speedRating, + SpeedNoteCount = speedNotes, FlashlightDifficulty = flashlightRating, SliderFactor = sliderFactor, ApproachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5, @@ -87,36 +107,33 @@ namespace osu.Game.Rulesets.Osu.Difficulty protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) { + List objects = new List(); + // The first jump is formed by the first two hitobjects of the map. // If the map has less than two OsuHitObjects, the enumerator will not return anything. for (int i = 1; i < beatmap.HitObjects.Count; i++) { var lastLast = i > 1 ? beatmap.HitObjects[i - 2] : null; - var last = beatmap.HitObjects[i - 1]; - var current = beatmap.HitObjects[i]; - - yield return new OsuDifficultyHitObject(current, lastLast, last, clockRate); + objects.Add(new OsuDifficultyHitObject(beatmap.HitObjects[i], beatmap.HitObjects[i - 1], lastLast, clockRate, objects, objects.Count)); } + + return objects; } protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) { - HitWindows hitWindows = new OsuHitWindows(); - hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); - - hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate; - return new Skill[] { new Aim(mods, true), new Aim(mods, false), - new Speed(mods, hitWindowGreat), + new Speed(mods), new Flashlight(mods) }; } protected override Mod[] DifficultyAdjustmentMods => new Mod[] { + new OsuModTouchDevice(), new OsuModDoubleTime(), new OsuModHalfTime(), new OsuModEasy(), diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs index 0aeaf7669f..efb3ade220 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using Newtonsoft.Json; using osu.Game.Rulesets.Difficulty; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index d046be9ccb..30b56ff769 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -13,6 +15,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty { public class OsuPerformanceCalculator : PerformanceCalculator { + public const double PERFORMANCE_BASE_MULTIPLIER = 1.14; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. + private double accuracy; private int scoreMaxCombo; private int countGreat; @@ -39,7 +43,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); effectiveMissCount = calculateEffectiveMissCount(osuAttributes); - double multiplier = 1.12; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. + double multiplier = PERFORMANCE_BASE_MULTIPLIER; if (score.Mods.Any(m => m is OsuModNoFail)) multiplier *= Math.Max(0.90, 1.0 - 0.02 * effectiveMissCount); @@ -49,10 +53,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (score.Mods.Any(h => h is OsuModRelax)) { - // As we're adding Oks and Mehs to an approximated number of combo breaks the result can be higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it. - effectiveMissCount = Math.Min(effectiveMissCount + countOk + countMeh, totalHits); + // https://www.desmos.com/calculator/bc9eybdthb + // we use OD13.3 as maximum since it's the value at which great hitwidow becomes 0 + // this is well beyond currently maximum achievable OD which is 12.17 (DTx2 + DA with OD11) + double okMultiplier = Math.Max(0.0, osuAttributes.OverallDifficulty > 0.0 ? 1 - Math.Pow(osuAttributes.OverallDifficulty / 13.33, 1.8) : 1.0); + double mehMultiplier = Math.Max(0.0, osuAttributes.OverallDifficulty > 0.0 ? 1 - Math.Pow(osuAttributes.OverallDifficulty / 13.33, 5) : 1.0); - multiplier *= 0.6; + // As we're adding Oks and Mehs to an approximated number of combo breaks the result can be higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it. + effectiveMissCount = Math.Min(effectiveMissCount + countOk * okMultiplier + countMeh * mehMultiplier, totalHits); } double aimValue = computeAimValue(score, osuAttributes); @@ -80,12 +88,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attributes) { - double rawAim = attributes.AimDifficulty; - - if (score.Mods.Any(m => m is OsuModTouchDevice)) - rawAim = Math.Pow(rawAim, 0.8); - - double aimValue = Math.Pow(5.0 * Math.Max(1.0, rawAim / 0.0675) - 4.0, 3.0) / 100000.0; + double aimValue = Math.Pow(5.0 * Math.Max(1.0, attributes.AimDifficulty / 0.0675) - 4.0, 3.0) / 100000.0; double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); @@ -101,7 +104,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (attributes.ApproachRate > 10.33) approachRateFactor = 0.3 * (attributes.ApproachRate - 10.33); else if (attributes.ApproachRate < 8.0) - approachRateFactor = 0.1 * (8.0 - attributes.ApproachRate); + approachRateFactor = 0.05 * (8.0 - attributes.ApproachRate); + + if (score.Mods.Any(h => h is OsuModRelax)) + approachRateFactor = 0.0; aimValue *= 1.0 + approachRateFactor * lengthBonus; // Buff for longer maps with high AR. @@ -132,6 +138,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attributes) { + if (score.Mods.Any(h => h is OsuModRelax)) + return 0.0; + double speedValue = Math.Pow(5.0 * Math.Max(1.0, attributes.SpeedDifficulty / 0.0675) - 4.0, 3.0) / 100000.0; double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + @@ -161,11 +170,18 @@ namespace osu.Game.Rulesets.Osu.Difficulty speedValue *= 1.0 + 0.04 * (12.0 - attributes.ApproachRate); } + // Calculate accuracy assuming the worst case scenario + double relevantTotalDiff = totalHits - attributes.SpeedNoteCount; + double relevantCountGreat = Math.Max(0, countGreat - relevantTotalDiff); + double relevantCountOk = Math.Max(0, countOk - Math.Max(0, relevantTotalDiff - countGreat)); + double relevantCountMeh = Math.Max(0, countMeh - Math.Max(0, relevantTotalDiff - countGreat - countOk)); + double relevantAccuracy = attributes.SpeedNoteCount == 0 ? 0 : (relevantCountGreat * 6.0 + relevantCountOk * 2.0 + relevantCountMeh) / (attributes.SpeedNoteCount * 6.0); + // Scale the speed value with accuracy and OD. - speedValue *= (0.95 + Math.Pow(attributes.OverallDifficulty, 2) / 750) * Math.Pow(accuracy, (14.5 - Math.Max(attributes.OverallDifficulty, 8)) / 2); + speedValue *= (0.95 + Math.Pow(attributes.OverallDifficulty, 2) / 750) * Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - Math.Max(attributes.OverallDifficulty, 8)) / 2); // Scale the speed value with # of 50s to punish doubletapping. - speedValue *= Math.Pow(0.98, countMeh < totalHits / 500.0 ? 0 : countMeh - totalHits / 500.0); + speedValue *= Math.Pow(0.99, countMeh < totalHits / 500.0 ? 0 : countMeh - totalHits / 500.0); return speedValue; } @@ -212,12 +228,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (!score.Mods.Any(h => h is OsuModFlashlight)) return 0.0; - double rawFlashlight = attributes.FlashlightDifficulty; - - if (score.Mods.Any(m => m is OsuModTouchDevice)) - rawFlashlight = Math.Pow(rawFlashlight, 0.8); - - double flashlightValue = Math.Pow(rawFlashlight, 2.0) * 25.0; + double flashlightValue = Math.Pow(attributes.FlashlightDifficulty, 2.0) * 25.0; // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses. if (effectiveMissCount > 0) @@ -249,14 +260,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty comboBasedMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); } - // Clamp miss count since it's derived from combo and can be higher than total hits and that breaks some calculations - comboBasedMissCount = Math.Min(comboBasedMissCount, totalHits); + // Clamp miss count to maximum amount of possible breaks + comboBasedMissCount = Math.Min(comboBasedMissCount, countOk + countMeh + countMiss); return Math.Max(countMiss, comboBasedMissCount); } private double getComboScalingFactor(OsuDifficultyAttributes attributes) => attributes.MaxCombo <= 0 ? 1.0 : Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(attributes.MaxCombo, 0.8), 1.0); private int totalHits => countGreat + countOk + countMeh + countMiss; - private int totalSuccessfulHits => countGreat + countOk + countMeh; } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index cf4802d282..6aea00fd35 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -1,21 +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 System.Collections.Generic; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; using osuTK; namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing { public class OsuDifficultyHitObject : DifficultyHitObject { - private const int normalised_radius = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths. + /// + /// A distance by which all distances should be scaled in order to assume a uniform circle size. + /// + public const int NORMALISED_RADIUS = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths. + private const int min_delta_time = 25; - private const float maximum_slider_radius = normalised_radius * 2.4f; - private const float assumed_slider_radius = normalised_radius * 1.8f; + private const float maximum_slider_radius = NORMALISED_RADIUS * 2.4f; + private const float assumed_slider_radius = NORMALISED_RADIUS * 1.8f; protected new OsuHitObject BaseObject => (OsuHitObject)base.BaseObject; @@ -61,7 +69,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing public double TravelDistance { get; private set; } /// - /// The time taken to travel through , with a minimum value of 25ms for a non-zero distance. + /// The time taken to travel through , with a minimum value of 25ms for objects. /// public double TravelTime { get; private set; } @@ -71,11 +79,16 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing /// public double? Angle { get; private set; } + /// + /// Retrieves the full hit window for a Great . + /// + public double HitWindowGreat { get; private set; } + private readonly OsuHitObject lastLastObject; private readonly OsuHitObject lastObject; - public OsuDifficultyHitObject(HitObject hitObject, HitObject lastLastObject, HitObject lastObject, double clockRate) - : base(hitObject, lastObject, clockRate) + public OsuDifficultyHitObject(HitObject hitObject, HitObject lastObject, HitObject lastLastObject, double clockRate, List objects, int index) + : base(hitObject, lastObject, clockRate, objects, index) { this.lastLastObject = (OsuHitObject)lastLastObject; this.lastObject = (OsuHitObject)lastObject; @@ -83,6 +96,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing // Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects. StrainTime = Math.Max(DeltaTime, min_delta_time); + if (BaseObject is Slider sliderObject) + { + HitWindowGreat = 2 * sliderObject.HeadCircle.HitWindows.WindowFor(HitResult.Great) / clockRate; + } + else + { + HitWindowGreat = 2 * BaseObject.HitWindows.WindowFor(HitResult.Great) / clockRate; + } + setDistances(clockRate); } @@ -120,7 +142,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing if (BaseObject is Slider currentSlider) { computeSliderCursorPosition(currentSlider); - TravelDistance = currentSlider.LazyTravelDistance; + // Bonus for repeat sliders until a better per nested object strain system can be achieved. + TravelDistance = currentSlider.LazyTravelDistance * (float)Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5); TravelTime = Math.Max(currentSlider.LazyTravelTime / clockRate, min_delta_time); } @@ -129,7 +152,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing return; // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps. - float scalingFactor = normalised_radius / (float)BaseObject.Radius; + float scalingFactor = NORMALISED_RADIUS / (float)BaseObject.Radius; if (BaseObject.Radius < 30) { @@ -203,7 +226,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing slider.LazyEndPosition = slider.StackedPosition + slider.Path.PositionAt(endTimeMin); // temporary lazy end position until a real result can be derived. var currCursorPosition = slider.StackedPosition; - double scalingFactor = normalised_radius / slider.Radius; // lazySliderDistance is coded to be sensitive to scaling, this makes the maths easier with the thresholds being used. + double scalingFactor = NORMALISED_RADIUS / slider.Radius; // lazySliderDistance is coded to be sensitive to scaling, this makes the maths easier with the thresholds being used. for (int i = 1; i < slider.NestedHitObjects.Count; i++) { @@ -231,7 +254,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing else if (currMovementObj is SliderRepeat) { // For a slider repeat, assume a tighter movement threshold to better assess repeat sliders. - requiredMovement = normalised_radius; + requiredMovement = NORMALISED_RADIUS; } if (currMovementLength > requiredMovement) @@ -245,8 +268,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing if (i == slider.NestedHitObjects.Count - 1) slider.LazyEndPosition = currCursorPosition; } - - slider.LazyTravelDistance *= (float)Math.Pow(1 + slider.RepeatCount / 2.5, 1.0 / 2.5); // Bonus for repeat sliders until a better per nested object strain system can be achieved. } private Vector2 getEndCursorPosition(OsuHitObject hitObject) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index a6301aed6d..38e0e5b677 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -1,11 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; -using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Difficulty.Evaluators; namespace osu.Game.Rulesets.Osu.Difficulty.Skills { @@ -22,142 +23,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills private readonly bool withSliders; - protected override int HistoryLength => 2; - - private const double wide_angle_multiplier = 1.5; - private const double acute_angle_multiplier = 2.0; - private const double slider_multiplier = 1.5; - private const double velocity_change_multiplier = 0.75; - private double currentStrain; - private double skillMultiplier => 23.25; + private double skillMultiplier => 23.55; private double strainDecayBase => 0.15; - private double strainValueOf(DifficultyHitObject current) - { - if (current.BaseObject is Spinner || Previous.Count <= 1 || Previous[0].BaseObject is Spinner) - return 0; - - var osuCurrObj = (OsuDifficultyHitObject)current; - var osuLastObj = (OsuDifficultyHitObject)Previous[0]; - var osuLastLastObj = (OsuDifficultyHitObject)Previous[1]; - - // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle. - double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.StrainTime; - - // But if the last object is a slider, then we extend the travel velocity through the slider into the current object. - if (osuLastObj.BaseObject is Slider && withSliders) - { - double travelVelocity = osuLastObj.TravelDistance / osuLastObj.TravelTime; // calculate the slider velocity from slider head to slider end. - double movementVelocity = osuCurrObj.MinimumJumpDistance / osuCurrObj.MinimumJumpTime; // calculate the movement velocity from slider end to current object - - currVelocity = Math.Max(currVelocity, movementVelocity + travelVelocity); // take the larger total combined velocity. - } - - // As above, do the same for the previous hitobject. - double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.StrainTime; - - if (osuLastLastObj.BaseObject is Slider && withSliders) - { - double travelVelocity = osuLastLastObj.TravelDistance / osuLastLastObj.TravelTime; - double movementVelocity = osuLastObj.MinimumJumpDistance / osuLastObj.MinimumJumpTime; - - prevVelocity = Math.Max(prevVelocity, movementVelocity + travelVelocity); - } - - double wideAngleBonus = 0; - double acuteAngleBonus = 0; - double sliderBonus = 0; - double velocityChangeBonus = 0; - - double aimStrain = currVelocity; // Start strain with regular velocity. - - if (Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime) < 1.25 * Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime)) // If rhythms are the same. - { - if (osuCurrObj.Angle != null && osuLastObj.Angle != null && osuLastLastObj.Angle != null) - { - double currAngle = osuCurrObj.Angle.Value; - double lastAngle = osuLastObj.Angle.Value; - double lastLastAngle = osuLastLastObj.Angle.Value; - - // Rewarding angles, take the smaller velocity as base. - double angleBonus = Math.Min(currVelocity, prevVelocity); - - wideAngleBonus = calcWideAngleBonus(currAngle); - acuteAngleBonus = calcAcuteAngleBonus(currAngle); - - if (osuCurrObj.StrainTime > 100) // Only buff deltaTime exceeding 300 bpm 1/2. - acuteAngleBonus = 0; - else - { - acuteAngleBonus *= calcAcuteAngleBonus(lastAngle) // Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern. - * Math.Min(angleBonus, 125 / osuCurrObj.StrainTime) // The maximum velocity we buff is equal to 125 / strainTime - * Math.Pow(Math.Sin(Math.PI / 2 * Math.Min(1, (100 - osuCurrObj.StrainTime) / 25)), 2) // scale buff from 150 bpm 1/4 to 200 bpm 1/4 - * Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.LazyJumpDistance, 50, 100) - 50) / 50), 2); // Buff distance exceeding 50 (radius) up to 100 (diameter). - } - - // Penalize wide angles if they're repeated, reducing the penalty as the lastAngle gets more acute. - wideAngleBonus *= angleBonus * (1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3))); - // Penalize acute angles if they're repeated, reducing the penalty as the lastLastAngle gets more obtuse. - acuteAngleBonus *= 0.5 + 0.5 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastLastAngle), 3))); - } - } - - if (Math.Max(prevVelocity, currVelocity) != 0) - { - // We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities. - prevVelocity = (osuLastObj.LazyJumpDistance + osuLastLastObj.TravelDistance) / osuLastObj.StrainTime; - currVelocity = (osuCurrObj.LazyJumpDistance + osuLastObj.TravelDistance) / osuCurrObj.StrainTime; - - // Scale with ratio of difference compared to 0.5 * max dist. - double distRatio = Math.Pow(Math.Sin(Math.PI / 2 * Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity)), 2); - - // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing. - double overlapVelocityBuff = Math.Min(125 / Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime), Math.Abs(prevVelocity - currVelocity)); - - // Reward for % distance slowed down compared to previous, paying attention to not award overlap - double nonOverlapVelocityBuff = Math.Abs(prevVelocity - currVelocity) - // do not award overlap - * Math.Pow(Math.Sin(Math.PI / 2 * Math.Min(1, Math.Min(osuCurrObj.LazyJumpDistance, osuLastObj.LazyJumpDistance) / 100)), 2); - - // Choose the largest bonus, multiplied by ratio. - velocityChangeBonus = Math.Max(overlapVelocityBuff, nonOverlapVelocityBuff) * distRatio; - - // Penalize for rhythm changes. - velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime) / Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime), 2); - } - - if (osuLastObj.TravelTime != 0) - { - // Reward sliders based on velocity. - sliderBonus = osuLastObj.TravelDistance / osuLastObj.TravelTime; - } - - // Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger. - aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier + velocityChangeBonus * velocity_change_multiplier); - - // Add in additional slider velocity bonus. - if (withSliders) - aimStrain += sliderBonus * slider_multiplier; - - return aimStrain; - } - - private double calcWideAngleBonus(double angle) => Math.Pow(Math.Sin(3.0 / 4 * (Math.Min(5.0 / 6 * Math.PI, Math.Max(Math.PI / 6, angle)) - Math.PI / 6)), 2); - - private double calcAcuteAngleBonus(double angle) => 1 - calcWideAngleBonus(angle); - - private double applyDiminishingExp(double val) => Math.Pow(val, 0.99); - private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); - protected override double CalculateInitialStrain(double time) => currentStrain * strainDecay(time - Previous[0].StartTime); + protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => currentStrain * strainDecay(time - current.Previous(0).StartTime); protected override double StrainValueAt(DifficultyHitObject current) { currentStrain *= strainDecay(current.DeltaTime); - currentStrain += strainValueOf(current) * skillMultiplier; + currentStrain += AimEvaluator.EvaluateDifficultyOf(current, withSliders) * skillMultiplier; return currentStrain; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs index d93007fae5..40448c444c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs @@ -1,102 +1,48 @@ // 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.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Difficulty.Evaluators; using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; -using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Difficulty.Skills { /// /// Represents the skill required to memorise and hit every object in a map with the Flashlight mod enabled. /// - public class Flashlight : OsuStrainSkill + public class Flashlight : StrainSkill { + private readonly bool hasHiddenMod; + public Flashlight(Mod[] mods) : base(mods) { - hidden = mods.Any(m => m is OsuModHidden); + hasHiddenMod = mods.Any(m => m is OsuModHidden); } - private double skillMultiplier => 0.05; + private double skillMultiplier => 0.052; private double strainDecayBase => 0.15; - protected override double DecayWeight => 1.0; - protected override int HistoryLength => 10; // Look back for 10 notes is added for the sake of flashlight calculations. - - private readonly bool hidden; - - private const double max_opacity_bonus = 0.4; - private const double hidden_bonus = 0.2; private double currentStrain; - private double strainValueOf(DifficultyHitObject current) - { - if (current.BaseObject is Spinner) - return 0; - - var osuCurrent = (OsuDifficultyHitObject)current; - var osuHitObject = (OsuHitObject)(osuCurrent.BaseObject); - - double scalingFactor = 52.0 / osuHitObject.Radius; - double smallDistNerf = 1.0; - double cumulativeStrainTime = 0.0; - - double result = 0.0; - - OsuDifficultyHitObject lastObj = osuCurrent; - - // This is iterating backwards in time from the current object. - for (int i = 0; i < Previous.Count; i++) - { - var currentObj = (OsuDifficultyHitObject)Previous[i]; - var currentHitObject = (OsuHitObject)(currentObj.BaseObject); - - if (!(currentObj.BaseObject is Spinner)) - { - double jumpDistance = (osuHitObject.StackedPosition - currentHitObject.EndPosition).Length; - - cumulativeStrainTime += lastObj.StrainTime; - - // We want to nerf objects that can be easily seen within the Flashlight circle radius. - if (i == 0) - smallDistNerf = Math.Min(1.0, jumpDistance / 75.0); - - // We also want to nerf stacks so that only the first object of the stack is accounted for. - double stackNerf = Math.Min(1.0, (currentObj.LazyJumpDistance / scalingFactor) / 25.0); - - // Bonus based on how visible the object is. - double opacityBonus = 1.0 + max_opacity_bonus * (1.0 - osuCurrent.OpacityAt(currentHitObject.StartTime, hidden)); - - result += stackNerf * opacityBonus * scalingFactor * jumpDistance / cumulativeStrainTime; - } - - lastObj = currentObj; - } - - result = Math.Pow(smallDistNerf * result, 2.0); - - // Additional bonus for Hidden due to there being no approach circles. - if (hidden) - result *= 1.0 + hidden_bonus; - - return result; - } - private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); - protected override double CalculateInitialStrain(double time) => currentStrain * strainDecay(time - Previous[0].StartTime); + protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => currentStrain * strainDecay(time - current.Previous(0).StartTime); protected override double StrainValueAt(DifficultyHitObject current) { currentStrain *= strainDecay(current.DeltaTime); - currentStrain += strainValueOf(current) * skillMultiplier; + currentStrain += FlashlightEvaluator.EvaluateDifficultyOf(current, hasHiddenMod) * skillMultiplier; return currentStrain; } + + public override double DifficultyValue() => GetCurrentStrainPeaks().Sum() * OsuStrainSkill.DEFAULT_DIFFICULTY_MULTIPLIER; } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs index dcd2c7d321..d6683ade05 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using osu.Game.Rulesets.Difficulty.Skills; @@ -12,6 +14,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills { public abstract class OsuStrainSkill : StrainSkill { + /// + /// The default multiplier applied by to the final difficulty value after all other calculations. + /// May be overridden via . + /// + public const double DEFAULT_DIFFICULTY_MULTIPLIER = 1.06; + /// /// The number of sections with the highest strains, which the peak strain reductions will apply to. /// This is done in order to decrease their impact on the overall difficulty of the map for this skill. @@ -26,7 +34,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// /// The final multiplier to be applied to after all other calculations. /// - protected virtual double DifficultyMultiplier => 1.06; + protected virtual double DifficultyMultiplier => DEFAULT_DIFFICULTY_MULTIPLIER; protected OsuStrainSkill(Mod[] mods) : base(mods) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 06d1ef7346..efe0e136bf 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -1,12 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Difficulty.Evaluators; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; -using osu.Game.Rulesets.Osu.Objects; -using osu.Framework.Utils; +using System.Collections.Generic; +using System.Linq; namespace osu.Game.Rulesets.Osu.Difficulty.Skills { @@ -15,12 +18,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// public class Speed : OsuStrainSkill { - private const double single_spacing_threshold = 125; - private const double rhythm_multiplier = 0.75; - private const int history_time_max = 5000; // 5 seconds of calculatingRhythmBonus max. - private const double min_speed_bonus = 75; // ~200BPM - private const double speed_balancing_factor = 40; - private double skillMultiplier => 1375; private double strainDecayBase => 0.3; @@ -29,151 +26,43 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills protected override int ReducedSectionCount => 5; protected override double DifficultyMultiplier => 1.04; - protected override int HistoryLength => 32; - private readonly double greatWindow; + private readonly List objectStrains = new List(); - public Speed(Mod[] mods, double hitWindowGreat) + public Speed(Mod[] mods) : base(mods) { - greatWindow = hitWindowGreat; - } - - /// - /// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current . - /// - private double calculateRhythmBonus(DifficultyHitObject current) - { - if (current.BaseObject is Spinner) - return 0; - - int previousIslandSize = 0; - - double rhythmComplexitySum = 0; - int islandSize = 1; - double startRatio = 0; // store the ratio of the current start of an island to buff for tighter rhythms - - bool firstDeltaSwitch = false; - - int rhythmStart = 0; - - while (rhythmStart < Previous.Count - 2 && current.StartTime - Previous[rhythmStart].StartTime < history_time_max) - rhythmStart++; - - for (int i = rhythmStart; i > 0; i--) - { - OsuDifficultyHitObject currObj = (OsuDifficultyHitObject)Previous[i - 1]; - OsuDifficultyHitObject prevObj = (OsuDifficultyHitObject)Previous[i]; - OsuDifficultyHitObject lastObj = (OsuDifficultyHitObject)Previous[i + 1]; - - double currHistoricalDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max; // scales note 0 to 1 from history to now - - currHistoricalDecay = Math.Min((double)(Previous.Count - i) / Previous.Count, currHistoricalDecay); // either we're limited by time or limited by object count. - - double currDelta = currObj.StrainTime; - double prevDelta = prevObj.StrainTime; - double lastDelta = lastObj.StrainTime; - double currRatio = 1.0 + 6.0 * Math.Min(0.5, Math.Pow(Math.Sin(Math.PI / (Math.Min(prevDelta, currDelta) / Math.Max(prevDelta, currDelta))), 2)); // fancy function to calculate rhythmbonuses. - - double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - greatWindow * 0.6) / (greatWindow * 0.6)); - - windowPenalty = Math.Min(1, windowPenalty); - - double effectiveRatio = windowPenalty * currRatio; - - if (firstDeltaSwitch) - { - if (!(prevDelta > 1.25 * currDelta || prevDelta * 1.25 < currDelta)) - { - if (islandSize < 7) - islandSize++; // island is still progressing, count size. - } - else - { - if (Previous[i - 1].BaseObject is Slider) // bpm change is into slider, this is easy acc window - effectiveRatio *= 0.125; - - if (Previous[i].BaseObject is Slider) // bpm change was from a slider, this is easier typically than circle -> circle - effectiveRatio *= 0.25; - - if (previousIslandSize == islandSize) // repeated island size (ex: triplet -> triplet) - effectiveRatio *= 0.25; - - if (previousIslandSize % 2 == islandSize % 2) // repeated island polartiy (2 -> 4, 3 -> 5) - effectiveRatio *= 0.50; - - if (lastDelta > prevDelta + 10 && prevDelta > currDelta + 10) // previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. - effectiveRatio *= 0.125; - - rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay * Math.Sqrt(4 + islandSize) / 2 * Math.Sqrt(4 + previousIslandSize) / 2; - - startRatio = effectiveRatio; - - previousIslandSize = islandSize; // log the last island size. - - if (prevDelta * 1.25 < currDelta) // we're slowing down, stop counting - firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size. - - islandSize = 1; - } - } - else if (prevDelta > 1.25 * currDelta) // we want to be speeding up. - { - // Begin counting island until we change speed again. - firstDeltaSwitch = true; - startRatio = effectiveRatio; - islandSize = 1; - } - } - - return Math.Sqrt(4 + rhythmComplexitySum * rhythm_multiplier) / 2; //produces multiplier that can be applied to strain. range [1, infinity) (not really though) - } - - private double strainValueOf(DifficultyHitObject current) - { - if (current.BaseObject is Spinner) - return 0; - - // derive strainTime for calculation - var osuCurrObj = (OsuDifficultyHitObject)current; - var osuPrevObj = Previous.Count > 0 ? (OsuDifficultyHitObject)Previous[0] : null; - - double strainTime = osuCurrObj.StrainTime; - double greatWindowFull = greatWindow * 2; - double speedWindowRatio = strainTime / greatWindowFull; - - // Aim to nerf cheesy rhythms (Very fast consecutive doubles with large deltatimes between) - if (osuPrevObj != null && strainTime < greatWindowFull && osuPrevObj.StrainTime > strainTime) - strainTime = Interpolation.Lerp(osuPrevObj.StrainTime, strainTime, speedWindowRatio); - - // Cap deltatime to the OD 300 hitwindow. - // 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap. - strainTime /= Math.Clamp((strainTime / greatWindowFull) / 0.93, 0.92, 1); - - // derive speedBonus for calculation - double speedBonus = 1.0; - - if (strainTime < min_speed_bonus) - speedBonus = 1 + 0.75 * Math.Pow((min_speed_bonus - strainTime) / speed_balancing_factor, 2); - - double travelDistance = osuPrevObj?.TravelDistance ?? 0; - double distance = Math.Min(single_spacing_threshold, travelDistance + osuCurrObj.MinimumJumpDistance); - - return (speedBonus + speedBonus * Math.Pow(distance / single_spacing_threshold, 3.5)) / strainTime; } private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000); - protected override double CalculateInitialStrain(double time) => (currentStrain * currentRhythm) * strainDecay(time - Previous[0].StartTime); + protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => (currentStrain * currentRhythm) * strainDecay(time - current.Previous(0).StartTime); protected override double StrainValueAt(DifficultyHitObject current) { - currentStrain *= strainDecay(current.DeltaTime); - currentStrain += strainValueOf(current) * skillMultiplier; + currentStrain *= strainDecay(((OsuDifficultyHitObject)current).StrainTime); + currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current) * skillMultiplier; - currentRhythm = calculateRhythmBonus(current); + currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current); - return currentStrain * currentRhythm; + double totalStrain = currentStrain * currentRhythm; + + objectStrains.Add(totalStrain); + + return totalStrain; + } + + public double RelevantNoteCount() + { + if (objectStrains.Count == 0) + return 0; + + double maxStrain = objectStrains.Max(); + + if (maxStrain == 0) + return 0; + + return objectStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0)))); } } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/BlueprintPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/BlueprintPiece.cs index b9c77d3f56..41ab5a81b8 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/BlueprintPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/BlueprintPiece.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Osu.Objects; @@ -10,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints /// A piece of a selection or placement blueprint which visualises an . /// /// The type of which this visualises. - public abstract class BlueprintPiece : CompositeDrawable + public abstract partial class BlueprintPiece : CompositeDrawable where T : OsuHitObject { /// diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCircleOverlapMarker.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCircleOverlapMarker.cs index ad4be2017e..e5cc8595d1 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCircleOverlapMarker.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCircleOverlapMarker.cs @@ -1,10 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Utils; +using osu.Game.Configuration; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Skinning.Default; @@ -15,7 +20,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components { - public class HitCircleOverlapMarker : BlueprintPiece + public partial class HitCircleOverlapMarker : BlueprintPiece { /// /// Hit objects are intentionally made to fade out at a constant slower rate than in gameplay. @@ -25,31 +30,45 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components private readonly RingPiece ring; + private readonly Container content; + [Resolved] private EditorClock editorClock { get; set; } + private Bindable showHitMarkers; + public HitCircleOverlapMarker() { Origin = Anchor.Centre; Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); - InternalChildren = new Drawable[] + InternalChild = content = new Container { - new Circle + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Colour = Color4.White, - }, - ring = new RingPiece - { - BorderThickness = 4, + new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Colour = Color4.White, + }, + ring = new RingPiece + { + BorderThickness = 4, + } } }; } + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + showHitMarkers = config.GetBindable(OsuSetting.EditorShowHitMarkers); + } + [Resolved] private ISkinSource skin { get; set; } @@ -66,21 +85,26 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components double hitObjectTime = hitObject.StartTime; bool hasReachedObject = editorTime >= hitObjectTime; - if (hasReachedObject) + if (hasReachedObject && showHitMarkers.Value) { float alpha = Interpolation.ValueAt(editorTime, 0, 1f, hitObjectTime, hitObjectTime + FADE_OUT_EXTENSION, Easing.In); float ringScale = MathHelper.Clamp(Interpolation.ValueAt(editorTime, 0, 1f, hitObjectTime, hitObjectTime + FADE_OUT_EXTENSION / 2, Easing.OutQuint), 0, 1); ring.Scale = new Vector2(1 + 0.1f * ringScale); - Alpha = 0.9f * (1 - alpha); + content.Alpha = 0.9f * (1 - alpha); } else - Alpha = 0; + content.Alpha = 0; + } + + public override void Show() + { + // intentional no op so SelectionBlueprint Selection/Deselection logic doesn't touch us. } public override void Hide() { - // intentional no op so we are not hidden when not selected. + // intentional no op so SelectionBlueprint Selection/Deselection logic doesn't touch us. } } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCirclePiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCirclePiece.cs index 0cfc67cedb..1fed19da03 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCirclePiece.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Graphics; @@ -10,7 +12,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components { - public class HitCirclePiece : BlueprintPiece + public partial class HitCirclePiece : BlueprintPiece { public HitCirclePiece() { diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs index c45a04053f..26d18c7a17 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Input.Events; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; @@ -9,7 +11,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles { - public class HitCirclePlacementBlueprint : PlacementBlueprint + public partial class HitCirclePlacementBlueprint : PlacementBlueprint { public new HitCircle HitObject => (HitCircle)base.HitObject; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs index 3f9cfe21d4..1b3e7f3a8f 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; @@ -10,7 +12,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles { - public class HitCircleSelectionBlueprint : OsuSelectionBlueprint + public partial class HitCircleSelectionBlueprint : OsuSelectionBlueprint { protected new DrawableHitCircle DrawableObject => (DrawableHitCircle)base.DrawableObject; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs index 2d0c6fe81d..d6409279a4 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; @@ -11,7 +13,7 @@ using osu.Game.Screens.Edit; namespace osu.Game.Rulesets.Osu.Edit.Blueprints { - public abstract class OsuSelectionBlueprint : HitObjectSelectionBlueprint + public abstract partial class OsuSelectionBlueprint : HitObjectSelectionBlueprint where T : OsuHitObject { [Resolved] @@ -22,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints protected override bool AlwaysShowWhenSelected => true; protected override bool ShouldBeAlive => base.ShouldBeAlive - || (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/PathControlPointConnectionPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs index d66c9ea4bf..67685d21a7 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs @@ -1,39 +1,43 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Lines; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; using osuTK; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components { /// - /// A visualisation of the line between two s. + /// A visualisation of the line between two s. /// - public class PathControlPointConnectionPiece : CompositeDrawable + /// The type of which this visualises. + public partial class PathControlPointConnectionPiece : CompositeDrawable where T : OsuHitObject, IHasPath { public readonly PathControlPoint ControlPoint; private readonly Path path; - private readonly Slider slider; + private readonly T hitObject; public int ControlPointIndex { get; set; } - private IBindable sliderPosition; + private IBindable hitObjectPosition; private IBindable pathVersion; - public PathControlPointConnectionPiece(Slider slider, int controlPointIndex) + public PathControlPointConnectionPiece(T hitObject, int controlPointIndex) { - this.slider = slider; + this.hitObject = hitObject; ControlPointIndex = controlPointIndex; Origin = Anchor.Centre; AutoSizeAxes = Axes.Both; - ControlPoint = slider.Path.ControlPoints[controlPointIndex]; + ControlPoint = hitObject.Path.ControlPoints[controlPointIndex]; InternalChild = path = new SmoothPath { @@ -46,10 +50,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components { base.LoadComplete(); - sliderPosition = slider.PositionBindable.GetBoundCopy(); - sliderPosition.BindValueChanged(_ => updateConnectingPath()); + hitObjectPosition = hitObject.PositionBindable.GetBoundCopy(); + hitObjectPosition.BindValueChanged(_ => updateConnectingPath()); - pathVersion = slider.Path.Version.GetBoundCopy(); + pathVersion = hitObject.Path.Version.GetBoundCopy(); pathVersion.BindValueChanged(_ => updateConnectingPath()); updateConnectingPath(); @@ -60,16 +64,16 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components /// private void updateConnectingPath() { - Position = slider.StackedPosition + ControlPoint.Position; + Position = hitObject.StackedPosition + ControlPoint.Position; path.ClearVertices(); int nextIndex = ControlPointIndex + 1; - if (nextIndex == 0 || nextIndex >= slider.Path.ControlPoints.Count) + if (nextIndex == 0 || nextIndex >= hitObject.Path.ControlPoints.Count) return; path.AddVertex(Vector2.Zero); - path.AddVertex(slider.Path.ControlPoints[nextIndex].Position - ControlPoint.Position); + path.AddVertex(hitObject.Path.ControlPoints[nextIndex].Position - ControlPoint.Position); path.OriginPosition = path.PositionInBoundingBox(Vector2.Zero); } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs index 09759aa118..12e5ca0236 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs @@ -1,9 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -26,11 +29,13 @@ using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components { /// - /// A visualisation of a single in a . + /// A visualisation of a single in an osu hit object with a path. /// - public class PathControlPointPiece : BlueprintPiece, IHasTooltip + /// The type of which this visualises. + public partial class PathControlPointPiece : BlueprintPiece, IHasTooltip + where T : OsuHitObject, IHasPath { - public Action RequestSelection; + public Action, MouseButtonEvent> RequestSelection; public Action DragStarted; public Action DragInProgress; @@ -41,29 +46,36 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components public readonly BindableBool IsSelected = new BindableBool(); public readonly PathControlPoint ControlPoint; - private readonly Slider slider; + private readonly T hitObject; private readonly Container marker; private readonly Drawable markerRing; [Resolved] private OsuColour colours { get; set; } - private IBindable sliderPosition; - private IBindable sliderScale; + private IBindable hitObjectPosition; + private IBindable hitObjectScale; - public PathControlPointPiece(Slider slider, PathControlPoint controlPoint) + [UsedImplicitly] + private readonly IBindable hitObjectVersion; + + public PathControlPointPiece(T hitObject, PathControlPoint controlPoint) { - this.slider = slider; + this.hitObject = hitObject; ControlPoint = controlPoint; - // we don't want to run the path type update on construction as it may inadvertently change the slider. - cachePoints(slider); + // we don't want to run the path type update on construction as it may inadvertently change the hit object. + cachePoints(hitObject); - slider.Path.Version.BindValueChanged(_ => + hitObjectVersion = hitObject.Path.Version.GetBoundCopy(); + + // schedule ensure that updates are only applied after all operations from a single frame are applied. + // this avoids inadvertently changing the hit object path type for batch operations. + hitObjectVersion.BindValueChanged(_ => Scheduler.AddOnce(() => { - cachePoints(slider); + cachePoints(hitObject); updatePathType(); - }); + })); controlPoint.Changed += updateMarkerDisplay; @@ -110,11 +122,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components { base.LoadComplete(); - sliderPosition = slider.PositionBindable.GetBoundCopy(); - sliderPosition.BindValueChanged(_ => updateMarkerDisplay()); + hitObjectPosition = hitObject.PositionBindable.GetBoundCopy(); + hitObjectPosition.BindValueChanged(_ => updateMarkerDisplay()); - sliderScale = slider.ScaleBindable.GetBoundCopy(); - sliderScale.BindValueChanged(_ => updateMarkerDisplay()); + hitObjectScale = hitObject.ScaleBindable.GetBoundCopy(); + hitObjectScale.BindValueChanged(_ => updateMarkerDisplay()); IsSelected.BindValueChanged(_ => updateMarkerDisplay()); @@ -202,7 +214,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components protected override void OnDragEnd(DragEndEvent e) => DragEnded?.Invoke(); - private void cachePoints(Slider slider) => PointsInSegment = slider.Path.PointsInSegment(ControlPoint); + private void cachePoints(T hitObject) => PointsInSegment = hitObject.Path.PointsInSegment(ControlPoint); /// /// Handles correction of invalid path types. @@ -229,7 +241,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components /// private void updateMarkerDisplay() { - Position = slider.StackedPosition + ControlPoint.Position; + Position = hitObject.StackedPosition + ControlPoint.Position; markerRing.Alpha = IsSelected.Value ? 1 : 0; @@ -239,7 +251,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components colour = colour.Lighten(1); marker.Colour = colour; - marker.Scale = new Vector2(slider.Scale); + marker.Scale = new Vector2(hitObject.Scale); } private Color4 getColourFromNodeType() 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 e71bde7357..17d0fc457a 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Collections.Specialized; @@ -27,35 +29,37 @@ using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components { - public class PathControlPointVisualiser : CompositeDrawable, IKeyBindingHandler, IHasContextMenu + public partial class PathControlPointVisualiser : CompositeDrawable, IKeyBindingHandler, IHasContextMenu + where T : OsuHitObject, IHasPath { public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; // allow context menu to appear outside of the playfield. - internal readonly Container Pieces; - internal readonly Container Connections; + internal readonly Container> Pieces; + internal readonly Container> Connections; private readonly IBindableList controlPoints = new BindableList(); - private readonly Slider slider; + private readonly T hitObject; private readonly bool allowSelection; private InputManager inputManager; public Action> RemoveControlPointsRequested; + public Action> SplitControlPointsRequested; [Resolved(CanBeNull = true)] private IDistanceSnapProvider snapProvider { get; set; } - public PathControlPointVisualiser(Slider slider, bool allowSelection) + public PathControlPointVisualiser(T hitObject, bool allowSelection) { - this.slider = slider; + this.hitObject = hitObject; this.allowSelection = allowSelection; RelativeSizeAxes = Axes.Both; InternalChildren = new Drawable[] { - Connections = new Container { RelativeSizeAxes = Axes.Both }, - Pieces = new Container { RelativeSizeAxes = Axes.Both } + Connections = new Container> { RelativeSizeAxes = Axes.Both }, + Pieces = new Container> { RelativeSizeAxes = Axes.Both } }; } @@ -66,12 +70,12 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components inputManager = GetContainingInputManager(); controlPoints.CollectionChanged += onControlPointsChanged; - controlPoints.BindTo(slider.Path.ControlPoints); + controlPoints.BindTo(hitObject.Path.ControlPoints); } /// - /// Selects the corresponding to the given , - /// and deselects all other s. + /// Selects the corresponding to the given , + /// and deselects all other s. /// public void SetSelectionTo(PathControlPoint pathControlPoint) { @@ -102,11 +106,36 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components return true; } + private bool splitSelected() + { + List controlPointsToSplitAt = Pieces.Where(p => p.IsSelected.Value && isSplittable(p)).Select(p => p.ControlPoint).ToList(); + + // Ensure that there are any points to be split + if (controlPointsToSplitAt.Count == 0) + return false; + + changeHandler?.BeginChange(); + SplitControlPointsRequested?.Invoke(controlPointsToSplitAt); + changeHandler?.EndChange(); + + // Since pieces are re-used, they will not point to the deleted control points while remaining selected + foreach (var piece in Pieces) + piece.IsSelected.Value = false; + + return true; + } + + private bool isSplittable(PathControlPointPiece p) => + // A hit object can only be split on control points which connect two different path segments. + p.ControlPoint.Type.HasValue && p != Pieces.FirstOrDefault() && p != Pieces.LastOrDefault(); + private void onControlPointsChanged(object sender, NotifyCollectionChangedEventArgs e) { switch (e.Action) { case NotifyCollectionChangedAction.Add: + Debug.Assert(e.NewItems != null); + // If inserting in the path (not appending), // update indices of existing connections after insert location if (e.NewStartingIndex < Pieces.Count) @@ -122,7 +151,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components { var point = (PathControlPoint)e.NewItems[i]; - Pieces.Add(new PathControlPointPiece(slider, point).With(d => + Pieces.Add(new PathControlPointPiece(hitObject, point).With(d => { if (allowSelection) d.RequestSelection = selectionRequested; @@ -132,16 +161,20 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components d.DragEnded = dragEnded; })); - Connections.Add(new PathControlPointConnectionPiece(slider, e.NewStartingIndex + i)); + Connections.Add(new PathControlPointConnectionPiece(hitObject, e.NewStartingIndex + i)); } break; case NotifyCollectionChangedAction.Remove: + Debug.Assert(e.OldItems != null); + foreach (var point in e.OldItems.Cast()) { - Pieces.RemoveAll(p => p.ControlPoint == point); - Connections.RemoveAll(c => c.ControlPoint == point); + foreach (var piece in Pieces.Where(p => p.ControlPoint == point).ToArray()) + piece.RemoveAndDisposeImmediately(); + foreach (var connection in Connections.Where(c => c.ControlPoint == point).ToArray()) + connection.RemoveAndDisposeImmediately(); } // If removing before the end of the path, @@ -187,7 +220,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components { } - private void selectionRequested(PathControlPointPiece piece, MouseButtonEvent e) + private void selectionRequested(PathControlPointPiece piece, MouseButtonEvent e) { if (e.Button == MouseButton.Left && inputManager.CurrentState.Keyboard.ControlPressed) piece.IsSelected.Toggle(); @@ -202,7 +235,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components /// /// The control point piece that we want to change the path type of. /// The path type we want to assign to the given control point piece. - private void updatePathType(PathControlPointPiece piece, PathType? type) + private void updatePathType(PathControlPointPiece piece, PathType? type) { int indexInSegment = piece.PointsInSegment.IndexOf(piece.ControlPoint); @@ -220,6 +253,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components break; } + hitObject.Path.ExpectedDistance.Value = null; piece.ControlPoint.Type = type; } @@ -235,9 +269,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components private void dragStarted(PathControlPoint controlPoint) { - dragStartPositions = slider.Path.ControlPoints.Select(point => point.Position).ToArray(); - dragPathTypes = slider.Path.ControlPoints.Select(point => point.Type).ToArray(); - draggedControlPointIndex = slider.Path.ControlPoints.IndexOf(controlPoint); + dragStartPositions = hitObject.Path.ControlPoints.Select(point => point.Position).ToArray(); + dragPathTypes = hitObject.Path.ControlPoints.Select(point => point.Type).ToArray(); + draggedControlPointIndex = hitObject.Path.ControlPoints.IndexOf(controlPoint); selectedControlPoints = new HashSet(Pieces.Where(piece => piece.IsSelected.Value).Select(piece => piece.ControlPoint)); Debug.Assert(draggedControlPointIndex >= 0); @@ -247,25 +281,25 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components private void dragInProgress(DragEvent e) { - Vector2[] oldControlPoints = slider.Path.ControlPoints.Select(cp => cp.Position).ToArray(); - var oldPosition = slider.Position; - double oldStartTime = slider.StartTime; + Vector2[] oldControlPoints = hitObject.Path.ControlPoints.Select(cp => cp.Position).ToArray(); + var oldPosition = hitObject.Position; + double oldStartTime = hitObject.StartTime; - if (selectedControlPoints.Contains(slider.Path.ControlPoints[0])) + if (selectedControlPoints.Contains(hitObject.Path.ControlPoints[0])) { - // Special handling for selections containing head control point - the position of the slider changes which means the snapped position and time have to be taken into account + // Special handling for selections containing head control point - the position of the hit object changes which means the snapped position and time have to be taken into account Vector2 newHeadPosition = Parent.ToScreenSpace(e.MousePosition + (dragStartPositions[0] - dragStartPositions[draggedControlPointIndex])); var result = snapProvider?.FindSnappedPositionAndTime(newHeadPosition); - Vector2 movementDelta = Parent.ToLocalSpace(result?.ScreenSpacePosition ?? newHeadPosition) - slider.Position; + Vector2 movementDelta = Parent.ToLocalSpace(result?.ScreenSpacePosition ?? newHeadPosition) - hitObject.Position; - slider.Position += movementDelta; - slider.StartTime = result?.Time ?? slider.StartTime; + hitObject.Position += movementDelta; + hitObject.StartTime = result?.Time ?? hitObject.StartTime; - for (int i = 1; i < slider.Path.ControlPoints.Count; i++) + for (int i = 1; i < hitObject.Path.ControlPoints.Count; i++) { - var controlPoint = slider.Path.ControlPoints[i]; - // Since control points are relative to the position of the slider, all points that are _not_ selected + var controlPoint = hitObject.Path.ControlPoints[i]; + // Since control points are relative to the position of the hit object, all points that are _not_ selected // need to be offset _back_ by the delta corresponding to the movement of the head point. // All other selected control points (if any) will move together with the head point // (and so they will not move at all, relative to each other). @@ -275,32 +309,36 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components } else { + var result = snapProvider?.FindSnappedPositionAndTime(Parent.ToScreenSpace(e.MousePosition)); + + Vector2 movementDelta = Parent.ToLocalSpace(result?.ScreenSpacePosition ?? Parent.ToScreenSpace(e.MousePosition)) - dragStartPositions[draggedControlPointIndex] - hitObject.Position; + for (int i = 0; i < controlPoints.Count; ++i) { var controlPoint = controlPoints[i]; if (selectedControlPoints.Contains(controlPoint)) - controlPoint.Position = dragStartPositions[i] + (e.MousePosition - e.MouseDownPosition); + controlPoint.Position = dragStartPositions[i] + movementDelta; } } // Snap the path to the current beat divisor before checking length validity. - slider.SnapTo(snapProvider); + hitObject.SnapTo(snapProvider); - if (!slider.Path.HasValidLength) + if (!hitObject.Path.HasValidLength) { - for (int i = 0; i < slider.Path.ControlPoints.Count; i++) - slider.Path.ControlPoints[i].Position = oldControlPoints[i]; + for (int i = 0; i < hitObject.Path.ControlPoints.Count; i++) + hitObject.Path.ControlPoints[i].Position = oldControlPoints[i]; - slider.Position = oldPosition; - slider.StartTime = oldStartTime; + hitObject.Position = oldPosition; + hitObject.StartTime = oldStartTime; // Snap the path length again to undo the invalid length. - slider.SnapTo(snapProvider); + hitObject.SnapTo(snapProvider); return; } // Maintain the path types in case they got defaulted to bezier at some point during the drag. - for (int i = 0; i < slider.Path.ControlPoints.Count; i++) - slider.Path.ControlPoints[i].Type = dragPathTypes[i]; + for (int i = 0; i < hitObject.Path.ControlPoints.Count; i++) + hitObject.Path.ControlPoints[i].Type = dragPathTypes[i]; } private void dragEnded() => changeHandler?.EndChange(); @@ -320,25 +358,42 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components if (count == 0) return null; - List items = new List(); + var splittablePieces = selectedPieces.Where(isSplittable).ToList(); + int splittableCount = splittablePieces.Count; + + List curveTypeItems = new List(); if (!selectedPieces.Contains(Pieces[0])) - items.Add(createMenuItemForPathType(null)); + curveTypeItems.Add(createMenuItemForPathType(null)); // todo: hide/disable items which aren't valid for selected points - items.Add(createMenuItemForPathType(PathType.Linear)); - items.Add(createMenuItemForPathType(PathType.PerfectCurve)); - items.Add(createMenuItemForPathType(PathType.Bezier)); - items.Add(createMenuItemForPathType(PathType.Catmull)); + curveTypeItems.Add(createMenuItemForPathType(PathType.Linear)); + curveTypeItems.Add(createMenuItemForPathType(PathType.PerfectCurve)); + curveTypeItems.Add(createMenuItemForPathType(PathType.Bezier)); + curveTypeItems.Add(createMenuItemForPathType(PathType.Catmull)); - return new MenuItem[] + var menuItems = new List { - new OsuMenuItem($"Delete {"control point".ToQuantity(count, count > 1 ? ShowQuantityAs.Numeric : ShowQuantityAs.None)}", MenuItemType.Destructive, () => DeleteSelected()), new OsuMenuItem("Curve type") { - Items = items + Items = curveTypeItems } }; + + if (splittableCount > 0) + { + menuItems.Add(new OsuMenuItem($"Split {"control point".ToQuantity(splittableCount, splittableCount > 1 ? ShowQuantityAs.Numeric : ShowQuantityAs.None)}", + MenuItemType.Destructive, + () => splitSelected())); + } + + menuItems.Add( + new OsuMenuItem($"Delete {"control point".ToQuantity(count, count > 1 ? ShowQuantityAs.Numeric : ShowQuantityAs.None)}", + MenuItemType.Destructive, + () => DeleteSelected()) + ); + + return menuItems.ToArray(); } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderBodyPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderBodyPiece.cs index 6e22c35ab3..68a44eb2f8 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderBodyPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderBodyPiece.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Allocation; using osu.Game.Graphics; @@ -11,7 +13,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components { - public class SliderBodyPiece : BlueprintPiece + public partial class SliderBodyPiece : BlueprintPiece { private readonly ManualSliderBody body; @@ -20,6 +22,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components /// public Vector2 PathStartLocation => body.PathOffset; + /// + /// Offset in absolute (local) coordinates from the end of the curve. + /// + public Vector2 PathEndLocation => body.PathEndOffset; + public SliderBodyPiece() { InternalChild = body = new ManualSliderBody @@ -38,16 +45,23 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components body.BorderColour = colours.Yellow; } + private int? lastVersion; + public override void UpdateFrom(Slider hitObject) { base.UpdateFrom(hitObject); body.PathRadius = hitObject.Scale * OsuHitObject.OBJECT_RADIUS; - var vertices = new List(); - hitObject.Path.GetPathToProgress(vertices, 0, 1); + if (lastVersion != hitObject.Path.Version.Value) + { + lastVersion = hitObject.Path.Version.Value; - body.SetVertices(vertices); + var vertices = new List(); + hitObject.Path.GetPathToProgress(vertices, 0, 1); + + body.SetVertices(vertices); + } Size = body.Size; OriginPosition = body.PathOffset; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleOverlay.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleOverlay.cs index d31d2aed97..3341a632c1 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleOverlay.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; @@ -8,7 +10,7 @@ using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { - public class SliderCircleOverlay : CompositeDrawable + public partial class SliderCircleOverlay : CompositeDrawable { protected readonly HitCirclePiece CirclePiece; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs index 501589987d..77393efeb3 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Diagnostics; using System.Linq; using JetBrains.Annotations; @@ -14,20 +16,21 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; +using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit; using osuTK; using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { - public class SliderPlacementBlueprint : PlacementBlueprint + public partial class SliderPlacementBlueprint : PlacementBlueprint { - public new Objects.Slider HitObject => (Objects.Slider)base.HitObject; + public new Slider HitObject => (Slider)base.HitObject; private SliderBodyPiece bodyPiece; private HitCirclePiece headCirclePiece; private HitCirclePiece tailCirclePiece; - private PathControlPointVisualiser controlPointVisualiser; + private PathControlPointVisualiser controlPointVisualiser; private InputManager inputManager; @@ -40,7 +43,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders private IDistanceSnapProvider snapProvider { get; set; } public SliderPlacementBlueprint() - : base(new Objects.Slider()) + : base(new Slider()) { RelativeSizeAxes = Axes.Both; @@ -56,7 +59,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders bodyPiece = new SliderBodyPiece(), headCirclePiece = new HitCirclePiece(), tailCirclePiece = new HitCirclePiece(), - controlPointVisualiser = new PathControlPointVisualiser(HitObject, false) + controlPointVisualiser = new PathControlPointVisualiser(HitObject, false) }; setState(SliderPlacementState.Initial); @@ -80,10 +83,16 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders case SliderPlacementState.Initial: BeginPlacement(); - var nearestDifficultyPoint = editorBeatmap.HitObjects.LastOrDefault(h => h.GetEndTime() < HitObject.StartTime)?.DifficultyControlPoint?.DeepClone() as DifficultyControlPoint; + var nearestDifficultyPoint = editorBeatmap.HitObjects + .LastOrDefault(h => h is Slider && h.GetEndTime() < HitObject.StartTime)? + .DifficultyControlPoint?.DeepClone() as DifficultyControlPoint; HitObject.DifficultyControlPoint = nearestDifficultyPoint ?? new DifficultyControlPoint(); HitObject.Position = ToLocalSpace(result.ScreenSpacePosition); + + // Replacing the DifficultyControlPoint above doesn't trigger any kind of invalidation. + // Without re-applying defaults, velocity won't be updated. + ApplyDefaultsToHitObject(); break; case SliderPlacementState.Body: @@ -189,7 +198,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders } // Update the cursor position. - cursor.Position = ToLocalSpace(inputManager.CurrentState.Mouse.Position) - HitObject.Position; + var result = snapProvider?.FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position); + 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/SliderPosition.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPosition.cs index 616bb17e05..92071d4a57 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPosition.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPosition.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { public enum SliderPosition diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs index a019f2fb64..e444287b73 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; @@ -11,6 +13,7 @@ using osu.Framework.Graphics.Primitives; 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; @@ -26,7 +29,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { - public class SliderSelectionBlueprint : OsuSelectionBlueprint + public partial class SliderSelectionBlueprint : OsuSelectionBlueprint { protected new DrawableSlider DrawableObject => (DrawableSlider)base.DrawableObject; @@ -35,7 +38,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders protected SliderCircleOverlay TailOverlay { get; private set; } [CanBeNull] - protected PathControlPointVisualiser ControlPointVisualiser { get; private set; } + protected PathControlPointVisualiser ControlPointVisualiser { get; private set; } [Resolved(CanBeNull = true)] private IDistanceSnapProvider snapProvider { get; set; } @@ -56,6 +59,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders private readonly BindableList controlPoints = new BindableList(); private readonly IBindable pathVersion = new Bindable(); + private readonly BindableList selectedObjects = new BindableList(); public SliderSelectionBlueprint(Slider slider) : base(slider) @@ -83,6 +87,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders pathVersion.BindValueChanged(_ => editorBeatmap?.Update(HitObject)); BodyPiece.UpdateFrom(HitObject); + + if (editorBeatmap != null) + selectedObjects.BindTo(editorBeatmap.SelectedHitObjects); + selectedObjects.BindCollectionChanged((_, _) => updateVisualDefinition(), true); } public override bool HandleQuickDeletion() @@ -105,13 +113,22 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders BodyPiece.UpdateFrom(HitObject); } + protected override bool OnHover(HoverEvent e) + { + updateVisualDefinition(); + + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateVisualDefinition(); + base.OnHoverLost(e); + } + protected override void OnSelected() { - AddInternal(ControlPointVisualiser = new PathControlPointVisualiser(HitObject, true) - { - RemoveControlPointsRequested = removeControlPoints - }); - + updateVisualDefinition(); base.OnSelected(); } @@ -119,13 +136,31 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { base.OnDeselected(); - // throw away frame buffers on deselection. - ControlPointVisualiser?.Expire(); - ControlPointVisualiser = null; - + updateVisualDefinition(); BodyPiece.RecyclePath(); } + private void updateVisualDefinition() + { + // To reduce overhead of drawing these blueprints, only add extra detail when only this slider is selected. + if (IsSelected && selectedObjects.Count < 2) + { + if (ControlPointVisualiser == null) + { + AddInternal(ControlPointVisualiser = new PathControlPointVisualiser(HitObject, true) + { + RemoveControlPointsRequested = removeControlPoints, + SplitControlPointsRequested = splitControlPoints + }); + } + } + else + { + ControlPointVisualiser?.Expire(); + ControlPointVisualiser = null; + } + } + private Vector2 rightClickPosition; protected override bool OnMouseDown(MouseDownEvent e) @@ -159,7 +194,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders protected override void OnDrag(DragEvent e) { if (placementControlPoint != null) - placementControlPoint.Position = e.MousePosition - HitObject.Position; + { + var result = snapProvider?.FindSnappedPositionAndTime(ToScreenSpace(e.MousePosition)); + placementControlPoint.Position = ToLocalSpace(result?.ScreenSpacePosition ?? ToScreenSpace(e.MousePosition)) - HitObject.Position; + } } protected override void OnMouseUp(MouseUpEvent e) @@ -247,15 +285,83 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders HitObject.Position += first; } + private void splitControlPoints(List controlPointsToSplitAt) + { + // Arbitrary gap in milliseconds to put between split slider pieces + const double split_gap = 100; + + // Ensure that there are any points to be split + if (controlPointsToSplitAt.Count == 0) + return; + + editorBeatmap.SelectedHitObjects.Clear(); + + foreach (var splitPoint in controlPointsToSplitAt) + { + if (splitPoint == controlPoints[0] || splitPoint == controlPoints[^1] || splitPoint.Type is null) + continue; + + // Split off the section of slider before this control point so the remaining control points to split are in the latter part of the slider. + int index = controlPoints.IndexOf(splitPoint); + + if (index <= 0) + continue; + + // Extract the split portion and remove from the original slider. + 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, + NodeSamples = HitObject.NodeSamples.Select(n => (IList)n.Select(s => s.With()).ToList()).ToList(), + Path = new SliderPath(splitControlPoints.Select(o => new PathControlPoint(o.Position - splitControlPoints[0].Position, o == splitControlPoints[^1] ? null : o.Type)).ToArray()) + }; + + // Increase the start time of the slider before adding the new slider so the new slider is immediately inserted at the correct index and internal state remains valid. + HitObject.StartTime += split_gap; + + editorBeatmap.Add(newSlider); + + HitObject.NewCombo = false; + HitObject.Path.ExpectedDistance.Value -= newSlider.Path.CalculatedDistance; + HitObject.StartTime += newSlider.SpanDuration; + + // In case the remainder of the slider has no length left over, give it length anyways so we don't get a 0 length slider. + if (HitObject.Path.ExpectedDistance.Value <= Precision.DOUBLE_EPSILON) + { + HitObject.Path.ExpectedDistance.Value = null; + } + } + + // Once all required pieces have been split off, the original slider has the final split. + // As a final step, we must reset its control points to have an origin of (0,0). + Vector2 first = controlPoints[0].Position; + foreach (var c in controlPoints) + c.Position -= first; + HitObject.Position += first; + } + private void convertToStream() { - if (editorBeatmap == null || changeHandler == null || beatDivisor == null) + if (editorBeatmap == null || beatDivisor == null) return; var timingPoint = editorBeatmap.ControlPointInfo.TimingPointAt(HitObject.StartTime); double streamSpacing = timingPoint.BeatLength / beatDivisor.Value; - changeHandler.BeginChange(); + changeHandler?.BeginChange(); int i = 0; double time = HitObject.StartTime; @@ -267,7 +373,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders double positionWithRepeats = (time - HitObject.StartTime) / HitObject.Duration * HitObject.SpanCount(); double pathPosition = positionWithRepeats - (int)positionWithRepeats; // every second span is in the reverse direction - need to reverse the path position. - if (Precision.AlmostBigger(positionWithRepeats % 2, 1)) + if (positionWithRepeats % 2 >= 1) pathPosition = 1 - pathPosition; Vector2 position = HitObject.Position + HitObject.Path.PositionAt(pathPosition); @@ -290,7 +396,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders editorBeatmap.Remove(HitObject); - changeHandler.EndChange(); + changeHandler?.EndChange(); } public override MenuItem[] ContextMenuItems => new MenuItem[] @@ -303,6 +409,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders public override Vector2 ScreenSpaceSelectionPoint => DrawableObject.SliderBody?.ToScreenSpace(DrawableObject.SliderBody.PathOffset) ?? BodyPiece.ToScreenSpace(BodyPiece.PathStartLocation); + protected override Vector2[] ScreenSpaceAdditionalNodes => new[] + { + DrawableObject.SliderBody?.ToScreenSpace(DrawableObject.SliderBody.PathEndOffset) ?? BodyPiece.ToScreenSpace(BodyPiece.PathEndLocation) + }; + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => BodyPiece.ReceivePositionalInputAt(screenSpacePos) || ControlPointVisualiser?.Pieces.Any(p => p.ReceivePositionalInputAt(screenSpacePos)) == true; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/Components/SpinnerPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/Components/SpinnerPiece.cs index 92961b40bc..cc58acdc80 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/Components/SpinnerPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/Components/SpinnerPiece.cs @@ -1,21 +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 osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Osu.Skinning.Default; using osuTK; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners.Components { - public class SpinnerPiece : BlueprintPiece + public partial class SpinnerPiece : BlueprintPiece { - private readonly CircularContainer circle; - private readonly RingPiece ring; + private readonly Circle circle; + private readonly Circle ring; public SpinnerPiece() { @@ -23,18 +23,21 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners.Components RelativeSizeAxes = Axes.Both; FillMode = FillMode.Fit; - Size = new Vector2(1.3f); + Size = new Vector2(1); InternalChildren = new Drawable[] { - circle = new CircularContainer + circle = new Circle { RelativeSizeAxes = Axes.Both, - Masking = true, Alpha = 0.5f, - Child = new Box { RelativeSizeAxes = Axes.Both } }, - ring = new RingPiece() + ring = new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(OsuHitObject.OBJECT_RADIUS), + }, }; } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs index cc4ed0eccf..73ee5df9dc 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs @@ -1,7 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; +using JetBrains.Annotations; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Input.Events; using osu.Game.Rulesets.Edit; @@ -12,7 +16,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners { - public class SpinnerPlacementBlueprint : PlacementBlueprint + public partial class SpinnerPlacementBlueprint : PlacementBlueprint { public new Spinner HitObject => (Spinner)base.HitObject; @@ -20,6 +24,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners private bool isPlacingEnd; + [Resolved(CanBeNull = true)] + [CanBeNull] + private IBeatSnapProvider beatSnapProvider { get; set; } + public SpinnerPlacementBlueprint() : base(new Spinner { Position = OsuPlayfield.BASE_SIZE / 2 }) { @@ -31,7 +39,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners base.Update(); if (isPlacingEnd) - HitObject.EndTime = Math.Max(HitObject.StartTime, EditorClock.CurrentTime); + updateEndTimeFromCurrent(); piece.UpdateFrom(HitObject); } @@ -43,7 +51,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners if (e.Button != MouseButton.Right) return false; - HitObject.EndTime = EditorClock.CurrentTime; + updateEndTimeFromCurrent(); EndPlacement(true); } else @@ -59,5 +67,12 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners return true; } + + private void updateEndTimeFromCurrent() + { + HitObject.EndTime = beatSnapProvider == null + ? Math.Max(HitObject.StartTime, EditorClock.CurrentTime) + : Math.Max(HitObject.StartTime + beatSnapProvider.GetBeatLengthAtTime(HitObject.StartTime), beatSnapProvider.SnapTime(EditorClock.CurrentTime)); + } } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerSelectionBlueprint.cs index ee573d1a01..a80ec68c10 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerSelectionBlueprint.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners.Components; using osu.Game.Rulesets.Osu.Objects; using osuTK; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners { - public class SpinnerSelectionBlueprint : OsuSelectionBlueprint + public partial class SpinnerSelectionBlueprint : OsuSelectionBlueprint { private readonly SpinnerPiece piece; diff --git a/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs b/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs index 516b34d807..14e7b93f3a 100644 --- a/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs +++ b/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; @@ -10,7 +12,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Edit { - public class DrawableOsuEditorRuleset : DrawableOsuRuleset + public partial class DrawableOsuEditorRuleset : DrawableOsuRuleset { public DrawableOsuEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods) : base(ruleset, beatmap, mods) @@ -21,7 +23,7 @@ namespace osu.Game.Rulesets.Osu.Edit public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new OsuPlayfieldAdjustmentContainer { Size = Vector2.One }; - private class OsuEditorPlayfield : OsuPlayfield + private partial class OsuEditorPlayfield : OsuPlayfield { protected override GameplayCursorContainer CreateCursor() => null; diff --git a/osu.Game.Rulesets.Osu/Edit/HitCircleCompositionTool.cs b/osu.Game.Rulesets.Osu/Edit/HitCircleCompositionTool.cs index 5f7c8b77b0..69187875d7 100644 --- a/osu.Game.Rulesets.Osu/Edit/HitCircleCompositionTool.cs +++ b/osu.Game.Rulesets.Osu/Edit/HitCircleCompositionTool.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; diff --git a/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs b/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs index 221723e4cd..2c67f0bf97 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Game.Rulesets.Edit; diff --git a/osu.Game.Rulesets.Osu/Edit/OsuBlueprintContainer.cs b/osu.Game.Rulesets.Osu/Edit/OsuBlueprintContainer.cs index dc8c3d6107..173a664902 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuBlueprintContainer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuBlueprintContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles; @@ -11,7 +13,7 @@ using osu.Game.Screens.Edit.Compose.Components; namespace osu.Game.Rulesets.Osu.Edit { - public class OsuBlueprintContainer : ComposeBlueprintContainer + public partial class OsuBlueprintContainer : ComposeBlueprintContainer { public OsuBlueprintContainer(HitObjectComposer composer) : base(composer) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapGrid.cs b/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapGrid.cs index b11929c1e8..848c994974 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapGrid.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapGrid.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Objects; @@ -8,7 +10,7 @@ using osu.Game.Screens.Edit.Compose.Components; namespace osu.Game.Rulesets.Osu.Edit { - public class OsuDistanceSnapGrid : CircularDistanceSnapGrid + public partial class OsuDistanceSnapGrid : CircularDistanceSnapGrid { public OsuDistanceSnapGrid(OsuHitObject hitObject, [CanBeNull] OsuHitObject nextHitObject = null) : base(hitObject, hitObject.StackedEndPosition, hitObject.GetEndTime(), nextHitObject?.StartTime - 1) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index b0d6170190..7a70257f3a 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -11,8 +13,11 @@ 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.Game.Beatmaps; using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mods; @@ -25,7 +30,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Edit { - public class OsuHitObjectComposer : DistancedHitObjectComposer + public partial class OsuHitObjectComposer : DistancedHitObjectComposer { public OsuHitObjectComposer(Ruleset ruleset) : base(ruleset) @@ -42,12 +47,10 @@ namespace osu.Game.Rulesets.Osu.Edit new SpinnerCompositionTool() }; - private readonly Bindable distanceSnapToggle = new Bindable(); private readonly Bindable rectangularGridSnapToggle = new Bindable(); protected override IEnumerable CreateTernaryButtons() => base.CreateTernaryButtons().Concat(new[] { - new TernaryButton(distanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler }), new TernaryButton(rectangularGridSnapToggle, "Grid Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Th }) }); @@ -58,6 +61,9 @@ namespace osu.Game.Rulesets.Osu.Edit [BackgroundDependencyLoader] private void load() { + // Give a bit of breathing room around the playfield content. + PlayfieldContentContainer.Padding = new MarginPadding(10); + LayerBelowRuleset.AddRange(new Drawable[] { distanceSnapGridContainer = new Container @@ -71,23 +77,11 @@ namespace osu.Game.Rulesets.Osu.Edit }); selectedHitObjects = EditorBeatmap.SelectedHitObjects.GetBoundCopy(); - selectedHitObjects.CollectionChanged += (_, __) => updateDistanceSnapGrid(); + selectedHitObjects.CollectionChanged += (_, _) => updateDistanceSnapGrid(); placementObject = EditorBeatmap.PlacementObject.GetBoundCopy(); placementObject.ValueChanged += _ => updateDistanceSnapGrid(); - distanceSnapToggle.ValueChanged += _ => - { - updateDistanceSnapGrid(); - - if (distanceSnapToggle.Value == TernaryState.True) - rectangularGridSnapToggle.Value = TernaryState.False; - }; - - rectangularGridSnapToggle.ValueChanged += _ => - { - if (rectangularGridSnapToggle.Value == TernaryState.True) - distanceSnapToggle.Value = TernaryState.False; - }; + DistanceSnapToggle.ValueChanged += _ => updateDistanceSnapGrid(); // we may be entering the screen with a selection already active updateDistanceSnapGrid(); @@ -107,6 +101,14 @@ namespace osu.Game.Rulesets.Osu.Edit private RectangularPositionSnapGrid rectangularPositionSnapGrid; + protected override double ReadCurrentDistanceSnap(HitObject before, HitObject after) + { + float expectedDistance = DurationToDistance(before, after.StartTime - before.GetEndTime()); + float actualDistance = Vector2.Distance(((OsuHitObject)before).EndPosition, ((OsuHitObject)after).Position); + + return actualDistance / expectedDistance; + } + protected override void Update() { base.Update(); @@ -127,24 +129,46 @@ namespace osu.Game.Rulesets.Osu.Edit public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) { if (snapType.HasFlagFast(SnapType.NearbyObjects) && snapToVisibleBlueprints(screenSpacePosition, out var snapResult)) + { + // In the case of snapping to nearby objects, a time value is not provided. + // This matches the stable editor (which also uses current time), but with the introduction of time-snapping distance snap + // this could result in unexpected behaviour when distance snapping is turned on and a user attempts to place an object that is + // BOTH on a valid distance snap ring, and also at the same position as a previous object. + // + // We want to ensure that in this particular case, the time-snapping component of distance snap is still applied. + // The easiest way to ensure this is to attempt application of distance snap after a nearby object is found, and copy over + // the time value if the proposed positions are roughly the same. + if (snapType.HasFlagFast(SnapType.Grids) && DistanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) + { + (Vector2 distanceSnappedPosition, double distanceSnappedTime) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(snapResult.ScreenSpacePosition)); + if (Precision.AlmostEquals(distanceSnapGrid.ToScreenSpace(distanceSnappedPosition), snapResult.ScreenSpacePosition, 1)) + snapResult.Time = distanceSnappedTime; + } + return snapResult; + } + + SnapResult result = base.FindSnappedPositionAndTime(screenSpacePosition, snapType); if (snapType.HasFlagFast(SnapType.Grids)) { - if (distanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) + if (DistanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) { (Vector2 pos, double time) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(screenSpacePosition)); - return new SnapResult(distanceSnapGrid.ToScreenSpace(pos), time, PlayfieldAtScreenSpacePosition(screenSpacePosition)); + + result.ScreenSpacePosition = distanceSnapGrid.ToScreenSpace(pos); + result.Time = time; } if (rectangularGridSnapToggle.Value == TernaryState.True) { - Vector2 pos = rectangularPositionSnapGrid.GetSnappedPosition(rectangularPositionSnapGrid.ToLocalSpace(screenSpacePosition)); - return new SnapResult(rectangularPositionSnapGrid.ToScreenSpace(pos), null, PlayfieldAtScreenSpacePosition(screenSpacePosition)); + Vector2 pos = rectangularPositionSnapGrid.GetSnappedPosition(rectangularPositionSnapGrid.ToLocalSpace(result.ScreenSpacePosition)); + + result.ScreenSpacePosition = rectangularPositionSnapGrid.ToScreenSpace(pos); } } - return base.FindSnappedPositionAndTime(screenSpacePosition, snapType); + return result; } private bool snapToVisibleBlueprints(Vector2 screenSpacePosition, out SnapResult snapResult) @@ -163,28 +187,19 @@ namespace osu.Game.Rulesets.Osu.Edit if (b.IsSelected) continue; - var hitObject = (OsuHitObject)b.Item; + var snapPositions = b.ScreenSpaceSnapPoints; - Vector2? snap = checkSnap(hitObject.Position); - if (snap == null && hitObject.Position != hitObject.EndPosition) - snap = checkSnap(hitObject.EndPosition); + if (!snapPositions.Any()) + continue; - if (snap != null) + var closestSnapPosition = snapPositions.MinBy(p => Vector2.Distance(p, screenSpacePosition)); + + if (Vector2.Distance(closestSnapPosition, screenSpacePosition) < snapRadius) { // only return distance portion, since time is not really valid - snapResult = new SnapResult(snap.Value, null, playfield); + snapResult = new SnapResult(closestSnapPosition, null, playfield); return true; } - - Vector2? checkSnap(Vector2 checkPos) - { - Vector2 checkScreenPos = playfield.GamefieldToScreenSpace(checkPos); - - if (Vector2.Distance(checkScreenPos, screenSpacePosition) < snapRadius) - return checkScreenPos; - - return null; - } } snapResult = null; @@ -197,12 +212,12 @@ namespace osu.Game.Rulesets.Osu.Edit distanceSnapGridCache.Invalidate(); distanceSnapGrid = null; - if (distanceSnapToggle.Value != TernaryState.True) + if (DistanceSnapToggle.Value != TernaryState.True) return; switch (BlueprintContainer.CurrentTool) { - case SelectTool _: + case SelectTool: if (!EditorBeatmap.SelectedHitObjects.Any()) return; @@ -224,6 +239,42 @@ namespace osu.Game.Rulesets.Osu.Edit } } + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Repeat) + return false; + + handleToggleViaKey(e); + return base.OnKeyDown(e); + } + + protected override void OnKeyUp(KeyUpEvent e) + { + handleToggleViaKey(e); + base.OnKeyUp(e); + } + + protected override bool AdjustDistanceSpacing(GlobalAction action, float amount) + { + // To allow better visualisation, ensure that the spacing grid is visible before adjusting. + DistanceSnapToggle.Value = TernaryState.True; + + return base.AdjustDistanceSpacing(action, amount); + } + + private bool gridSnapMomentary; + + private void handleToggleViaKey(KeyboardEvent key) + { + bool shiftPressed = key.ShiftPressed; + + if (shiftPressed != gridSnapMomentary) + { + gridSnapMomentary = shiftPressed; + rectangularGridSnapToggle.Value = rectangularGridSnapToggle.Value == TernaryState.False ? TernaryState.True : TernaryState.False; + } + } + private DistanceSnapGrid createDistanceSnapGrid(IEnumerable selectedHitObjects) { if (BlueprintContainer.CurrentTool is SpinnerCompositionTool) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuRectangularPositionSnapGrid.cs b/osu.Game.Rulesets.Osu/Edit/OsuRectangularPositionSnapGrid.cs index d1c81b51bc..3234b03a3e 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuRectangularPositionSnapGrid.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuRectangularPositionSnapGrid.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Input.Bindings; @@ -13,7 +15,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Edit { - public class OsuRectangularPositionSnapGrid : RectangularPositionSnapGrid, IKeyBindingHandler + public partial class OsuRectangularPositionSnapGrid : RectangularPositionSnapGrid, IKeyBindingHandler { private static readonly int[] grid_sizes = { 4, 8, 16, 32 }; diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs index 70c60ab635..6d5280e528 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs @@ -1,15 +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 enable - using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; using osu.Framework.Utils; using osu.Game.Extensions; +using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; @@ -17,10 +18,11 @@ using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; using osu.Game.Screens.Edit.Compose.Components; using osuTK; +using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit { - public class OsuSelectionHandler : EditorSelectionHandler + public partial class OsuSelectionHandler : EditorSelectionHandler { [Resolved(CanBeNull = true)] private IDistanceSnapProvider? snapProvider { get; set; } @@ -55,6 +57,17 @@ namespace osu.Game.Rulesets.Osu.Edit referencePathTypes = null; } + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Key == Key.M && e.ControlPressed && e.ShiftPressed) + { + mergeSelection(); + return true; + } + + return false; + } + public override bool HandleMovement(MoveSelectionEvent moveEvent) { var hitObjects = selectedMovableObjects; @@ -114,11 +127,11 @@ namespace osu.Game.Rulesets.Osu.Edit { didFlip = true; - foreach (var point in slider.Path.ControlPoints) + foreach (var cp in slider.Path.ControlPoints) { - point.Position = new Vector2( - (direction == Direction.Horizontal ? -1 : 1) * point.Position.X, - (direction == Direction.Vertical ? -1 : 1) * point.Position.Y + cp.Position = new Vector2( + (direction == Direction.Horizontal ? -1 : 1) * cp.Position.X, + (direction == Direction.Vertical ? -1 : 1) * cp.Position.Y ); } } @@ -170,8 +183,8 @@ namespace osu.Game.Rulesets.Osu.Edit if (h is IHasPath path) { - foreach (var point in path.Path.ControlPoints) - point.Position = RotatePointAroundOrigin(point.Position, Vector2.Zero, delta); + foreach (PathControlPoint cp in path.Path.ControlPoints) + cp.Position = RotatePointAroundOrigin(cp.Position, Vector2.Zero, delta); } } @@ -322,7 +335,115 @@ namespace osu.Game.Rulesets.Osu.Edit /// All osu! hitobjects which can be moved/rotated/scaled. /// private OsuHitObject[] selectedMovableObjects => SelectedItems.OfType() - .Where(h => !(h is Spinner)) + .Where(h => h is not Spinner) .ToArray(); + + /// + /// All osu! hitobjects which can be merged. + /// + private OsuHitObject[] selectedMergeableObjects => SelectedItems.OfType() + .Where(h => h is HitCircle or Slider) + .OrderBy(h => h.StartTime) + .ToArray(); + + private void mergeSelection() + { + var mergeableObjects = selectedMergeableObjects; + + if (!canMerge(mergeableObjects)) + return; + + EditorBeatmap.BeginChange(); + + // Have an initial slider object. + var firstHitObject = mergeableObjects[0]; + var mergedHitObject = firstHitObject as Slider ?? new Slider + { + StartTime = firstHitObject.StartTime, + Position = firstHitObject.Position, + NewCombo = firstHitObject.NewCombo, + SampleControlPoint = firstHitObject.SampleControlPoint, + Samples = firstHitObject.Samples, + }; + + if (mergedHitObject.Path.ControlPoints.Count == 0) + { + mergedHitObject.Path.ControlPoints.Add(new PathControlPoint(Vector2.Zero, PathType.Linear)); + } + + // Merge all the selected hit objects into one slider path. + bool lastCircle = firstHitObject is HitCircle; + + foreach (var selectedMergeableObject in mergeableObjects.Skip(1)) + { + if (selectedMergeableObject is IHasPath hasPath) + { + var offset = lastCircle ? selectedMergeableObject.Position - mergedHitObject.Position : mergedHitObject.Path.ControlPoints[^1].Position; + float distanceToLastControlPoint = Vector2.Distance(mergedHitObject.Path.ControlPoints[^1].Position, offset); + + // Calculate the distance required to travel to the expected distance of the merging slider. + mergedHitObject.Path.ExpectedDistance.Value = mergedHitObject.Path.CalculatedDistance + distanceToLastControlPoint + hasPath.Path.Distance; + + // Remove the last control point if it sits exactly on the start of the next control point. + if (Precision.AlmostEquals(distanceToLastControlPoint, 0)) + { + mergedHitObject.Path.ControlPoints.RemoveAt(mergedHitObject.Path.ControlPoints.Count - 1); + } + + mergedHitObject.Path.ControlPoints.AddRange(hasPath.Path.ControlPoints.Select(o => new PathControlPoint(o.Position + offset, o.Type))); + lastCircle = false; + } + else + { + // Turn the last control point into a linear type if this is the first merging circle in a sequence, so the subsequent control points can be inherited path type. + if (!lastCircle) + { + mergedHitObject.Path.ControlPoints.Last().Type = PathType.Linear; + } + + mergedHitObject.Path.ControlPoints.Add(new PathControlPoint(selectedMergeableObject.Position - mergedHitObject.Position)); + mergedHitObject.Path.ExpectedDistance.Value = null; + lastCircle = true; + } + } + + // Make sure only the merged hit object is in the beatmap. + if (firstHitObject is Slider) + { + foreach (var selectedMergeableObject in mergeableObjects.Skip(1)) + { + EditorBeatmap.Remove(selectedMergeableObject); + } + } + else + { + foreach (var selectedMergeableObject in mergeableObjects) + { + EditorBeatmap.Remove(selectedMergeableObject); + } + + EditorBeatmap.Add(mergedHitObject); + } + + // Make sure the merged hitobject is selected. + SelectedItems.Clear(); + SelectedItems.Add(mergedHitObject); + + EditorBeatmap.EndChange(); + } + + protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) + { + foreach (var item in base.GetContextMenuItemsForSelection(selection)) + yield return item; + + if (canMerge(selectedMergeableObjects)) + yield return new OsuMenuItem("Merge selection", MenuItemType.Destructive, mergeSelection); + } + + private bool canMerge(IReadOnlyList objects) => + objects.Count > 1 + && (objects.Any(h => h is Slider) + || objects.Zip(objects.Skip(1), (h1, h2) => Precision.DefinitelyBigger(Vector2.DistanceSquared(h1.Position, h2.Position), 1)).Any(x => x)); } } diff --git a/osu.Game.Rulesets.Osu/Edit/Setup/OsuSetupSection.cs b/osu.Game.Rulesets.Osu/Edit/Setup/OsuSetupSection.cs index 8cb778a2e1..ac567559b8 100644 --- a/osu.Game.Rulesets.Osu/Edit/Setup/OsuSetupSection.cs +++ b/osu.Game.Rulesets.Osu/Edit/Setup/OsuSetupSection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Graphics.UserInterfaceV2; @@ -8,7 +10,7 @@ using osu.Game.Screens.Edit.Setup; namespace osu.Game.Rulesets.Osu.Edit.Setup { - public class OsuSetupSection : RulesetSetupSection + public partial class OsuSetupSection : RulesetSetupSection { private LabelledSliderBar stackLeniency; @@ -47,6 +49,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Setup private void updateBeatmap() { Beatmap.BeatmapInfo.StackLeniency = stackLeniency.Current.Value; + Beatmap.SaveState(); } } } diff --git a/osu.Game.Rulesets.Osu/Edit/SliderCompositionTool.cs b/osu.Game.Rulesets.Osu/Edit/SliderCompositionTool.cs index 596224e5c6..0a3fc176ad 100644 --- a/osu.Game.Rulesets.Osu/Edit/SliderCompositionTool.cs +++ b/osu.Game.Rulesets.Osu/Edit/SliderCompositionTool.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; diff --git a/osu.Game.Rulesets.Osu/Edit/SpinnerCompositionTool.cs b/osu.Game.Rulesets.Osu/Edit/SpinnerCompositionTool.cs index c5e90da3bd..3c0cf34010 100644 --- a/osu.Game.Rulesets.Osu/Edit/SpinnerCompositionTool.cs +++ b/osu.Game.Rulesets.Osu/Edit/SpinnerCompositionTool.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; diff --git a/osu.Game.Rulesets.Osu/Judgements/ComboResult.cs b/osu.Game.Rulesets.Osu/Judgements/ComboResult.cs index ad292b0439..9762c676c5 100644 --- a/osu.Game.Rulesets.Osu/Judgements/ComboResult.cs +++ b/osu.Game.Rulesets.Osu/Judgements/ComboResult.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; namespace osu.Game.Rulesets.Osu.Judgements diff --git a/osu.Game.Rulesets.Osu/Judgements/OsuHitCircleJudgementResult.cs b/osu.Game.Rulesets.Osu/Judgements/OsuHitCircleJudgementResult.cs index 9b33e746b3..5f9faaceb2 100644 --- a/osu.Game.Rulesets.Osu/Judgements/OsuHitCircleJudgementResult.cs +++ b/osu.Game.Rulesets.Osu/Judgements/OsuHitCircleJudgementResult.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Objects; diff --git a/osu.Game.Rulesets.Osu/Judgements/OsuIgnoreJudgement.cs b/osu.Game.Rulesets.Osu/Judgements/OsuIgnoreJudgement.cs index 1999785efe..1bdb74cd3b 100644 --- a/osu.Game.Rulesets.Osu/Judgements/OsuIgnoreJudgement.cs +++ b/osu.Game.Rulesets.Osu/Judgements/OsuIgnoreJudgement.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Judgements diff --git a/osu.Game.Rulesets.Osu/Judgements/OsuJudgement.cs b/osu.Game.Rulesets.Osu/Judgements/OsuJudgement.cs index 1a88e2a8b2..a5503d3273 100644 --- a/osu.Game.Rulesets.Osu/Judgements/OsuJudgement.cs +++ b/osu.Game.Rulesets.Osu/Judgements/OsuJudgement.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; diff --git a/osu.Game.Rulesets.Osu/Judgements/OsuJudgementResult.cs b/osu.Game.Rulesets.Osu/Judgements/OsuJudgementResult.cs index 15444b847b..50d73fa19d 100644 --- a/osu.Game.Rulesets.Osu/Judgements/OsuJudgementResult.cs +++ b/osu.Game.Rulesets.Osu/Judgements/OsuJudgementResult.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; diff --git a/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs b/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs index 9f77175398..4229c87b58 100644 --- a/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs +++ b/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Objects; diff --git a/osu.Game.Rulesets.Osu/Judgements/SliderTickJudgement.cs b/osu.Game.Rulesets.Osu/Judgements/SliderTickJudgement.cs index a088696784..270c1f31fb 100644 --- a/osu.Game.Rulesets.Osu/Judgements/SliderTickJudgement.cs +++ b/osu.Game.Rulesets.Osu/Judgements/SliderTickJudgement.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Judgements diff --git a/osu.Game.Rulesets.Osu/Mods/InputBlockingMod.cs b/osu.Game.Rulesets.Osu/Mods/InputBlockingMod.cs new file mode 100644 index 0000000000..b56fdbdf74 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/InputBlockingMod.cs @@ -0,0 +1,118 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.Play; +using osu.Game.Utils; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public abstract partial class InputBlockingMod : Mod, IApplicableToDrawableRuleset, IUpdatableByPlayfield + { + public override double ScoreMultiplier => 1.0; + public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModRelax), typeof(OsuModCinema) }; + public override ModType Type => ModType.Conversion; + + private const double flash_duration = 1000; + + private DrawableOsuRuleset ruleset = null!; + + protected OsuAction? LastAcceptedAction { get; private set; } + + /// + /// A tracker for periods where alternate should not be forced (i.e. non-gameplay periods). + /// + /// + /// This is different from in that the periods here end strictly at the first object after the break, rather than the break's end time. + /// + private PeriodTracker nonGameplayPeriods = null!; + + private IFrameStableClock gameplayClock = null!; + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + ruleset = (DrawableOsuRuleset)drawableRuleset; + ruleset.KeyBindingInputManager.Add(new InputInterceptor(this)); + + var periods = new List(); + + if (drawableRuleset.Objects.Any()) + { + periods.Add(new Period(int.MinValue, getValidJudgementTime(ruleset.Objects.First()) - 1)); + + foreach (BreakPeriod b in drawableRuleset.Beatmap.Breaks) + periods.Add(new Period(b.StartTime, getValidJudgementTime(ruleset.Objects.First(h => h.StartTime >= b.EndTime)) - 1)); + + static double getValidJudgementTime(HitObject hitObject) => hitObject.StartTime - hitObject.HitWindows.WindowFor(HitResult.Meh); + } + + nonGameplayPeriods = new PeriodTracker(periods); + + gameplayClock = drawableRuleset.FrameStableClock; + } + + public void Update(Playfield playfield) + { + if (LastAcceptedAction != null && nonGameplayPeriods.IsInAny(gameplayClock.CurrentTime)) + LastAcceptedAction = null; + } + + protected abstract bool CheckValidNewAction(OsuAction action); + + private bool checkCorrectAction(OsuAction action) + { + if (nonGameplayPeriods.IsInAny(gameplayClock.CurrentTime)) + return true; + + switch (action) + { + case OsuAction.LeftButton: + case OsuAction.RightButton: + break; + + // Any action which is not left or right button should be ignored. + default: + return true; + } + + if (CheckValidNewAction(action)) + { + LastAcceptedAction = action; + return true; + } + + ruleset.Cursor.FlashColour(Colour4.Red, flash_duration, Easing.OutQuint); + return false; + } + + private partial class InputInterceptor : Component, IKeyBindingHandler + { + private readonly InputBlockingMod mod; + + public InputInterceptor(InputBlockingMod mod) + { + this.mod = mod; + } + + public bool OnPressed(KeyBindingPressEvent e) + // if the pressed action is incorrect, block it from reaching gameplay. + => !mod.checkCorrectAction(e.Action); + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAccuracyChallenge.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAccuracyChallenge.cs new file mode 100644 index 0000000000..5b79753632 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAccuracyChallenge.cs @@ -0,0 +1,14 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public class OsuModAccuracyChallenge : ModAccuracyChallenge + { + public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModAutopilot)).ToArray(); + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs index 0832cfb545..9bf5d33d4a 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs @@ -2,116 +2,20 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Linq; -using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Bindings; -using osu.Framework.Input.Events; -using osu.Game.Beatmaps.Timing; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Scoring; -using osu.Game.Rulesets.UI; -using osu.Game.Screens.Play; -using osu.Game.Utils; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModAlternate : Mod, IApplicableToDrawableRuleset + public class OsuModAlternate : InputBlockingMod { public override string Name => @"Alternate"; public override string Acronym => @"AL"; - public override string Description => @"Don't use the same key twice in a row!"; - public override double ScoreMultiplier => 1.0; - public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModRelax) }; - public override ModType Type => ModType.Conversion; + public override LocalisableString Description => @"Don't use the same key twice in a row!"; public override IconUsage? Icon => FontAwesome.Solid.Keyboard; + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModSingleTap) }).ToArray(); - private const double flash_duration = 1000; - - /// - /// A tracker for periods where alternate should not be forced (i.e. non-gameplay periods). - /// - /// - /// This is different from in that the periods here end strictly at the first object after the break, rather than the break's end time. - /// - private PeriodTracker nonGameplayPeriods; - - private OsuAction? lastActionPressed; - private DrawableRuleset ruleset; - - private IFrameStableClock gameplayClock; - - public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) - { - ruleset = drawableRuleset; - drawableRuleset.KeyBindingInputManager.Add(new InputInterceptor(this)); - - var periods = new List(); - - if (drawableRuleset.Objects.Any()) - { - periods.Add(new Period(int.MinValue, getValidJudgementTime(ruleset.Objects.First()) - 1)); - - foreach (BreakPeriod b in drawableRuleset.Beatmap.Breaks) - periods.Add(new Period(b.StartTime, getValidJudgementTime(ruleset.Objects.First(h => h.StartTime >= b.EndTime)) - 1)); - - static double getValidJudgementTime(HitObject hitObject) => hitObject.StartTime - hitObject.HitWindows.WindowFor(HitResult.Meh); - } - - nonGameplayPeriods = new PeriodTracker(periods); - - gameplayClock = drawableRuleset.FrameStableClock; - } - - private bool checkCorrectAction(OsuAction action) - { - if (nonGameplayPeriods.IsInAny(gameplayClock.CurrentTime)) - { - lastActionPressed = null; - return true; - } - - switch (action) - { - case OsuAction.LeftButton: - case OsuAction.RightButton: - break; - - // Any action which is not left or right button should be ignored. - default: - return true; - } - - if (lastActionPressed != action) - { - // User alternated correctly. - lastActionPressed = action; - return true; - } - - ruleset.Cursor.FlashColour(Colour4.Red, flash_duration, Easing.OutQuint); - return false; - } - - private class InputInterceptor : Component, IKeyBindingHandler - { - private readonly OsuModAlternate mod; - - public InputInterceptor(OsuModAlternate mod) - { - this.mod = mod; - } - - public bool OnPressed(KeyBindingPressEvent e) - // if the pressed action is incorrect, block it from reaching gameplay. - => !mod.checkCorrectAction(e.Action); - - public void OnReleased(KeyBindingReleaseEvent e) - { - } - } + protected override bool CheckValidNewAction(OsuAction action) => LastAcceptedAction != action; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs b/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs index 8b323eefa6..f213d9f193 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs @@ -5,6 +5,7 @@ using System; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; @@ -16,11 +17,11 @@ namespace osu.Game.Rulesets.Osu.Mods { public override string Name => "Approach Different"; public override string Acronym => "AD"; - public override string Description => "Never trust the approach circles..."; + public override LocalisableString Description => "Never trust the approach circles..."; public override double ScoreMultiplier => 1; public override IconUsage? Icon { get; } = FontAwesome.Regular.Circle; - public override Type[] IncompatibleMods => new[] { typeof(IHidesApproachCircles) }; + public override Type[] IncompatibleMods => new[] { typeof(IHidesApproachCircles), typeof(OsuModFreezeFrame) }; [SettingSource("Initial size", "Change the initial size of the approach circle, relative to hit circles.", 0)] public BindableFloat Scale { get; } = new BindableFloat(4) @@ -35,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.Mods public void ApplyToDrawableHitObject(DrawableHitObject drawable) { - drawable.ApplyCustomUpdateState += (drawableObject, state) => + drawable.ApplyCustomUpdateState += (drawableObject, _) => { if (!(drawableObject is DrawableHitCircle drawableHitCircle)) return; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs index aaf455e95f..4782a0e49c 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs @@ -6,10 +6,12 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.StateChanges; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Osu.Mods @@ -20,19 +22,17 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Acronym => "AP"; public override IconUsage? Icon => OsuIcon.ModAutopilot; public override ModType Type => ModType.Automation; - public override string Description => @"Automatic cursor movement - just follow the rhythm."; - public override double ScoreMultiplier => 1; - public override Type[] IncompatibleMods => new[] { typeof(OsuModSpunOut), typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail), typeof(ModAutoplay), typeof(OsuModMagnetised) }; + 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 bool PerformFail() => false; public bool RestartOnFail => false; - private OsuInputManager inputManager; + private OsuInputManager inputManager = null!; - private IFrameStableClock gameplayClock; - - private List replayFrames; + private List replayFrames = null!; private int currentFrame; @@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Osu.Mods { if (currentFrame == replayFrames.Count - 1) return; - double time = gameplayClock.CurrentTime; + double time = playfield.Clock.CurrentTime; // Very naive implementation of autopilot based on proximity to replay frames. // TODO: this needs to be based on user interactions to better match stable (pausing until judgement is registered). @@ -55,14 +55,14 @@ namespace osu.Game.Rulesets.Osu.Mods public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { - gameplayClock = drawableRuleset.FrameStableClock; - // Grab the input manager to disable the user's cursor, and for future use - inputManager = (OsuInputManager)drawableRuleset.KeyBindingInputManager; + inputManager = ((DrawableOsuRuleset)drawableRuleset).KeyBindingInputManager; inputManager.AllowUserCursorMovement = false; // Generate the replay frames the cursor should follow replayFrames = new OsuAutoGenerator(drawableRuleset.Beatmap, drawableRuleset.Mods).Generate().Frames.Cast().ToList(); + + drawableRuleset.UseResumeOverlay = false; } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs index 507b3588bd..7c1f6be9ed 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModAutoplay : ModAutoplay { - public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModAutopilot), typeof(OsuModSpunOut), typeof(OsuModAlternate) }).ToArray(); + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModRepel), typeof(OsuModAutopilot), typeof(OsuModSpunOut), typeof(OsuModAlternate), typeof(OsuModSingleTap) }).ToArray(); public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList mods) => new ModReplayData(new OsuAutoGenerator(beatmap, mods).Generate(), new ModCreatedUser { Username = "Autoplay" }); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs index 7567c96b50..2e2d320313 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; @@ -19,19 +20,19 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModBlinds : Mod, IApplicableToDrawableRuleset, IApplicableToHealthProcessor + public partial class OsuModBlinds : Mod, IApplicableToDrawableRuleset, IApplicableToHealthProcessor { public override string Name => "Blinds"; - public override string Description => "Play with blinds on your screen."; + public override LocalisableString Description => "Play with blinds on your screen."; public override string Acronym => "BL"; public override IconUsage? Icon => FontAwesome.Solid.Adjust; public override ModType Type => ModType.DifficultyIncrease; - public override double ScoreMultiplier => 1.12; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1; public override Type[] IncompatibleMods => new[] { typeof(OsuModFlashlight) }; - private DrawableOsuBlinds blinds; + private DrawableOsuBlinds blinds = null!; public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { @@ -48,14 +49,17 @@ namespace osu.Game.Rulesets.Osu.Mods /// /// Element for the Blinds mod drawing 2 black boxes covering the whole screen which resize inside a restricted area with some leniency. /// - public class DrawableOsuBlinds : Container + public partial class DrawableOsuBlinds : Container { /// /// Black background boxes behind blind panel textures. /// - private Box blackBoxLeft, blackBoxRight; + private Box blackBoxLeft = null!, blackBoxRight = null!; - private Drawable panelLeft, panelRight, bgPanelLeft, bgPanelRight; + private Drawable panelLeft = null!; + private Drawable panelRight = null!; + private Drawable bgPanelLeft = null!; + private Drawable bgPanelRight = null!; private readonly Beatmap beatmap; @@ -200,7 +204,7 @@ namespace osu.Game.Rulesets.Osu.Mods /// public void AnimateClosedness(float value) => this.TransformTo(nameof(easing), value, 200, Easing.OutQuint); - public class ModBlindsPanel : Sprite + public partial class ModBlindsPanel : Sprite { [BackgroundDependencyLoader] private void load(TextureStore textures) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs b/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs index 99d7535957..769694baf4 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs @@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModCinema : ModCinema { - public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModAutopilot), typeof(OsuModSpunOut), typeof(OsuModAlternate) }).ToArray(); + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModAutopilot), typeof(OsuModSpunOut), typeof(OsuModAlternate), typeof(OsuModSingleTap), typeof(OsuModRepel) }).ToArray(); public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList mods) => new ModReplayData(new OsuAutoGenerator(beatmap, mods).Generate(), new ModCreatedUser { Username = "Autoplay" }); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs index 76ff361ce3..250d97c537 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using osu.Framework.Bindables; +using osu.Framework.Graphics; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; @@ -11,6 +12,7 @@ using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Osu.Mods @@ -28,12 +30,14 @@ namespace osu.Game.Rulesets.Osu.Mods [SettingSource("Apply classic note lock", "Applies note lock to the full hit window.")] public Bindable ClassicNoteLock { get; } = new BindableBool(true); - [SettingSource("Use fixed slider follow circle hit area", "Makes the slider follow circle track its final size at all times.")] - public Bindable FixedFollowCircleHitArea { get; } = new BindableBool(true); - [SettingSource("Always play a slider's tail sample", "Always plays a slider's tail sample regardless of whether it was hit or not.")] public Bindable AlwaysPlayTailSample { get; } = new BindableBool(true); + [SettingSource("Fade out hit circles earlier", "Make hit circles fade out into a miss, rather than after it.")] + public Bindable FadeHitCircleEarly { get; } = new Bindable(true); + + private bool usingHiddenFading; + public void ApplyToHitObject(HitObject hitObject) { switch (hitObject) @@ -54,24 +58,42 @@ namespace osu.Game.Rulesets.Osu.Mods if (ClassicNoteLock.Value) osuRuleset.Playfield.HitPolicy = new ObjectOrderedHitPolicy(); + + usingHiddenFading = drawableRuleset.Mods.OfType().SingleOrDefault()?.OnlyFadeApproachCircles.Value == false; } public void ApplyToDrawableHitObject(DrawableHitObject obj) { switch (obj) { - case DrawableSlider slider: - slider.Ball.InputTracksVisualSize = !FixedFollowCircleHitArea.Value; - break; - case DrawableSliderHead head: head.TrackFollowCircle = !NoSliderHeadMovement.Value; + if (FadeHitCircleEarly.Value && !usingHiddenFading) + applyEarlyFading(head); break; case DrawableSliderTail tail: tail.SamplePlaysOnlyOnHit = !AlwaysPlayTailSample.Value; break; + + case DrawableHitCircle circle: + if (FadeHitCircleEarly.Value && !usingHiddenFading) + applyEarlyFading(circle); + break; } } + + private void applyEarlyFading(DrawableHitCircle circle) + { + circle.ApplyCustomUpdateState += (o, _) => + { + using (o.BeginAbsoluteSequence(o.StateUpdateTime)) + { + double okWindow = o.HitObject.HitWindows.WindowFor(HitResult.Ok); + double lateMissFadeTime = o.HitObject.HitWindows.WindowFor(HitResult.Meh) - okWindow; + o.Delay(okWindow).FadeOut(lateMissFadeTime); + } + }; + } } } 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/OsuModDeflate.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs index ee6a7815e2..f6622c268d 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs @@ -3,7 +3,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; -using osu.Game.Configuration; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Osu.Mods { @@ -15,15 +15,12 @@ namespace osu.Game.Rulesets.Osu.Mods public override IconUsage? Icon => FontAwesome.Solid.CompressArrowsAlt; - public override string Description => "Hit them at the right size!"; + public override LocalisableString Description => "Hit them at the right size!"; - [SettingSource("Starting Size", "The initial size multiplier applied to all objects.")] - public override BindableNumber StartScale { get; } = new BindableFloat + public override BindableNumber StartScale { get; } = new BindableFloat(2) { MinValue = 1f, MaxValue = 25f, - Default = 2f, - Value = 2f, Precision = 0.1f, }; } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDoubleTime.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDoubleTime.cs index 933c6cde1c..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 => 1.12; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs b/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs index 06b5b6cfb8..281b36e70e 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs @@ -1,12 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Osu.Mods { public class OsuModEasy : ModEasyWithExtraLives { - public override string Description => @"Larger circles, more forgiving HP drain, less accuracy required, and three lives!"; + public override LocalisableString Description => @"Larger circles, more forgiving HP drain, less accuracy required, and three lives!"; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs b/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs index 44d72fae61..efeac9a180 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs @@ -17,9 +17,9 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModFlashlight : ModFlashlight, IApplicableToDrawableHitObject + public partial class OsuModFlashlight : ModFlashlight, IApplicableToDrawableHitObject { - public override double ScoreMultiplier => 1.12; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModBlinds)).ToArray(); private const double default_follow_delay = 120; @@ -32,26 +32,18 @@ namespace osu.Game.Rulesets.Osu.Mods Precision = default_follow_delay, }; - [SettingSource("Flashlight size", "Multiplier applied to the default flashlight size.")] - public override BindableFloat SizeMultiplier { get; } = new BindableFloat + public override BindableFloat SizeMultiplier { get; } = new BindableFloat(1) { MinValue = 0.5f, MaxValue = 2f, - Default = 1f, - Value = 1f, Precision = 0.1f }; - [SettingSource("Change size based on combo", "Decrease the flashlight size as combo increases.")] - public override BindableBool ComboBasedSize { get; } = new BindableBool - { - Default = true, - Value = true - }; + public override BindableBool ComboBasedSize { get; } = new BindableBool(true); - public override float DefaultFlashlightSize => 180; + public override float DefaultFlashlightSize => 200; - private OsuFlashlight flashlight; + private OsuFlashlight flashlight = null!; protected override Flashlight CreateFlashlight() => flashlight = new OsuFlashlight(this); @@ -61,7 +53,7 @@ namespace osu.Game.Rulesets.Osu.Mods s.Tracking.ValueChanged += flashlight.OnSliderTrackingChange; } - private class OsuFlashlight : Flashlight, IRequireHighFrequencyMousePosition + private partial class OsuFlashlight : Flashlight, IRequireHighFrequencyMousePosition { private readonly double followDelay; @@ -70,7 +62,8 @@ namespace osu.Game.Rulesets.Osu.Mods { followDelay = modFlashlight.FollowDelay.Value; - FlashlightSize = new Vector2(0, GetSizeFor(0)); + FlashlightSize = new Vector2(0, GetSize()); + FlashlightSmoothness = 1.4f; } public void OnSliderTrackingChange(ValueChangedEvent e) @@ -90,9 +83,9 @@ namespace osu.Game.Rulesets.Osu.Mods return base.OnMouseMove(e); } - protected override void OnComboChange(ValueChangedEvent e) + protected override void UpdateFlashlightSize(float size) { - this.TransformTo(nameof(FlashlightSize), new Vector2(0, GetSizeFor(e.NewValue)), FLASHLIGHT_FADE_DURATION); + this.TransformTo(nameof(FlashlightSize), new Vector2(0, size), FLASHLIGHT_FADE_DURATION); } protected override string FragmentShader => "CircularFlashlight"; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs b/osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs new file mode 100644 index 0000000000..0a1aab9ef1 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs @@ -0,0 +1,91 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public class OsuModFreezeFrame : Mod, IApplicableToDrawableHitObject, IApplicableToBeatmap + { + public override string Name => "Freeze Frame"; + + public override string Acronym => "FR"; + + public override double ScoreMultiplier => 1; + + public override LocalisableString Description => "Burn the notes into your memory."; + + //Alters the transforms of the approach circles, breaking the effects of these mods. + public override Type[] IncompatibleMods => new[] { typeof(OsuModApproachDifferent) }; + + public override ModType Type => ModType.Fun; + + //mod breaks normal approach circle preempt + private double originalPreempt; + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var firstHitObject = beatmap.HitObjects.OfType().FirstOrDefault(); + if (firstHitObject == null) + return; + + double lastNewComboTime = 0; + + originalPreempt = firstHitObject.TimePreempt; + + foreach (var obj in beatmap.HitObjects.OfType()) + { + if (obj.NewCombo) { lastNewComboTime = obj.StartTime; } + + applyFadeInAdjustment(obj); + } + + void applyFadeInAdjustment(OsuHitObject osuObject) + { + osuObject.TimePreempt += osuObject.StartTime - lastNewComboTime; + + foreach (var nested in osuObject.NestedHitObjects.OfType()) + { + switch (nested) + { + //Freezing the SliderTicks doesnt play well with snaking sliders + case SliderTick: + //SliderRepeat wont layer correctly if preempt is changed. + case SliderRepeat: + break; + + default: + applyFadeInAdjustment(nested); + break; + } + } + } + } + + public void ApplyToDrawableHitObject(DrawableHitObject drawableObject) + { + drawableObject.ApplyCustomUpdateState += (drawableHitObject, _) => + { + if (drawableHitObject is not DrawableHitCircle drawableHitCircle) return; + + var hitCircle = drawableHitCircle.HitObject; + var approachCircle = drawableHitCircle.ApproachCircle; + + // Reapply scale, ensuring the AR isn't changed due to the new preempt. + approachCircle.ClearTransforms(targetMember: nameof(approachCircle.Scale)); + approachCircle.ScaleTo(4 * (float)(hitCircle.TimePreempt / originalPreempt)); + + using (drawableHitCircle.ApproachCircle.BeginAbsoluteSequence(hitCircle.StartTime - hitCircle.TimePreempt)) + approachCircle.ScaleTo(1, hitCircle.TimePreempt).Then().Expire(); + }; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs b/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs index 182d6eeb4b..3d066d3ada 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs @@ -3,7 +3,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; -using osu.Game.Configuration; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Osu.Mods { @@ -15,15 +15,12 @@ namespace osu.Game.Rulesets.Osu.Mods public override IconUsage? Icon => FontAwesome.Solid.ArrowsAltV; - public override string Description => "Hit them at the right size!"; + public override LocalisableString Description => "Hit them at the right size!"; - [SettingSource("Starting Size", "The initial size multiplier applied to all objects.")] - public override BindableNumber StartScale { get; } = new BindableFloat + public override BindableNumber StartScale { get; } = new BindableFloat(0.5f) { MinValue = 0f, MaxValue = 0.99f, - Default = 0.5f, - Value = 0.5f, Precision = 0.01f, }; } 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/OsuModHardRock.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs index 007820b016..19d4a1bf83 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModHardRock : ModHardRock, IApplicableToHitObject { - public override double ScoreMultiplier => 1.06; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModMirror)).ToArray(); @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Mods { var osuObject = (OsuHitObject)hitObject; - OsuHitObjectGenerationUtils.ReflectVertically(osuObject); + OsuHitObjectGenerationUtils.ReflectVerticallyAlongPlayfield(osuObject); } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs index fc04e4d091..996ee1cddb 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Linq; using osu.Framework.Graphics; using osu.Framework.Bindables; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; @@ -22,8 +23,8 @@ namespace osu.Game.Rulesets.Osu.Mods [SettingSource("Only fade approach circles", "The main object body will not fade when enabled.")] public Bindable OnlyFadeApproachCircles { get; } = new BindableBool(); - public override string Description => @"Play with no approach circles and fading circles/sliders."; - public override double ScoreMultiplier => 1.06; + public override LocalisableString Description => @"Play with no approach circles and fading circles/sliders."; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1; public override Type[] IncompatibleMods => new[] { typeof(IRequiresApproachCircles), typeof(OsuModSpinIn) }; @@ -86,7 +87,7 @@ namespace osu.Game.Rulesets.Osu.Mods switch (drawableObject) { - case DrawableSliderTail _: + case DrawableSliderTail: using (drawableObject.BeginAbsoluteSequence(fadeStartTime)) drawableObject.FadeOut(fadeDuration); @@ -163,14 +164,14 @@ namespace osu.Game.Rulesets.Osu.Mods switch (hitObject) { - case Slider _: + case Slider: return (fadeOutStartTime, longFadeDuration); - case SliderTick _: + case SliderTick: double tickFadeOutDuration = Math.Min(hitObject.TimePreempt - DrawableSliderTick.ANIM_DURATION, 1000); return (hitObject.StartTime - tickFadeOutDuration, tickFadeOutDuration); - case Spinner _: + case Spinner: return (fadeOutStartTime + longFadeDuration, fadeOutDuration); default: diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs b/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs index ca6e9cfb1d..38d90eb121 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs @@ -3,7 +3,10 @@ using System; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Framework.Timing; using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; @@ -22,11 +25,9 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Acronym => "MG"; public override IconUsage? Icon => FontAwesome.Solid.Magnet; public override ModType Type => ModType.Fun; - public override string Description => "No need to chase the circles – your cursor is a magnet!"; - public override double ScoreMultiplier => 1; - public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModRelax) }; - - private IFrameStableClock gameplayClock; + 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) }; [SettingSource("Attraction strength", "How strong the pull is.", 0)] public BindableFloat AttractionStrength { get; } = new BindableFloat(0.5f) @@ -38,8 +39,6 @@ namespace osu.Game.Rulesets.Osu.Mods public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { - gameplayClock = drawableRuleset.FrameStableClock; - // Hide judgment displays and follow points as they won't make any sense. // Judgements can potentially be turned on in a future where they display at a position relative to their drawable counterpart. drawableRuleset.Playfield.DisplayJudgements.Value = false; @@ -48,34 +47,34 @@ namespace osu.Game.Rulesets.Osu.Mods public void Update(Playfield playfield) { - var cursorPos = playfield.Cursor.ActiveCursor.DrawPosition; + var cursorPos = playfield.Cursor.AsNonNull().ActiveCursor.DrawPosition; foreach (var drawable in playfield.HitObjectContainer.AliveObjects) { switch (drawable) { case DrawableHitCircle circle: - easeTo(circle, cursorPos); + easeTo(playfield.Clock, circle, cursorPos); break; case DrawableSlider slider: if (!slider.HeadCircle.Result.HasResult) - easeTo(slider, cursorPos); + easeTo(playfield.Clock, slider, cursorPos); else - easeTo(slider, cursorPos - slider.Ball.DrawPosition); + easeTo(playfield.Clock, slider, cursorPos - slider.Ball.DrawPosition); break; } } } - private void easeTo(DrawableHitObject hitObject, Vector2 destination) + private void easeTo(IFrameBasedClock clock, DrawableHitObject hitObject, Vector2 destination) { double dampLength = Interpolation.Lerp(3000, 40, AttractionStrength.Value); - float x = (float)Interpolation.DampContinuously(hitObject.X, destination.X, dampLength, gameplayClock.ElapsedFrameTime); - float y = (float)Interpolation.DampContinuously(hitObject.Y, destination.Y, dampLength, gameplayClock.ElapsedFrameTime); + float x = (float)Interpolation.DampContinuously(hitObject.X, destination.X, dampLength, clock.ElapsedFrameTime); + float y = (float)Interpolation.DampContinuously(hitObject.Y, destination.Y, dampLength, clock.ElapsedFrameTime); hitObject.Position = new Vector2(x, y); } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModMirror.cs b/osu.Game.Rulesets.Osu/Mods/OsuModMirror.cs index 3faca0b01f..6d01808fb5 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModMirror.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModMirror.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Bindables; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; @@ -13,7 +14,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModMirror : ModMirror, IApplicableToHitObject { - public override string Description => "Flip objects on the chosen axes."; + public override LocalisableString Description => "Flip objects on the chosen axes."; public override Type[] IncompatibleMods => new[] { typeof(ModHardRock) }; [SettingSource("Mirrored axes", "Choose which axes objects are mirrored over.")] @@ -26,16 +27,16 @@ namespace osu.Game.Rulesets.Osu.Mods switch (Reflection.Value) { case MirrorType.Horizontal: - OsuHitObjectGenerationUtils.ReflectHorizontally(osuObject); + OsuHitObjectGenerationUtils.ReflectHorizontallyAlongPlayfield(osuObject); break; case MirrorType.Vertical: - OsuHitObjectGenerationUtils.ReflectVertically(osuObject); + OsuHitObjectGenerationUtils.ReflectVerticallyAlongPlayfield(osuObject); break; case MirrorType.Both: - OsuHitObjectGenerationUtils.ReflectHorizontally(osuObject); - OsuHitObjectGenerationUtils.ReflectVertically(osuObject); + OsuHitObjectGenerationUtils.ReflectHorizontallyAlongPlayfield(osuObject); + OsuHitObjectGenerationUtils.ReflectVerticallyAlongPlayfield(osuObject); break; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModNightcore.cs b/osu.Game.Rulesets.Osu/Mods/OsuModNightcore.cs index 7780e23a26..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 => 1.12; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs b/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs index 8e377ea632..d1bbae8e1a 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs @@ -2,14 +2,15 @@ // 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.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.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.UI; using osu.Game.Utils; @@ -17,19 +18,12 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModNoScope : ModNoScope, IUpdatableByPlayfield, IApplicableToBeatmap { - public override string Description => "Where's the cursor?"; + public override LocalisableString Description => "Where's the cursor?"; - private PeriodTracker spinnerPeriods; + private PeriodTracker spinnerPeriods = null!; - [SettingSource( - "Hidden at combo", - "The combo count at which the cursor becomes completely hidden", - SettingControlType = typeof(SettingsSlider) - )] - public override BindableInt HiddenComboCount { get; } = new BindableInt + public override BindableInt HiddenComboCount { get; } = new BindableInt(10) { - Default = 10, - Value = 10, MinValue = 0, MaxValue = 50, }; @@ -41,9 +35,15 @@ namespace osu.Game.Rulesets.Osu.Mods public void Update(Playfield playfield) { - bool shouldAlwaysShowCursor = IsBreakTime.Value || spinnerPeriods.IsInAny(playfield.Clock.CurrentTime); + var osuPlayfield = (OsuPlayfield)playfield; + Debug.Assert(osuPlayfield.Cursor != null); + + bool shouldAlwaysShowCursor = IsBreakTime.Value || spinnerPeriods.IsInAny(osuPlayfield.Clock.CurrentTime); float targetAlpha = shouldAlwaysShowCursor ? 1 : ComboBasedAlpha; - playfield.Cursor.Alpha = (float)Interpolation.Lerp(playfield.Cursor.Alpha, targetAlpha, Math.Clamp(playfield.Time.Elapsed / TRANSITION_DURATION, 0, 1)); + float currentAlpha = (float)Interpolation.Lerp(osuPlayfield.Cursor.Alpha, targetAlpha, Math.Clamp(osuPlayfield.Time.Elapsed / TRANSITION_DURATION, 0, 1)); + + osuPlayfield.Cursor.Alpha = currentAlpha; + osuPlayfield.Smoke.Alpha = currentAlpha; } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs b/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs index 70c075276f..6f1206382a 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs @@ -4,6 +4,7 @@ using System; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; @@ -20,6 +21,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override double ScoreMultiplier => 1; + [SettingSource("Starting Size", "The initial size multiplier applied to all objects.")] public abstract BindableNumber StartScale { get; } protected virtual float EndScale => 1; @@ -42,13 +44,13 @@ namespace osu.Game.Rulesets.Osu.Mods // apply grow effect switch (drawable) { - case DrawableSliderHead _: - case DrawableSliderTail _: + case DrawableSliderHead: + case DrawableSliderTail: // special cases we should *not* be scaling. break; - case DrawableSlider _: - case DrawableHitCircle _: + case DrawableSlider: + case DrawableHitCircle: { using (drawable.BeginAbsoluteSequence(h.StartTime - h.TimePreempt)) drawable.ScaleTo(StartScale.Value).Then().ScaleTo(EndScale, h.TimePreempt, Easing.OutSine); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModPerfect.cs b/osu.Game.Rulesets.Osu/Mods/OsuModPerfect.cs index da462eb6e8..33581405a6 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModPerfect.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModPerfect.cs @@ -1,11 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Linq; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Osu.Mods { public class OsuModPerfect : ModPerfect { + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot) }).ToArray(); } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs index 79ff222a89..307d731fd4 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs @@ -1,15 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - using System; +using System.Collections.Generic; using System.Linq; +using osu.Framework.Bindables; +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; +using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Osu.Utils; @@ -20,46 +24,142 @@ namespace osu.Game.Rulesets.Osu.Mods /// public class OsuModRandom : ModRandom, IApplicableToBeatmap { - public override string Description => "It never gets boring!"; + public override LocalisableString Description => "It never gets boring!"; - public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModTarget)).ToArray(); + public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModTargetPractice)).ToArray(); + + [SettingSource("Angle sharpness", "How sharp angles should be", SettingControlType = typeof(SettingsSlider))] + public BindableFloat AngleSharpness { get; } = new BindableFloat(7) + { + MinValue = 1, + MaxValue = 10, + Precision = 0.1f + }; private static readonly float playfield_diagonal = OsuPlayfield.BASE_SIZE.LengthFast; - private Random? rng; + private Random random = null!; public void ApplyToBeatmap(IBeatmap beatmap) { - if (!(beatmap is OsuBeatmap osuBeatmap)) + if (beatmap is not OsuBeatmap osuBeatmap) return; Seed.Value ??= RNG.Next(); - rng = new Random((int)Seed.Value); + random = new Random((int)Seed.Value); var positionInfos = OsuHitObjectGenerationUtils.GeneratePositionInfos(osuBeatmap.HitObjects); - float rateOfChangeMultiplier = 0; + // Offsets the angles of all hit objects in a "section" by the same amount. + float sectionOffset = 0; - foreach (var positionInfo in positionInfos) + // Whether the angles are positive or negative (clockwise or counter-clockwise flow). + bool flowDirection = false; + + for (int i = 0; i < positionInfos.Count; i++) { - // rateOfChangeMultiplier only changes every 5 iterations in a combo - // to prevent shaky-line-shaped streams - if (positionInfo.HitObject.IndexInCurrentCombo % 5 == 0) - rateOfChangeMultiplier = (float)rng.NextDouble() * 2 - 1; - - if (positionInfo == positionInfos.First()) + if (shouldStartNewSection(osuBeatmap, positionInfos, i)) { - positionInfo.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.Y / 2); - positionInfo.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI); + sectionOffset = getRandomOffset(0.0008f); + flowDirection = !flowDirection; + } + + if (positionInfos[i].HitObject is Slider slider && random.NextDouble() < 0.5) + { + OsuHitObjectGenerationUtils.FlipSliderInPlaceHorizontally(slider); + } + + if (i == 0) + { + positionInfos[i].DistanceFromPrevious = (float)(random.NextDouble() * OsuPlayfield.BASE_SIZE.Y / 2); + positionInfos[i].RelativeAngle = (float)(random.NextDouble() * 2 * Math.PI - Math.PI); } else { - positionInfo.RelativeAngle = rateOfChangeMultiplier * 2 * (float)Math.PI * Math.Min(1f, positionInfo.DistanceFromPrevious / (playfield_diagonal * 0.5f)); + // Offsets only the angle of the current hit object if a flow change occurs. + float flowChangeOffset = 0; + + // Offsets only the angle of the current hit object. + float oneTimeOffset = getRandomOffset(0.002f); + + if (shouldApplyFlowChange(positionInfos, i)) + { + flowChangeOffset = getRandomOffset(0.002f); + flowDirection = !flowDirection; + } + + float totalOffset = + // sectionOffset and oneTimeOffset should mainly affect patterns with large spacing. + (sectionOffset + oneTimeOffset) * positionInfos[i].DistanceFromPrevious + + // flowChangeOffset should mainly affect streams. + flowChangeOffset * (playfield_diagonal - positionInfos[i].DistanceFromPrevious); + + positionInfos[i].RelativeAngle = getRelativeTargetAngle(positionInfos[i].DistanceFromPrevious, totalOffset, flowDirection); } } osuBeatmap.HitObjects = OsuHitObjectGenerationUtils.RepositionHitObjects(positionInfos); } + + private float getRandomOffset(float stdDev) + { + // Range: [0.5, 2] + // Higher angle sharpness -> lower multiplier + float customMultiplier = (1.5f * AngleSharpness.MaxValue - AngleSharpness.Value) / (1.5f * AngleSharpness.MaxValue - AngleSharpness.Default); + + return OsuHitObjectGenerationUtils.RandomGaussian(random, 0, stdDev * customMultiplier); + } + + /// The target distance between the previous and the current . + /// The angle (in rad) by which the target angle should be offset. + /// Whether the relative angle should be positive or negative. + private float getRelativeTargetAngle(float targetDistance, float offset, bool flowDirection) + { + // Range: [0.1, 1] + float angleSharpness = AngleSharpness.Value / AngleSharpness.MaxValue; + // Range: [0, 0.9] + float angleWideness = 1 - angleSharpness; + + // Range: [-60, 30] + float customOffsetX = angleSharpness * 100 - 70; + // Range: [-0.075, 0.15] + float customOffsetY = angleWideness * 0.25f - 0.075f; + + targetDistance += customOffsetX; + float angle = (float)(2.16 / (1 + 200 * Math.Exp(0.036 * (targetDistance - 310 + customOffsetX))) + 0.5); + angle += offset + customOffsetY; + + float relativeAngle = (float)Math.PI - angle; + + return flowDirection ? -relativeAngle : relativeAngle; + } + + /// Whether a new section should be started at the current . + private bool shouldStartNewSection(OsuBeatmap beatmap, IReadOnlyList positionInfos, int i) + { + if (i == 0) + return true; + + // Exclude new-combo-spam and 1-2-combos. + bool previousObjectStartedCombo = positionInfos[Math.Max(0, i - 2)].HitObject.IndexInCurrentCombo > 1 && + positionInfos[i - 1].HitObject.NewCombo; + bool previousObjectWasOnDownbeat = OsuHitObjectGenerationUtils.IsHitObjectOnBeat(beatmap, positionInfos[i - 1].HitObject, true); + bool previousObjectWasOnBeat = OsuHitObjectGenerationUtils.IsHitObjectOnBeat(beatmap, positionInfos[i - 1].HitObject); + + return (previousObjectStartedCombo && random.NextDouble() < 0.6f) || + previousObjectWasOnDownbeat || + (previousObjectWasOnBeat && random.NextDouble() < 0.4f); + } + + /// Whether a flow change should be applied at the current . + private bool shouldApplyFlowChange(IReadOnlyList positionInfos, int i) + { + // Exclude new-combo-spam and 1-2-combos. + bool previousObjectStartedCombo = positionInfos[Math.Max(0, i - 2)].HitObject.IndexInCurrentCombo > 1 && + positionInfos[i - 1].HitObject.NewCombo; + + return previousObjectStartedCombo && random.NextDouble() < 0.6f; + } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs index 5f37c6a0ae..32ffb545e0 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs @@ -5,10 +5,12 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.UI; using osu.Game.Screens.Play; @@ -18,8 +20,10 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModRelax : ModRelax, IUpdatableByPlayfield, IApplicableToDrawableRuleset, IApplicableToPlayer { - public override string Description => @"You don't need to click. Give your clicking/tapping fingers a break from the heat of things."; - public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModMagnetised), typeof(OsuModAlternate) }).ToArray(); + public override LocalisableString Description => @"You don't need to click. Give your clicking/tapping fingers a break from the heat of things."; + + public override Type[] IncompatibleMods => + base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModMagnetised), typeof(OsuModAlternate), typeof(OsuModSingleTap) }).ToArray(); /// /// How early before a hitobject's start time to trigger a hit. @@ -29,9 +33,9 @@ namespace osu.Game.Rulesets.Osu.Mods private bool isDownState; private bool wasLeft; - private OsuInputManager osuInputManager; + private OsuInputManager osuInputManager = null!; - private ReplayState state; + private ReplayState state = null!; private double lastStateChangeTime; private bool hasReplay; @@ -39,7 +43,7 @@ namespace osu.Game.Rulesets.Osu.Mods public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { // grab the input manager for future use. - osuInputManager = (OsuInputManager)drawableRuleset.KeyBindingInputManager; + osuInputManager = ((DrawableOsuRuleset)drawableRuleset).KeyBindingInputManager; } public void ApplyToPlayer(Player player) @@ -50,7 +54,7 @@ namespace osu.Game.Rulesets.Osu.Mods return; } - osuInputManager.AllowUserPresses = false; + osuInputManager.AllowGameplayInputs = false; } public void Update(Playfield playfield) @@ -132,7 +136,7 @@ namespace osu.Game.Rulesets.Osu.Mods wasLeft = !wasLeft; } - state?.Apply(osuInputManager.CurrentState, osuInputManager); + state.Apply(osuInputManager.CurrentState, osuInputManager); } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs new file mode 100644 index 0000000000..31a6b69d6b --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs @@ -0,0 +1,94 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Localisation; +using osu.Framework.Timing; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Osu.Utils; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Mods +{ + internal class OsuModRepel : Mod, IUpdatableByPlayfield, IApplicableToDrawableRuleset + { + public override string Name => "Repel"; + public override string Acronym => "RP"; + 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) }; + + [SettingSource("Repulsion strength", "How strong the repulsion is.", 0)] + public BindableFloat RepulsionStrength { get; } = new BindableFloat(0.5f) + { + Precision = 0.05f, + MinValue = 0.05f, + MaxValue = 1.0f, + }; + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + // Hide judgment displays and follow points as they won't make any sense. + // Judgements can potentially be turned on in a future where they display at a position relative to their drawable counterpart. + drawableRuleset.Playfield.DisplayJudgements.Value = false; + (drawableRuleset.Playfield as OsuPlayfield)?.FollowPoints.Hide(); + } + + public void Update(Playfield playfield) + { + var cursorPos = playfield.Cursor.AsNonNull().ActiveCursor.DrawPosition; + + foreach (var drawable in playfield.HitObjectContainer.AliveObjects) + { + var destination = Vector2.Clamp(2 * drawable.Position - cursorPos, Vector2.Zero, OsuPlayfield.BASE_SIZE); + + if (drawable.HitObject is Slider thisSlider) + { + var possibleMovementBounds = OsuHitObjectGenerationUtils.CalculatePossibleMovementBounds(thisSlider); + + destination = Vector2.Clamp( + destination, + new Vector2(possibleMovementBounds.Left, possibleMovementBounds.Top), + new Vector2(possibleMovementBounds.Right, possibleMovementBounds.Bottom) + ); + } + + switch (drawable) + { + case DrawableHitCircle circle: + easeTo(playfield.Clock, circle, destination, cursorPos); + break; + + case DrawableSlider slider: + + if (!slider.HeadCircle.Result.HasResult) + easeTo(playfield.Clock, slider, destination, cursorPos); + else + easeTo(playfield.Clock, slider, destination - slider.Ball.DrawPosition, cursorPos); + + break; + } + } + } + + private void easeTo(IFrameBasedClock clock, DrawableHitObject hitObject, Vector2 destination, Vector2 cursorPos) + { + double dampLength = Vector2.Distance(hitObject.Position, cursorPos) / (0.04 * RepulsionStrength.Value + 0.04); + + float x = (float)Interpolation.DampContinuously(hitObject.X, destination.X, dampLength, clock.ElapsedFrameTime); + float y = (float)Interpolation.DampContinuously(hitObject.Y, destination.Y, dampLength, clock.ElapsedFrameTime); + + hitObject.Position = new Vector2(x, y); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSingleTap.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSingleTap.cs new file mode 100644 index 0000000000..91731b25cf --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSingleTap.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Localisation; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public class OsuModSingleTap : InputBlockingMod + { + public override string Name => @"Single Tap"; + public override string Acronym => @"SG"; + public override LocalisableString Description => @"You must only use one key!"; + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAlternate) }).ToArray(); + + protected override bool CheckValidNewAction(OsuAction action) => LastAcceptedAction == null || LastAcceptedAction == action; + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs index 95e7d13ee7..b0533d0cfa 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs @@ -4,6 +4,7 @@ using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; @@ -18,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Acronym => "SI"; public override IconUsage? Icon => FontAwesome.Solid.Undo; public override ModType Type => ModType.Fun; - public override string Description => "Circles spin in. No approach circles."; + public override LocalisableString Description => "Circles spin in. No approach circles."; public override double ScoreMultiplier => 1; // todo: this mod needs to be incompatible with "hidden" due to forcing the circle to remain opaque, diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs index d9ab749ad3..f691731afe 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs @@ -4,6 +4,7 @@ using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Rulesets.Mods; @@ -18,9 +19,9 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Acronym => "SO"; public override IconUsage? Icon => OsuIcon.ModSpunOut; public override ModType Type => ModType.Automation; - public override string Description => @"Spinners will be automatically completed."; + public override LocalisableString Description => @"Spinners will be automatically completed."; public override double ScoreMultiplier => 0.9; - public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(OsuModAutopilot), typeof(OsuModTarget) }; + public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(OsuModAutopilot), typeof(OsuModTargetPractice) }; public void ApplyToDrawableHitObject(DrawableHitObject hitObject) { diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs index 0b34ab28a3..7e4ffc7408 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using System.Threading; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; @@ -18,14 +19,14 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModStrictTracking : Mod, IApplicableAfterBeatmapConversion, IApplicableToDrawableHitObject, IApplicableToDrawableRuleset + public partial class OsuModStrictTracking : Mod, IApplicableAfterBeatmapConversion, IApplicableToDrawableHitObject, IApplicableToDrawableRuleset { public override string Name => @"Strict Tracking"; public override string Acronym => @"ST"; public override ModType Type => ModType.DifficultyIncrease; - public override string Description => @"Once you start a slider, follow precisely or get a miss."; + public override LocalisableString Description => @"Once you start a slider, follow precisely or get a miss."; public override double ScoreMultiplier => 1.0; - public override Type[] IncompatibleMods => new[] { typeof(ModClassic), typeof(OsuModTarget) }; + public override Type[] IncompatibleMods => new[] { typeof(ModClassic), typeof(OsuModTargetPractice) }; public void ApplyToDrawableHitObject(DrawableHitObject drawable) { @@ -78,7 +79,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override Judgement CreateJudgement() => new OsuJudgement(); } - private class StrictTrackingDrawableSliderTail : DrawableSliderTail + private partial class StrictTrackingDrawableSliderTail : DrawableSliderTail { public override bool DisplayResult => true; } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSuddenDeath.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSuddenDeath.cs index 429fe30fc5..b4edb1581e 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSuddenDeath.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSuddenDeath.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), - typeof(OsuModTarget), + typeof(OsuModTargetPractice), }).ToArray(); } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTargetPractice.cs similarity index 87% rename from osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs rename to osu.Game.Rulesets.Osu/Mods/OsuModTargetPractice.cs index c58c624f5c..77cf340b95 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTargetPractice.cs @@ -7,6 +7,7 @@ using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Beatmaps; @@ -16,7 +17,6 @@ using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Beatmaps; @@ -31,16 +31,15 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModTarget : ModWithVisibilityAdjustment, IApplicableToDrawableRuleset, - IApplicableToHealthProcessor, IApplicableToDifficulty, IApplicableFailOverride, - IHasSeed, IHidesApproachCircles + public class OsuModTargetPractice : ModWithVisibilityAdjustment, IApplicableToDrawableRuleset, + IApplicableToHealthProcessor, IApplicableToDifficulty, IApplicableFailOverride, IHasSeed, IHidesApproachCircles { - public override string Name => "Target"; + public override string Name => "Target Practice"; public override string Acronym => "TP"; public override ModType Type => ModType.Conversion; public override IconUsage? Icon => OsuIcon.ModTarget; - public override string Description => @"Practice keeping up with the beat of the song."; - public override double ScoreMultiplier => 1; + public override LocalisableString Description => @"Practice keeping up with the beat of the song."; + public override double ScoreMultiplier => 0.1; public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { @@ -52,11 +51,10 @@ namespace osu.Game.Rulesets.Osu.Mods }).ToArray(); [SettingSource("Seed", "Use a custom seed instead of a random one", SettingControlType = typeof(SettingsNumberBox))] - public Bindable Seed { get; } = new Bindable - { - Default = null, - Value = null - }; + public Bindable Seed { get; } = new Bindable(); + + [SettingSource("Metronome ticks", "Whether a metronome beat should play in the background")] + public Bindable Metronome { get; } = new BindableBool(true); #region Constants @@ -94,11 +92,7 @@ namespace osu.Game.Rulesets.Osu.Mods #region Private Fields - private ControlPointInfo controlPointInfo; - - private List originalHitObjects; - - private Random rng; + private ControlPointInfo controlPointInfo = null!; #endregion @@ -169,16 +163,17 @@ namespace osu.Game.Rulesets.Osu.Mods public override void ApplyToBeatmap(IBeatmap beatmap) { Seed.Value ??= RNG.Next(); - rng = new Random(Seed.Value.Value); + + var rng = new Random(Seed.Value.Value); var osuBeatmap = (OsuBeatmap)beatmap; if (osuBeatmap.HitObjects.Count == 0) return; controlPointInfo = osuBeatmap.ControlPointInfo; - originalHitObjects = osuBeatmap.HitObjects.OrderBy(x => x.StartTime).ToList(); - var hitObjects = generateBeats(osuBeatmap) + var originalHitObjects = osuBeatmap.HitObjects.OrderBy(x => x.StartTime).ToList(); + var hitObjects = generateBeats(osuBeatmap, originalHitObjects) .Select(beat => { var newCircle = new HitCircle(); @@ -187,21 +182,21 @@ namespace osu.Game.Rulesets.Osu.Mods return (OsuHitObject)newCircle; }).ToList(); - addHitSamples(hitObjects); + addHitSamples(hitObjects, originalHitObjects); - fixComboInfo(hitObjects); + fixComboInfo(hitObjects, originalHitObjects); - randomizeCirclePos(hitObjects); + randomizeCirclePos(hitObjects, rng); osuBeatmap.HitObjects = hitObjects; base.ApplyToBeatmap(beatmap); } - private IEnumerable generateBeats(IBeatmap beatmap) + private IEnumerable generateBeats(IBeatmap beatmap, IReadOnlyCollection originalHitObjects) { - double startTime = originalHitObjects.First().StartTime; - double endTime = originalHitObjects.Last().GetEndTime(); + double startTime = beatmap.HitObjects.First().StartTime; + double endTime = beatmap.GetLastObjectTime(); var beats = beatmap.ControlPointInfo.TimingPoints // Ignore timing points after endTime @@ -211,7 +206,7 @@ namespace osu.Game.Rulesets.Osu.Mods // Remove beats before startTime .Where(beat => almostBigger(beat, startTime)) // Remove beats during breaks - .Where(beat => !isInsideBreakPeriod(beatmap.Breaks, beat)) + .Where(beat => !isInsideBreakPeriod(originalHitObjects, beatmap.Breaks, beat)) .ToList(); // Remove beats that are too close to the next one (e.g. due to timing point changes) @@ -226,7 +221,7 @@ namespace osu.Game.Rulesets.Osu.Mods return beats; } - private void addHitSamples(IEnumerable hitObjects) + private void addHitSamples(IEnumerable hitObjects, List originalHitObjects) { foreach (var obj in hitObjects) { @@ -238,7 +233,7 @@ namespace osu.Game.Rulesets.Osu.Mods } } - private void fixComboInfo(List hitObjects) + private void fixComboInfo(List hitObjects, List originalHitObjects) { // Copy combo indices from an original object at the same time or from the closest preceding object // (Objects lying between two combos are assumed to belong to the preceding combo) @@ -272,7 +267,7 @@ namespace osu.Game.Rulesets.Osu.Mods } } - private void randomizeCirclePos(IReadOnlyList hitObjects) + private void randomizeCirclePos(IReadOnlyList hitObjects, Random rng) { if (hitObjects.Count == 0) return; @@ -339,7 +334,8 @@ namespace osu.Game.Rulesets.Osu.Mods public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { - drawableRuleset.Overlays.Add(new MetronomeBeat(drawableRuleset.Beatmap.HitObjects.First().StartTime)); + if (Metronome.Value) + drawableRuleset.Overlays.Add(new MetronomeBeat(drawableRuleset.Beatmap.HitObjects.First().StartTime)); } #endregion @@ -353,16 +349,19 @@ namespace osu.Game.Rulesets.Osu.Mods /// The given time is also considered to be inside a break if it is earlier than the /// start time of the first original hit object after the break. /// + /// Hit objects order by time. /// The breaks of the beatmap. /// The time to be checked.= - private bool isInsideBreakPeriod(IEnumerable breaks, double time) + private bool isInsideBreakPeriod(IReadOnlyCollection originalHitObjects, IEnumerable breaks, double time) { return breaks.Any(breakPeriod => { - var firstObjAfterBreak = originalHitObjects.First(obj => almostBigger(obj.StartTime, breakPeriod.EndTime)); + OsuHitObject? firstObjAfterBreak = originalHitObjects.FirstOrDefault(obj => almostBigger(obj.StartTime, breakPeriod.EndTime)); return almostBigger(time, breakPeriod.StartTime) - && definitelyBigger(firstObjAfterBreak.StartTime, time); + // There should never really be a break section with no objects after it, but we've seen crashes from users with malformed beatmaps, + // so it's best to guard against this. + && (firstObjAfterBreak == null || definitelyBigger(firstObjAfterBreak.StartTime, time)); }); } @@ -372,7 +371,7 @@ namespace osu.Game.Rulesets.Osu.Mods int i = 0; double currentTime = timingPoint.Time; - while (!definitelyBigger(currentTime, mapEndTime) && controlPointInfo.TimingPointAt(currentTime) == timingPoint) + while (!definitelyBigger(currentTime, mapEndTime) && ReferenceEquals(controlPointInfo.TimingPointAt(currentTime), timingPoint)) { beats.Add(Math.Floor(currentTime)); i++; @@ -403,7 +402,7 @@ namespace osu.Game.Rulesets.Osu.Mods /// The list of hit objects in a beatmap, ordered by StartTime /// The point in time to get samples for /// Hit samples - private IList getSamplesAtTime(IEnumerable hitObjects, double time) + private IList? getSamplesAtTime(IEnumerable hitObjects, double time) { // Get a hit object that // either has StartTime equal to the target time diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs index 7276cc753c..fd5c46a226 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Osu.Mods @@ -9,7 +10,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public override string Name => "Touch Device"; public override string Acronym => "TD"; - public override string Description => "Automatically applied to plays on devices with a touchscreen."; + public override LocalisableString Description => "Automatically applied to plays on devices with a touchscreen."; public override double ScoreMultiplier => 1; public override ModType Type => ModType.System; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs index 07ce009cf8..25d05a88a8 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs @@ -4,6 +4,7 @@ using System; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects.Drawables; @@ -16,7 +17,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Name => "Traceable"; public override string Acronym => "TC"; public override ModType Type => ModType.Fun; - public override string Description => "Put your faith in the approach circles..."; + public override LocalisableString Description => "Put your faith in the approach circles..."; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => new[] { typeof(IHidesApproachCircles) }; @@ -57,7 +58,7 @@ namespace osu.Game.Rulesets.Osu.Mods } } - private void applyCirclePieceState(DrawableOsuHitObject hitObject, IDrawable hitCircle = null) + private void applyCirclePieceState(DrawableOsuHitObject hitObject, IDrawable? hitCircle = null) { var h = hitObject.HitObject; using (hitObject.BeginAbsoluteSequence(h.StartTime - h.TimePreempt)) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs index 45ce4d555a..2354cd50ae 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs @@ -4,6 +4,7 @@ using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; @@ -18,9 +19,9 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Acronym => "TR"; public override IconUsage? Icon => FontAwesome.Solid.ArrowsAlt; public override ModType Type => ModType.Fun; - public override string Description => "Everything rotates. EVERYTHING."; + public override LocalisableString Description => "Everything rotates. EVERYTHING."; public override double ScoreMultiplier => 1; - public override Type[] IncompatibleMods => new[] { typeof(OsuModWiggle), typeof(OsuModMagnetised) }; + public override Type[] IncompatibleMods => new[] { typeof(OsuModWiggle), typeof(OsuModMagnetised), typeof(OsuModRepel) }; private float theta; @@ -32,10 +33,10 @@ namespace osu.Game.Rulesets.Osu.Mods { switch (drawable) { - case DrawableSliderHead _: - case DrawableSliderTail _: - case DrawableSliderTick _: - case DrawableSliderRepeat _: + case DrawableSliderHead: + case DrawableSliderTail: + case DrawableSliderTick: + case DrawableSliderRepeat: return; default: diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs b/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs index 693a5bee0b..a45338d91f 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs @@ -2,8 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; @@ -18,12 +21,19 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Acronym => "WG"; public override IconUsage? Icon => FontAwesome.Solid.Certificate; public override ModType Type => ModType.Fun; - public override string Description => "They just won't stay still..."; + public override LocalisableString Description => "They just won't stay still..."; public override double ScoreMultiplier => 1; - public override Type[] IncompatibleMods => new[] { typeof(OsuModTransform), typeof(OsuModMagnetised) }; + public override Type[] IncompatibleMods => new[] { typeof(OsuModTransform), typeof(OsuModMagnetised), typeof(OsuModRepel) }; - private const int wiggle_duration = 90; // (ms) Higher = fewer wiggles - private const int wiggle_strength = 10; // Higher = stronger wiggles + private const int wiggle_duration = 100; // (ms) Higher = fewer wiggles + + [SettingSource("Strength", "Multiplier applied to the wiggling strength.")] + public BindableDouble Strength { get; } = new BindableDouble(1) + { + MinValue = 0.1f, + MaxValue = 2f, + Precision = 0.1f + }; protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) => drawableOnApplyCustomUpdateState(hitObject, state); @@ -47,7 +57,7 @@ namespace osu.Game.Rulesets.Osu.Mods void wiggle() { float nextAngle = (float)(objRand.NextDouble() * 2 * Math.PI); - float nextDist = (float)(objRand.NextDouble() * wiggle_strength); + float nextDist = (float)(objRand.NextDouble() * Strength.Value * 7); drawable.MoveTo(new Vector2((float)(nextDist * Math.Cos(nextAngle) + origin.X), (float)(nextDist * Math.Sin(nextAngle) + origin.Y)), wiggle_duration); } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPoint.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPoint.cs index b989500066..d588127cb9 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPoint.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPoint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osuTK; using osuTK.Graphics; @@ -17,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections /// /// A single follow point positioned between two adjacent s. /// - public class FollowPoint : PoolableDrawable, IAnimationTimeReference + public partial class FollowPoint : PoolableDrawable, IAnimationTimeReference { private const float width = 8; @@ -27,7 +29,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections { Origin = Anchor.Centre; - InternalChild = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.FollowPoint), _ => new CircularContainer + InternalChild = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.FollowPoint), _ => new CircularContainer { Masking = true, AutoSizeAxes = Axes.Both, diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs index e88d09e6d9..20c36b7804 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using osu.Framework.Graphics; using osu.Framework.Graphics.Pooling; using osu.Game.Rulesets.Objects; @@ -13,38 +14,42 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections /// /// Visualises the s between two s. /// - public class FollowPointConnection : PoolableDrawableWithLifetime + public partial class FollowPointConnection : PoolableDrawableWithLifetime { // Todo: These shouldn't be constants public const int SPACING = 32; public const double PREEMPT = 800; - public DrawablePool Pool; + public DrawablePool? Pool { private get; set; } protected override void OnApply(FollowPointLifetimeEntry entry) { base.OnApply(entry); - entry.Invalidated += onEntryInvalidated; - refreshPoints(); + entry.Invalidated += scheduleRefresh; + + // Our clock may not be correct at this point if `LoadComplete` has not run yet. + // Without a schedule, animations referencing FollowPoint's clock (see `IAnimationTimeReference`) would be incorrect on first pool usage. + scheduleRefresh(); } protected override void OnFree(FollowPointLifetimeEntry entry) { base.OnFree(entry); - entry.Invalidated -= onEntryInvalidated; + entry.Invalidated -= scheduleRefresh; // Return points to the pool. ClearInternal(false); } - private void onEntryInvalidated() => Scheduler.AddOnce(refreshPoints); - - private void refreshPoints() + private void scheduleRefresh() => Scheduler.AddOnce(() => { + Debug.Assert(Pool != null); + ClearInternal(false); var entry = Entry; + if (entry?.End == null) return; OsuHitObject start = entry.Start; @@ -93,7 +98,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections } entry.LifetimeEnd = finalTransformEndTime; - } + }); /// /// Computes the fade time of follow point positioned between two hitobjects. diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointLifetimeEntry.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointLifetimeEntry.cs index ad656d2085..cbec5efda7 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointLifetimeEntry.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointLifetimeEntry.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 enable - using System; using System.Diagnostics; using osu.Framework.Bindables; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs index 011e9fa85d..144eaffe82 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -15,7 +17,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections /// /// Visualises connections between s. /// - public class FollowPointRenderer : PooledDrawableWithLifetimeContainer + public partial class FollowPointRenderer : PooledDrawableWithLifetimeContainer { public new IReadOnlyList Entries => lifetimeEntries; @@ -30,7 +32,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections { InternalChildren = new Drawable[] { - connectionPool = new DrawablePool(1, 200), + connectionPool = new DrawablePool(10, 200), pointPool = new DrawablePool(50, 1000) }; } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs index f05aea0df4..3458069dd1 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using JetBrains.Annotations; @@ -10,6 +12,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Game.Graphics.Containers; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Judgements; @@ -21,7 +24,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableHitCircle : DrawableOsuHitObject, IHasMainCirclePiece, IHasApproachCircle + public partial class DrawableHitCircle : DrawableOsuHitObject, IHasApproachCircle { public OsuAction? HitAction => HitArea.HitAction; protected virtual OsuSkinComponents CirclePieceComponent => OsuSkinComponents.HitCircle; @@ -45,12 +48,14 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { } + private ShakeContainer shakeContainer; + [BackgroundDependencyLoader] private void load() { Origin = Anchor.Centre; - InternalChildren = new Drawable[] + AddRangeInternal(new Drawable[] { scaleContainer = new Container { @@ -70,27 +75,35 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables return true; }, }, - CirclePiece = new SkinnableDrawable(new OsuSkinComponent(CirclePieceComponent), _ => new MainCirclePiece()) + shakeContainer = new ShakeContainer { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - ApproachCircle = new ProxyableSkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.ApproachCircle), _ => new DefaultApproachCircle()) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, + ShakeDuration = 30, RelativeSizeAxes = Axes.Both, - Alpha = 0, - Scale = new Vector2(4), + Children = new Drawable[] + { + CirclePiece = new SkinnableDrawable(new OsuSkinComponentLookup(CirclePieceComponent), _ => new MainCirclePiece()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + ApproachCircle = new ProxyableSkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.ApproachCircle), _ => new DefaultApproachCircle()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Scale = new Vector2(4), + } + } } } }, - }; + }); Size = HitArea.DrawSize; - PositionBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); - StackHeightBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); + PositionBindable.BindValueChanged(_ => UpdatePosition()); + StackHeightBindable.BindValueChanged(_ => UpdatePosition()); ScaleBindable.BindValueChanged(scale => scaleContainer.Scale = new Vector2(scale.NewValue)); } @@ -121,6 +134,13 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables } } + protected virtual void UpdatePosition() + { + Position = HitObject.StackedPosition; + } + + public override void Shake() => shakeContainer.Shake(); + protected override void CheckForResult(bool userTriggered, double timeOffset) { Debug.Assert(HitObject.HitWindows != null); @@ -137,7 +157,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables if (result == HitResult.None || CheckHittable?.Invoke(this, Time.Current) == false) { - Shake(Math.Abs(timeOffset) - HitObject.HitWindows.WindowFor(HitResult.Miss)); + Shake(); return; } @@ -189,12 +209,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables // todo: temporary / arbitrary, used for lifetime optimisation. this.Delay(800).FadeOut(); - // in the case of an early state change, the fade should be expedited to the current point in time. - if (HitStateUpdateTime < HitObject.StartTime) - ApproachCircle.FadeOut(50); - switch (state) { + default: + ApproachCircle.FadeOut(); + break; + case ArmedState.Idle: HitArea.HitAction = null; break; @@ -211,7 +231,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables protected override JudgementResult CreateResult(Judgement judgement) => new OsuHitCircleJudgementResult(HitObject, judgement); - public class HitReceptor : CompositeDrawable, IKeyBindingHandler + public partial class HitReceptor : CompositeDrawable, IKeyBindingHandler { // IsHovered is used public override bool HandlePositionalInput => true; @@ -254,12 +274,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables } } - private class ProxyableSkinnableDrawable : SkinnableDrawable + private partial class ProxyableSkinnableDrawable : SkinnableDrawable { public override bool RemoveWhenNotAlive => false; - public ProxyableSkinnableDrawable(ISkinComponent component, Func defaultImplementation = null, ConfineMode confineMode = ConfineMode.NoScaling) - : base(component, defaultImplementation, confineMode) + public ProxyableSkinnableDrawable(ISkinComponentLookup lookup, Func defaultImplementation = null, ConfineMode confineMode = ConfineMode.NoScaling) + : base(lookup, defaultImplementation, confineMode) { } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs index fa2d2ba38c..df0ba344d8 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs @@ -1,20 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Game.Rulesets.Objects.Drawables; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Judgements; -using osu.Game.Graphics.Containers; +using osu.Game.Rulesets.Osu.Scoring; using osuTK; +using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableOsuHitObject : DrawableHitObject + public abstract partial class DrawableOsuHitObject : DrawableHitObject { public readonly IBindable PositionBindable = new Bindable(); public readonly IBindable StackHeightBindable = new Bindable(); @@ -32,8 +35,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables /// public Func CheckHittable; - private ShakeContainer shakeContainer; - protected DrawableOsuHitObject(OsuHitObject hitObject) : base(hitObject) { @@ -43,12 +44,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables private void load() { Alpha = 0; - - base.AddInternal(shakeContainer = new ShakeContainer - { - ShakeDuration = 30, - RelativeSizeAxes = Axes.Both - }); } protected override void OnApply() @@ -71,18 +66,32 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables ScaleBindable.UnbindFrom(HitObject.ScaleBindable); } - // Forward all internal management to shakeContainer. - // This is a bit ugly but we don't have the concept of InternalContent so it'll have to do for now. (https://github.com/ppy/osu-framework/issues/1690) - protected override void AddInternal(Drawable drawable) => shakeContainer.Add(drawable); - protected override void ClearInternal(bool disposeChildren = true) => shakeContainer.Clear(disposeChildren); - protected override bool RemoveInternal(Drawable drawable) => shakeContainer.Remove(drawable); + protected override void UpdateInitialTransforms() + { + base.UpdateInitialTransforms(); + + // Dim should only be applied at a top level, as it will be implicitly applied to nested objects. + if (ParentHitObject == null) + { + // Of note, no one noticed this was missing for years, but it definitely feels like it should still exist. + // For now this is applied across all skins, and matches stable. + // For simplicity, dim colour is applied to the DrawableHitObject itself. + // We may need to make a nested container setup if this even causes a usage conflict (ie. with a mod). + this.FadeColour(new Color4(195, 195, 195, 255)); + using (BeginDelayedSequence(InitialLifetimeOffset - OsuHitWindows.MISS_WINDOW)) + this.FadeColour(Color4.White, 100); + } + } protected sealed override double InitialLifetimeOffset => HitObject.TimePreempt; private OsuInputManager osuActionInputManager; internal OsuInputManager OsuActionInputManager => osuActionInputManager ??= GetContainingInputManager() as OsuInputManager; - public virtual void Shake(double maximumLength) => shakeContainer.Shake(maximumLength); + /// + /// Shake the hit object in case it was clicked far too early or late (aka "note lock"). + /// + public virtual void Shake() { } /// /// Causes this to get missed, disregarding all conditions in implementations of . diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs index e4df41a4fe..76ae7340ff 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Configuration; @@ -10,9 +12,9 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableOsuJudgement : DrawableJudgement + public partial class DrawableOsuJudgement : DrawableJudgement { - protected SkinnableLighting Lighting { get; private set; } + internal SkinnableLighting Lighting { get; private set; } [Resolved] private OsuConfigManager config { get; set; } @@ -50,7 +52,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Lighting.Alpha = 0; - if (hitLightingEnabled && Lighting.Drawable != null) + if (hitLightingEnabled) { // todo: this animation changes slightly based on new/old legacy skin versions. Lighting.ScaleTo(0.8f).ScaleTo(1.2f, 600, Easing.Out); @@ -65,7 +67,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables protected override Drawable CreateDefaultJudgement(HitResult result) => new OsuJudgementPiece(result); - private class OsuJudgementPiece : DefaultJudgementPiece + private partial class OsuJudgementPiece : DefaultJudgementPiece { public OsuJudgementPiece(HitResult result) : base(result) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index c48ab998ba..a7b02596d5 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using JetBrains.Annotations; @@ -9,27 +11,30 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Audio; +using osu.Game.Graphics.Containers; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Osu.Skinning; using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; using osuTK; -using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableSlider : DrawableOsuHitObject + public partial class DrawableSlider : DrawableOsuHitObject { public new Slider HitObject => (Slider)base.HitObject; public DrawableSliderHead HeadCircle => headContainer.Child; public DrawableSliderTail TailCircle => tailContainer.Child; - public SliderBall Ball { get; private set; } + [Cached] + public DrawableSliderBall Ball { get; private set; } + public SkinnableDrawable Body { get; private set; } + private ShakeContainer shakeContainer; + /// /// A target container which can be used to add top level elements to the slider's display. /// Intended to be used for proxy purposes only. @@ -58,28 +63,38 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public DrawableSlider([CanBeNull] Slider s = null) : base(s) { + Ball = new DrawableSliderBall + { + GetInitialHitAction = () => HeadCircle.HitAction, + BypassAutoSizeAxes = Axes.Both, + AlwaysPresent = true, + Alpha = 0 + }; } [BackgroundDependencyLoader] private void load() { - InternalChildren = new Drawable[] + AddRangeInternal(new Drawable[] { - Body = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderBody), _ => new DefaultSliderBody(), confineMode: ConfineMode.NoScaling), - tailContainer = new Container { RelativeSizeAxes = Axes.Both }, - tickContainer = new Container { RelativeSizeAxes = Axes.Both }, - repeatContainer = new Container { RelativeSizeAxes = Axes.Both }, + shakeContainer = new ShakeContainer + { + ShakeDuration = 30, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + Body = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.SliderBody), _ => new DefaultSliderBody(), confineMode: ConfineMode.NoScaling), + tailContainer = new Container { RelativeSizeAxes = Axes.Both }, + tickContainer = new Container { RelativeSizeAxes = Axes.Both }, + repeatContainer = new Container { RelativeSizeAxes = Axes.Both }, + } + }, + // slider head is not included in shake as it handles hit detection, and handles its own shaking. headContainer = new Container { RelativeSizeAxes = Axes.Both }, OverlayElementContainer = new Container { RelativeSizeAxes = Axes.Both, }, - Ball = new SliderBall(this) - { - GetInitialHitAction = () => HeadCircle.HitAction, - BypassAutoSizeAxes = Axes.Both, - AlwaysPresent = true, - Alpha = 0 - }, + Ball, slidingSample = new PausableSkinnableSound { Looping = true } - }; + }); PositionBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); StackHeightBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); @@ -89,7 +104,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { foreach (var drawableHitObject in NestedHitObjects) drawableHitObject.AccentColour.Value = colour.NewValue; - updateBallTint(); }, true); Tracking.BindValueChanged(updateSlidingSample); @@ -104,13 +118,15 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables PathVersion.BindTo(HitObject.Path.Version); } + public override void Shake() => shakeContainer.Shake(); + protected override void OnFree() { base.OnFree(); PathVersion.UnbindFrom(HitObject.Path.Version); - slidingSample.Samples = null; + slidingSample?.ClearSamples(); } protected override void LoadSamples() @@ -238,22 +254,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables SliderBody?.RecyclePath(); } - protected override void ApplySkin(ISkinSource skin, bool allowFallback) - { - base.ApplySkin(skin, allowFallback); - - updateBallTint(); - } - - private void updateBallTint() - { - if (CurrentSkin == null) - return; - - bool allowBallTint = CurrentSkin.GetConfig(OsuSkinConfiguration.AllowSliderBallTint)?.Value ?? false; - Ball.AccentColour = allowBallTint ? AccentColour.Value : Color4.White; - } - protected override void CheckForResult(bool userTriggered, double timeOffset) { if (userTriggered || Time.Current < HitObject.EndTime) @@ -312,26 +312,22 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { base.UpdateHitStateTransforms(state); - const float fade_out_time = 450; - - // intentionally pile on an extra FadeOut to make it happen much faster. - Ball.FadeOut(fade_out_time / 4, Easing.Out); + const float fade_out_time = 240; switch (state) { case ArmedState.Hit: - Ball.ScaleTo(HitObject.Scale * 1.4f, fade_out_time, Easing.Out); if (SliderBody?.SnakingOut.Value == true) Body.FadeOut(40); // short fade to allow for any body colour to smoothly disappear. break; } - this.FadeOut(fade_out_time, Easing.OutQuint).Expire(); + this.FadeOut(fade_out_time).Expire(); } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => SliderBody?.ReceivePositionalInputAt(screenSpacePos) ?? base.ReceivePositionalInputAt(screenSpacePos); - private class DefaultSliderBody : PlaySliderBody + private partial class DefaultSliderBody : PlaySliderBody { } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SliderBall.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs similarity index 61% rename from osu.Game.Rulesets.Osu/Skinning/Default/SliderBall.cs rename to osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs index 710967b741..e1766adc20 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SliderBall.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs @@ -1,50 +1,38 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Framework.Input; using osu.Framework.Input.Events; -using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; -using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Skinning; using osuTK; -using osuTK.Graphics; -namespace osu.Game.Rulesets.Osu.Skinning.Default +namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class SliderBall : CircularContainer, ISliderProgress, IRequireHighFrequencyMousePosition, IHasAccentColour + public partial class DrawableSliderBall : CircularContainer, ISliderProgress, IRequireHighFrequencyMousePosition { + public const float FOLLOW_AREA = 2.4f; + public Func GetInitialHitAction; - public Color4 AccentColour + private Drawable followCircleReceptor; + private DrawableSlider drawableSlider; + private Drawable ball; + + [BackgroundDependencyLoader] + private void load(DrawableHitObject drawableSlider) { - get => ball.Colour; - set => ball.Colour = value; - } - - /// - /// Whether to track accurately to the visual size of this . - /// If false, tracking will be performed at the final scale at all times. - /// - public bool InputTracksVisualSize = true; - - private readonly Drawable followCircle; - private readonly DrawableSlider drawableSlider; - private readonly Drawable ball; - - public SliderBall(DrawableSlider drawableSlider) - { - this.drawableSlider = drawableSlider; + this.drawableSlider = (DrawableSlider)drawableSlider; Origin = Anchor.Centre; @@ -52,15 +40,20 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default Children = new[] { - followCircle = new FollowCircleContainer + new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.SliderFollowCircle), _ => new DefaultFollowCircle()) { Origin = Anchor.Centre, Anchor = Anchor.Centre, RelativeSizeAxes = Axes.Both, - Alpha = 0, - Child = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderFollowCircle), _ => new DefaultFollowCircle()), }, - ball = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderBall), _ => new DefaultSliderBall()) + followCircleReceptor = new CircularContainer + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true + }, + ball = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.SliderBall), _ => new DefaultSliderBall()) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -86,7 +79,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default public override void ApplyTransformsAt(double time, bool propagateChildren = false) { // For the same reasons as above w.r.t rewinding, we shouldn't propagate to children here either. - // ReSharper disable once RedundantArgumentDefaultValue - removing the "redundant" default value triggers BaseMethodCallWithDefaultParameter + + // ReSharper disable once RedundantArgumentDefaultValue base.ApplyTransformsAt(time, false); } @@ -102,15 +96,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default tracking = value; - if (InputTracksVisualSize) - followCircle.ScaleTo(tracking ? 2.4f : 1f, 300, Easing.OutQuint); - else - { - // We need to always be tracking the final size, at both endpoints. For now, this is achieved by removing the scale duration. - followCircle.ScaleTo(tracking ? 2.4f : 1f); - } - - followCircle.FadeTo(tracking ? 1f : 0, 300, Easing.OutQuint); + followCircleReceptor.Scale = new Vector2(tracking ? FOLLOW_AREA : 1f); } } @@ -168,7 +154,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default // in valid time range Time.Current >= drawableSlider.HitObject.StartTime && Time.Current < drawableSlider.HitObject.EndTime && // in valid position range - lastScreenSpaceMousePosition.HasValue && followCircle.ReceivePositionalInputAt(lastScreenSpaceMousePosition.Value) && + lastScreenSpaceMousePosition.HasValue && followCircleReceptor.ReceivePositionalInputAt(lastScreenSpaceMousePosition.Value) && // valid action (actions?.Any(isValidTrackingAction) ?? false); @@ -193,87 +179,23 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default private Vector2? lastPosition; + private bool rewinding; + public void UpdateProgress(double completionProgress) { Position = drawableSlider.HitObject.CurvePositionAt(completionProgress); var diff = lastPosition.HasValue ? lastPosition.Value - Position : Position - drawableSlider.HitObject.CurvePositionAt(completionProgress + 0.01f); + if (Clock.ElapsedFrameTime != 0) + rewinding = Clock.ElapsedFrameTime < 0; + // Ensure the value is substantially high enough to allow for Atan2 to get a valid angle. if (diff.LengthFast < 0.01f) return; - ball.Rotation = -90 + (float)(-Math.Atan2(diff.X, diff.Y) * 180 / Math.PI); + ball.Rotation = -90 + (float)(-Math.Atan2(diff.X, diff.Y) * 180 / Math.PI) + (rewinding ? 180 : 0); lastPosition = Position; } - - private class FollowCircleContainer : CircularContainer - { - public override bool HandlePositionalInput => true; - } - - public class DefaultFollowCircle : CompositeDrawable - { - public DefaultFollowCircle() - { - RelativeSizeAxes = Axes.Both; - - InternalChild = new CircularContainer - { - RelativeSizeAxes = Axes.Both, - Masking = true, - BorderThickness = 5, - BorderColour = Color4.Orange, - Blending = BlendingParameters.Additive, - Child = new Box - { - Colour = Color4.Orange, - RelativeSizeAxes = Axes.Both, - Alpha = 0.2f, - } - }; - } - } - - public class DefaultSliderBall : CompositeDrawable - { - private Box box; - - [BackgroundDependencyLoader] - private void load(DrawableHitObject drawableObject, ISkinSource skin) - { - var slider = (DrawableSlider)drawableObject; - - RelativeSizeAxes = Axes.Both; - - float radius = skin.GetConfig(OsuSkinConfiguration.SliderPathRadius)?.Value ?? OsuHitObject.OBJECT_RADIUS; - - InternalChild = new CircularContainer - { - Masking = true, - RelativeSizeAxes = Axes.Both, - Scale = new Vector2(radius / OsuHitObject.OBJECT_RADIUS), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Blending = BlendingParameters.Additive, - BorderThickness = 10, - BorderColour = Color4.White, - Alpha = 1, - Child = box = new Box - { - Blending = BlendingParameters.Additive, - RelativeSizeAxes = Axes.Both, - Colour = Color4.White, - AlwaysPresent = true, - Alpha = 0 - } - }; - - slider.Tracking.BindValueChanged(trackingChanged, true); - } - - private void trackingChanged(ValueChangedEvent tracking) => - box.FadeTo(tracking.NewValue ? 0.3f : 0.05f, 200, Easing.OutQuint); - } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs index 2b026e6840..b8a1efabe0 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs @@ -1,17 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using JetBrains.Annotations; -using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableSliderHead : DrawableHitCircle + public partial class DrawableSliderHead : DrawableHitCircle { public new SliderHeadCircle HitObject => (SliderHeadCircle)base.HitObject; @@ -41,13 +42,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { } - [BackgroundDependencyLoader] - private void load() - { - PositionBindable.BindValueChanged(_ => updatePosition()); - pathVersion.BindValueChanged(_ => updatePosition()); - } - protected override void OnFree() { base.OnFree(); @@ -55,13 +49,17 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables pathVersion.UnbindFrom(DrawableSlider.PathVersion); } + protected override void UpdatePosition() + { + // Slider head is always drawn at (0,0). + } + protected override void OnApply() { base.OnApply(); pathVersion.BindTo(DrawableSlider.PathVersion); - OnShake = DrawableSlider.Shake; CheckHittable = (d, t) => DrawableSlider.CheckHittable?.Invoke(d, t) ?? true; } @@ -94,14 +92,10 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables return result.IsHit() ? HitResult.LargeTickHit : HitResult.LargeTickMiss; } - public Action OnShake; - - public override void Shake(double maximumLength) => OnShake?.Invoke(maximumLength); - - private void updatePosition() + public override void Shake() { - if (Slider != null) - Position = HitObject.Position - Slider.Position; + base.Shake(); + DrawableSlider.Shake(); } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs index 673211ac6c..3446d41fb4 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using JetBrains.Annotations; @@ -15,7 +17,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableSliderRepeat : DrawableOsuHitObject, ITrackSnaking, IHasMainCirclePiece + public partial class DrawableSliderRepeat : DrawableOsuHitObject, ITrackSnaking { public new SliderRepeat HitObject => (SliderRepeat)base.HitObject; @@ -50,7 +52,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Origin = Anchor.Centre; Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); - InternalChild = scaleContainer = new Container + AddInternal(scaleContainer = new Container { RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, @@ -58,14 +60,14 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Children = new Drawable[] { // no default for this; only visible in legacy skins. - CirclePiece = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderTailHitCircle), _ => Empty()) + CirclePiece = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.SliderTailHitCircle), _ => Empty()) { Anchor = Anchor.Centre, Origin = Anchor.Centre, }, Arrow = new ReverseArrowPiece(), } - }; + }); ScaleBindable.BindValueChanged(scale => scaleContainer.Scale = new Vector2(scale.NewValue)); } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs index 64964ed396..2c1b68e05a 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Diagnostics; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -8,13 +10,12 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; -using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Skinning; using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableSliderTail : DrawableOsuHitObject, IRequireTracking, IHasMainCirclePiece + public partial class DrawableSliderTail : DrawableOsuHitObject, IRequireTracking { public new SliderTailCircle HitObject => (SliderTailCircle)base.HitObject; @@ -56,7 +57,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Origin = Anchor.Centre; Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); - InternalChildren = new Drawable[] + AddRangeInternal(new Drawable[] { scaleContainer = new Container { @@ -66,10 +67,10 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Children = new Drawable[] { // no default for this; only visible in legacy skins. - CirclePiece = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderTailHitCircle), _ => Empty()) + CirclePiece = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.SliderTailHitCircle), _ => Empty()) } }, - }; + }); ScaleBindable.BindValueChanged(scale => scaleContainer.Scale = new Vector2(scale.NewValue)); } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs index c7bfdb02fb..6d0ae93e62 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Rulesets.Objects.Drawables; @@ -12,7 +14,7 @@ using osu.Framework.Graphics.Containers; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableSliderTick : DrawableOsuHitObject, IRequireTracking + public partial class DrawableSliderTick : DrawableOsuHitObject, IRequireTracking { public const double ANIM_DURATION = 150; @@ -42,7 +44,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); Origin = Anchor.Centre; - InternalChild = scaleContainer = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderScorePoint), _ => new CircularContainer + AddInternal(scaleContainer = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.SliderScorePoint), _ => new CircularContainer { Masking = true, Origin = Anchor.Centre, @@ -59,7 +61,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { Anchor = Anchor.Centre, Origin = Anchor.Centre, - }; + }); ScaleBindable.BindValueChanged(scale => scaleContainer.Scale = new Vector2(scale.NewValue)); } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs index fa095edafa..a5193f1b6e 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using JetBrains.Annotations; @@ -22,7 +24,7 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableSpinner : DrawableOsuHitObject + public partial class DrawableSpinner : DrawableOsuHitObject { public new Spinner HitObject => (Spinner)base.HitObject; @@ -55,7 +57,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables /// public readonly IBindable SpinsPerMinute = new BindableDouble(); - private const double fade_out_duration = 160; + private const double fade_out_duration = 240; public DrawableSpinner() : this(null) @@ -90,7 +92,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables RelativeSizeAxes = Axes.Y, Children = new Drawable[] { - Body = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SpinnerBody), _ => new DefaultSpinner()), + Body = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.SpinnerBody), _ => new DefaultSpinner()), RotationTracker = new SpinnerRotationTracker(this) } }, @@ -117,7 +119,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { base.OnFree(); - spinningSample.Samples = null; + spinningSample.ClearSamples(); } protected override void LoadSamples() diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerBonusTick.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerBonusTick.cs index ffeb14b0a8..f1f4ec983e 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerBonusTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerBonusTick.cs @@ -1,9 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableSpinnerBonusTick : DrawableSpinnerTick + public partial class DrawableSpinnerBonusTick : DrawableSpinnerTick { public DrawableSpinnerBonusTick() : base(null) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs index 39239c8233..34253e3d4f 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableSpinnerTick : DrawableOsuHitObject + public partial class DrawableSpinnerTick : DrawableOsuHitObject { public override bool DisplayResult => false; @@ -23,8 +25,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Origin = Anchor.Centre; } - protected override double MaximumJudgementOffset => DrawableSpinner.HitObject.Duration; - /// /// Apply a judgement result. /// diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/IRequireTracking.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/IRequireTracking.cs index b1815b23c9..55de5a0e8d 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/IRequireTracking.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/IRequireTracking.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Osu.Objects.Drawables { public interface IRequireTracking diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/ITrackSnaking.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/ITrackSnaking.cs index cae2a7c36d..9e8035a1ee 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/ITrackSnaking.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/ITrackSnaking.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/SkinnableLighting.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/SkinnableLighting.cs index c72080c9e5..b39b9c4c54 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/SkinnableLighting.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/SkinnableLighting.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Skinning; @@ -8,7 +10,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class SkinnableLighting : SkinnableSprite + internal partial class SkinnableLighting : SkinnableSprite { private DrawableHitObject targetObject; private JudgementResult targetResult; diff --git a/osu.Game.Rulesets.Osu/Objects/HitCircle.cs b/osu.Game.Rulesets.Osu/Objects/HitCircle.cs index d652db0fd4..5f43e57ed8 100644 --- a/osu.Game.Rulesets.Osu/Objects/HitCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/HitCircle.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Osu.Judgements; diff --git a/osu.Game.Rulesets.Osu/Objects/ISliderProgress.cs b/osu.Game.Rulesets.Osu/Objects/ISliderProgress.cs index 7594f7c2e0..eddd251bda 100644 --- a/osu.Game.Rulesets.Osu/Objects/ISliderProgress.cs +++ b/osu.Game.Rulesets.Osu/Objects/ISliderProgress.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Osu.Objects { public interface ISliderProgress diff --git a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs index 8b7de9e109..7b98fc48e0 100644 --- a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs @@ -1,16 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using osu.Framework.Bindables; using osu.Game.Beatmaps; -using osu.Game.Rulesets.Objects; -using osuTK; -using osu.Game.Rulesets.Objects.Types; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Scoring; +using osuTK; namespace osu.Game.Rulesets.Osu.Objects { @@ -34,12 +36,14 @@ namespace osu.Game.Rulesets.Osu.Objects public double TimePreempt = 600; public double TimeFadeIn = 400; - public readonly Bindable PositionBindable = new Bindable(); + private HitObjectProperty position; + + public Bindable PositionBindable => position.Bindable; public virtual Vector2 Position { - get => PositionBindable.Value; - set => PositionBindable.Value = value; + get => position.Value; + set => position.Value = value; } public float X => Position.X; @@ -51,66 +55,80 @@ namespace osu.Game.Rulesets.Osu.Objects public Vector2 StackedEndPosition => EndPosition + StackOffset; - public readonly Bindable StackHeightBindable = new Bindable(); + private HitObjectProperty stackHeight; + + public Bindable StackHeightBindable => stackHeight.Bindable; public int StackHeight { - get => StackHeightBindable.Value; - set => StackHeightBindable.Value = value; + get => stackHeight.Value; + set => stackHeight.Value = value; } public virtual Vector2 StackOffset => new Vector2(StackHeight * Scale * -6.4f); public double Radius => OBJECT_RADIUS * Scale; - public readonly Bindable ScaleBindable = new BindableFloat(1); + private HitObjectProperty scale = new HitObjectProperty(1); + + public Bindable ScaleBindable => scale.Bindable; public float Scale { - get => ScaleBindable.Value; - set => ScaleBindable.Value = value; + get => scale.Value; + set => scale.Value = value; } public virtual bool NewCombo { get; set; } - public readonly Bindable ComboOffsetBindable = new Bindable(); + private HitObjectProperty comboOffset; + + public Bindable ComboOffsetBindable => comboOffset.Bindable; public int ComboOffset { - get => ComboOffsetBindable.Value; - set => ComboOffsetBindable.Value = value; + get => comboOffset.Value; + set => comboOffset.Value = value; } - public Bindable IndexInCurrentComboBindable { get; } = new Bindable(); + private HitObjectProperty indexInCurrentCombo; + + public Bindable IndexInCurrentComboBindable => indexInCurrentCombo.Bindable; public virtual int IndexInCurrentCombo { - get => IndexInCurrentComboBindable.Value; - set => IndexInCurrentComboBindable.Value = value; + get => indexInCurrentCombo.Value; + set => indexInCurrentCombo.Value = value; } - public Bindable ComboIndexBindable { get; } = new Bindable(); + private HitObjectProperty comboIndex; + + public Bindable ComboIndexBindable => comboIndex.Bindable; public virtual int ComboIndex { - get => ComboIndexBindable.Value; - set => ComboIndexBindable.Value = value; + get => comboIndex.Value; + set => comboIndex.Value = value; } - public Bindable ComboIndexWithOffsetsBindable { get; } = new Bindable(); + private HitObjectProperty comboIndexWithOffsets; + + public Bindable ComboIndexWithOffsetsBindable => comboIndexWithOffsets.Bindable; public int ComboIndexWithOffsets { - get => ComboIndexWithOffsetsBindable.Value; - set => ComboIndexWithOffsetsBindable.Value = value; + get => comboIndexWithOffsets.Value; + set => comboIndexWithOffsets.Value = value; } - public Bindable LastInComboBindable { get; } = new Bindable(); + private HitObjectProperty lastInCombo; + + public Bindable LastInComboBindable => lastInCombo.Bindable; public bool LastInCombo { - get => LastInComboBindable.Value; - set => LastInComboBindable.Value = value; + get => lastInCombo.Value; + set => lastInCombo.Value = value; } protected OsuHitObject() diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index a698311bf7..6c2be8a49a 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK; using osu.Game.Rulesets.Objects.Types; using System.Collections.Generic; @@ -12,6 +14,7 @@ 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; @@ -31,21 +34,6 @@ namespace osu.Game.Rulesets.Osu.Objects public override IList AuxiliarySamples => CreateSlidingSamples().Concat(TailSamples).ToArray(); - public IList CreateSlidingSamples() - { - var slidingSamples = new List(); - - var normalSample = Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL); - if (normalSample != null) - slidingSamples.Add(normalSample.With("sliderslide")); - - var whistleSample = Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_WHISTLE); - if (whistleSample != null) - slidingSamples.Add(whistleSample.With("sliderwhistle")); - - return slidingSamples; - } - private readonly Cached endPositionCache = new Cached(); public override Vector2 EndPosition => endPositionCache.IsValid ? endPositionCache.Value : endPositionCache.Value = Position + this.CurvePositionAt(1); @@ -154,7 +142,7 @@ namespace osu.Game.Rulesets.Osu.Objects public Slider() { - SamplesBindable.CollectionChanged += (_, __) => UpdateNestedSamples(); + SamplesBindable.CollectionChanged += (_, _) => UpdateNestedSamples(); Path.Version.ValueChanged += _ => updateNestedPositions(); } @@ -163,11 +151,15 @@ 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; Velocity = scoringDistance / timingPoint.BeatLength; - TickDistance = scoringDistance / difficulty.SliderTickRate * TickDistanceMultiplier; + 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 f893559548..35bec92354 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderEndCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderEndCircle.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Scoring; diff --git a/osu.Game.Rulesets.Osu/Objects/SliderHeadCircle.cs b/osu.Game.Rulesets.Osu/Objects/SliderHeadCircle.cs index 5672283230..2a84b04030 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderHeadCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderHeadCircle.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Osu.Judgements; diff --git a/osu.Game.Rulesets.Osu/Objects/SliderRepeat.cs b/osu.Game.Rulesets.Osu/Objects/SliderRepeat.cs index cca86361c2..7b9316f8ac 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderRepeat.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderRepeat.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Scoring; diff --git a/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs b/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs index f9450062f4..87c8117b6b 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Judgements; diff --git a/osu.Game.Rulesets.Osu/Objects/SliderTick.cs b/osu.Game.Rulesets.Osu/Objects/SliderTick.cs index e7e64954e9..676ff62455 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderTick.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Judgements; @@ -25,7 +27,7 @@ namespace osu.Game.Rulesets.Osu.Objects // This is so on repeats ticks don't appear too late to be visually processed by the player. offset = 200; else - offset = TimeFadeIn * 0.66f; + offset = TimePreempt * 0.66f; TimePreempt = (StartTime - SpanStartTime) / 2 + offset; } diff --git a/osu.Game.Rulesets.Osu/Objects/Spinner.cs b/osu.Game.Rulesets.Osu/Objects/Spinner.cs index 1a130e96b3..ed6f8a9a6a 100644 --- a/osu.Game.Rulesets.Osu/Objects/Spinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Spinner.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -69,8 +71,8 @@ namespace osu.Game.Rulesets.Osu.Objects double startTime = StartTime + (float)(i + 1) / totalSpins * Duration; AddNested(i < SpinsRequired - ? new SpinnerTick { StartTime = startTime } - : new SpinnerBonusTick { StartTime = startTime }); + ? new SpinnerTick { StartTime = startTime, SpinnerDuration = Duration } + : new SpinnerBonusTick { StartTime = startTime, SpinnerDuration = Duration }); } } diff --git a/osu.Game.Rulesets.Osu/Objects/SpinnerBonusTick.cs b/osu.Game.Rulesets.Osu/Objects/SpinnerBonusTick.cs index 2c443cb96b..81cdf5755b 100644 --- a/osu.Game.Rulesets.Osu/Objects/SpinnerBonusTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/SpinnerBonusTick.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Audio; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; diff --git a/osu.Game.Rulesets.Osu/Objects/SpinnerTick.cs b/osu.Game.Rulesets.Osu/Objects/SpinnerTick.cs index d715b9a428..c890f3771b 100644 --- a/osu.Game.Rulesets.Osu/Objects/SpinnerTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/SpinnerTick.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Scoring; @@ -9,10 +11,17 @@ namespace osu.Game.Rulesets.Osu.Objects { public class SpinnerTick : OsuHitObject { + /// + /// Duration of the containing this spinner tick. + /// + public double SpinnerDuration { get; set; } + public override Judgement CreateJudgement() => new OsuSpinnerTickJudgement(); protected override HitWindows CreateHitWindows() => HitWindows.Empty; + public override double MaximumJudgementOffset => SpinnerDuration; + public class OsuSpinnerTickJudgement : OsuJudgement { public override HitResult MaxResult => HitResult.SmallBonus; diff --git a/osu.Game.Rulesets.Osu/OsuInputManager.cs b/osu.Game.Rulesets.Osu/OsuInputManager.cs index 5c6b907e42..ccd388192e 100644 --- a/osu.Game.Rulesets.Osu/OsuInputManager.cs +++ b/osu.Game.Rulesets.Osu/OsuInputManager.cs @@ -3,21 +3,34 @@ using System.Collections.Generic; using System.ComponentModel; -using osu.Framework.Input; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; -using osu.Framework.Input.StateChanges.Events; +using osu.Game.Input.Bindings; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.UI; +using osuTK; namespace osu.Game.Rulesets.Osu { - public class OsuInputManager : RulesetInputManager + public partial class OsuInputManager : RulesetInputManager { public IEnumerable PressedActions => KeyBindingContainer.PressedActions; - public bool AllowUserPresses + /// + /// Whether gameplay input buttons should be allowed. + /// Defaults to true, generally used for mods like Relax which turn off main inputs. + /// + /// + /// Of note, auxiliary inputs like the "smoke" key are left usable. + /// + public bool AllowGameplayInputs { - set => ((OsuKeyBindingContainer)KeyBindingContainer).AllowUserPresses = value; + get => ((OsuKeyBindingContainer)KeyBindingContainer).AllowGameplayInputs; + set => ((OsuKeyBindingContainer)KeyBindingContainer).AllowGameplayInputs = value; } /// @@ -29,11 +42,24 @@ namespace osu.Game.Rulesets.Osu protected override KeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) => new OsuKeyBindingContainer(ruleset, variant, unique); + public bool CheckScreenSpaceActionPressJudgeable(Vector2 screenSpacePosition) => + // This is a very naive but simple approach. + // + // Based on user feedback of more nuanced scenarios (where touch doesn't behave as expected), + // this can be expanded to a more complex implementation, but I'd still want to keep it as simple as we can. + NonPositionalInputQueue.OfType().Any(c => c.ReceivePositionalInputAt(screenSpacePosition)); + public OsuInputManager(RulesetInfo ruleset) : base(ruleset, 0, SimultaneousBindingMode.Unique) { } + [BackgroundDependencyLoader] + private void load() + { + Add(new OsuTouchInputMapper(this) { RelativeSizeAxes = Axes.Both }); + } + protected override bool Handle(UIEvent e) { if ((e is MouseMoveEvent || e is TouchMoveEvent) && !AllowUserCursorMovement) return false; @@ -41,33 +67,38 @@ namespace osu.Game.Rulesets.Osu return base.Handle(e); } - protected override bool HandleMouseTouchStateChange(TouchStateChangeEvent e) + private partial class OsuKeyBindingContainer : RulesetKeyBindingContainer { - if (!AllowUserCursorMovement) + private bool allowGameplayInputs = true; + + /// + /// Whether gameplay input buttons should be allowed. + /// Defaults to true, generally used for mods like Relax which turn off main inputs. + /// + /// + /// Of note, auxiliary inputs like the "smoke" key are left usable. + /// + public bool AllowGameplayInputs { - // Still allow for forwarding of the "touch" part, but replace the positional data with that of the mouse. - // Primarily relied upon by the "autopilot" osu! mod. - var touch = new Touch(e.Touch.Source, CurrentState.Mouse.Position); - e = new TouchStateChangeEvent(e.State, e.Input, touch, e.IsActive, null); + get => allowGameplayInputs; + set + { + allowGameplayInputs = value; + ReloadMappings(); + } } - return base.HandleMouseTouchStateChange(e); - } - - private class OsuKeyBindingContainer : RulesetKeyBindingContainer - { - public bool AllowUserPresses = true; - public OsuKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) : base(ruleset, variant, unique) { } - protected override bool Handle(UIEvent e) + protected override void ReloadMappings(IQueryable realmKeyBindings) { - if (!AllowUserPresses) return false; + base.ReloadMappings(realmKeyBindings); - return base.Handle(e); + if (!AllowGameplayInputs) + KeyBindings = KeyBindings.Where(b => b.GetAction() == OsuAction.Smoke).ToList(); } } } @@ -78,6 +109,9 @@ namespace osu.Game.Rulesets.Osu LeftButton, [Description("Right button")] - RightButton + RightButton, + + [Description("Smoke")] + Smoke, } } diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 207e7a4ab0..48056a49de 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -1,47 +1,49 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Rulesets.Osu.UI; -using osu.Game.Rulesets.UI; +using System; using System.Collections.Generic; +using System.Linq; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; -using osu.Game.Overlays.Settings; using osu.Framework.Input.Bindings; -using osu.Game.Rulesets.Osu.Edit; -using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Osu.Replays; -using osu.Game.Rulesets.Replays.Types; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; using osu.Game.Beatmaps.Legacy; using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Configuration; using osu.Game.Rulesets.Osu.Difficulty; -using osu.Game.Rulesets.Osu.Scoring; -using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring; -using osu.Game.Skinning; -using System; -using System.Linq; -using osu.Framework.Extensions.EnumExtensions; +using osu.Game.Rulesets.Osu.Edit; using osu.Game.Rulesets.Osu.Edit.Setup; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Osu.Scoring; +using osu.Game.Rulesets.Osu.Skinning.Argon; using osu.Game.Rulesets.Osu.Skinning.Legacy; using osu.Game.Rulesets.Osu.Statistics; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Replays.Types; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Ranking.Statistics; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Osu { public class OsuRuleset : Ruleset, ILegacyRuleset { - public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableOsuRuleset(this, beatmap, mods); + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList? mods = null) => new DrawableOsuRuleset(this, beatmap, mods); public override ScoreProcessor CreateScoreProcessor() => new OsuScoreProcessor(); @@ -51,10 +53,13 @@ namespace osu.Game.Rulesets.Osu public const string SHORT_NAME = "osu"; + public override string RulesetAPIVersionSupported => CURRENT_RULESET_API_VERSION; + public override IEnumerable GetDefaultKeyBindings(int variant = 0) => new[] { new KeyBinding(InputKey.Z, OsuAction.LeftButton), new KeyBinding(InputKey.X, OsuAction.RightButton), + new KeyBinding(InputKey.C, OsuAction.Smoke), new KeyBinding(InputKey.MouseLeft, OsuAction.LeftButton), new KeyBinding(InputKey.MouseRight, OsuAction.RightButton), }; @@ -104,7 +109,7 @@ namespace osu.Game.Rulesets.Osu yield return new OsuModSpunOut(); if (mods.HasFlagFast(LegacyMods.Target)) - yield return new OsuModTarget(); + yield return new OsuModTargetPractice(); if (mods.HasFlagFast(LegacyMods.TouchDevice)) yield return new OsuModTouchDevice(); @@ -118,19 +123,19 @@ namespace osu.Game.Rulesets.Osu { switch (mod) { - case OsuModAutopilot _: + case OsuModAutopilot: value |= LegacyMods.Autopilot; break; - case OsuModSpunOut _: + case OsuModSpunOut: value |= LegacyMods.SpunOut; break; - case OsuModTarget _: + case OsuModTargetPractice: value |= LegacyMods.Target; break; - case OsuModTouchDevice _: + case OsuModTouchDevice: value |= LegacyMods.TouchDevice; break; } @@ -159,18 +164,19 @@ namespace osu.Game.Rulesets.Osu new MultiMod(new OsuModDoubleTime(), new OsuModNightcore()), new OsuModHidden(), new MultiMod(new OsuModFlashlight(), new OsuModBlinds()), - new OsuModStrictTracking() + new OsuModStrictTracking(), + new OsuModAccuracyChallenge(), }; case ModType.Conversion: return new Mod[] { - new OsuModTarget(), + new OsuModTargetPractice(), new OsuModDifficultyAdjust(), new OsuModClassic(), new OsuModRandom(), new OsuModMirror(), - new OsuModAlternate(), + new MultiMod(new OsuModAlternate(), new OsuModSingleTap()) }; case ModType.Automation: @@ -195,8 +201,9 @@ namespace osu.Game.Rulesets.Osu new OsuModApproachDifferent(), new OsuModMuted(), new OsuModNoScope(), - new OsuModMagnetised(), - new ModAdaptiveSpeed() + new MultiMod(new OsuModMagnetised(), new OsuModRepel()), + new ModAdaptiveSpeed(), + new OsuModFreezeFrame() }; case ModType.System: @@ -228,13 +235,25 @@ namespace osu.Game.Rulesets.Osu public override RulesetSettingsSubsection CreateSettings() => new OsuSettingsSubsection(this); - public override ISkin CreateLegacySkinProvider(ISkin skin, IBeatmap beatmap) => new OsuLegacySkinTransformer(skin); + public override ISkin? CreateSkinTransformer(ISkin skin, IBeatmap beatmap) + { + switch (skin) + { + case LegacySkin: + return new OsuLegacySkinTransformer(skin); + + case ArgonSkin: + return new OsuArgonSkinTransformer(skin); + } + + return null; + } public int LegacyID => 0; public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new OsuReplayFrame(); - public override IRulesetConfigManager CreateConfig(SettingsStore settings) => new OsuRulesetConfigManager(settings, RulesetInfo); + public override IRulesetConfigManager CreateConfig(SettingsStore? settings) => new OsuRulesetConfigManager(settings, RulesetInfo); protected override IEnumerable GetValidHitResults() { @@ -251,7 +270,7 @@ namespace osu.Game.Rulesets.Osu }; } - public override string GetDisplayNameForHitResult(HitResult result) + public override LocalisableString GetDisplayNameForHitResult(HitResult result) { switch (result) { diff --git a/osu.Game.Rulesets.Osu/OsuSkinComponent.cs b/osu.Game.Rulesets.Osu/OsuSkinComponentLookup.cs similarity index 69% rename from osu.Game.Rulesets.Osu/OsuSkinComponent.cs rename to osu.Game.Rulesets.Osu/OsuSkinComponentLookup.cs index 1d223f231b..81d5811f85 100644 --- a/osu.Game.Rulesets.Osu/OsuSkinComponent.cs +++ b/osu.Game.Rulesets.Osu/OsuSkinComponentLookup.cs @@ -5,15 +5,15 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Osu { - public class OsuSkinComponent : GameplaySkinComponent + public class OsuSkinComponentLookup : GameplaySkinComponentLookup { - public OsuSkinComponent(OsuSkinComponents component) + public OsuSkinComponentLookup(OsuSkinComponents component) : base(component) { } protected override string RulesetPrefix => OsuRuleset.SHORT_NAME; - protected override string ComponentName => Component.ToString().ToLower(); + protected override string ComponentName => Component.ToString().ToLowerInvariant(); } } diff --git a/osu.Game.Rulesets.Osu/OsuSkinComponents.cs b/osu.Game.Rulesets.Osu/OsuSkinComponents.cs index 71657ed532..8fdf3821fa 100644 --- a/osu.Game.Rulesets.Osu/OsuSkinComponents.cs +++ b/osu.Game.Rulesets.Osu/OsuSkinComponents.cs @@ -19,6 +19,7 @@ namespace osu.Game.Rulesets.Osu SliderBall, SliderBody, SpinnerBody, + CursorSmoke, ApproachCircle, } } diff --git a/osu.Game.Rulesets.Osu/Properties/AssemblyInfo.cs b/osu.Game.Rulesets.Osu/Properties/AssemblyInfo.cs index c842874635..7fffb1871f 100644 --- a/osu.Game.Rulesets.Osu/Properties/AssemblyInfo.cs +++ b/osu.Game.Rulesets.Osu/Properties/AssemblyInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Runtime.CompilerServices; // We publish our internal attributes to other sub-projects of the framework. diff --git a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs index e231550e3e..5a3d882ef0 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs @@ -93,7 +93,7 @@ namespace osu.Game.Rulesets.Osu.Replays { double endTime = prev.GetEndTime(); - HitWindows hitWindows = null; + HitWindows? hitWindows = null; switch (h) { @@ -105,7 +105,7 @@ namespace osu.Game.Rulesets.Osu.Replays hitWindows = slider.TailCircle.HitWindows; break; - case Spinner _: + case Spinner: hitWindows = defaultHitWindows; break; } @@ -243,7 +243,7 @@ namespace osu.Game.Rulesets.Osu.Replays } double timeDifference = ApplyModsToTimeDelta(lastFrame.Time, h.StartTime); - OsuReplayFrame lastLastFrame = Frames.Count >= 2 ? (OsuReplayFrame)Frames[^2] : null; + OsuReplayFrame? lastLastFrame = Frames.Count >= 2 ? (OsuReplayFrame)Frames[^2] : null; if (timeDifference > 0) { diff --git a/osu.Game.Rulesets.Osu/Replays/OsuAutoGeneratorBase.cs b/osu.Game.Rulesets.Osu/Replays/OsuAutoGeneratorBase.cs index 1cb3208c30..74e16f7e0b 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuAutoGeneratorBase.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuAutoGeneratorBase.cs @@ -82,10 +82,10 @@ namespace osu.Game.Rulesets.Osu.Replays private class ReplayFrameComparer : IComparer { - public int Compare(ReplayFrame f1, ReplayFrame f2) + public int Compare(ReplayFrame? f1, ReplayFrame? f2) { - if (f1 == null) throw new ArgumentNullException(nameof(f1)); - if (f2 == null) throw new ArgumentNullException(nameof(f2)); + ArgumentNullException.ThrowIfNull(f1); + ArgumentNullException.ThrowIfNull(f2); return f1.Time.CompareTo(f2.Time); } diff --git a/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs b/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs index 3db81d70da..8082c5aef4 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs @@ -26,11 +26,12 @@ namespace osu.Game.Rulesets.Osu.Replays Actions.AddRange(actions); } - public void FromLegacy(LegacyReplayFrame currentFrame, IBeatmap beatmap, ReplayFrame lastFrame = null) + public void FromLegacy(LegacyReplayFrame currentFrame, IBeatmap beatmap, ReplayFrame? lastFrame = null) { Position = currentFrame.Position; if (currentFrame.MouseLeft) Actions.Add(OsuAction.LeftButton); if (currentFrame.MouseRight) Actions.Add(OsuAction.RightButton); + if (currentFrame.Smoke) Actions.Add(OsuAction.Smoke); } public LegacyReplayFrame ToLegacy(IBeatmap beatmap) @@ -41,6 +42,8 @@ namespace osu.Game.Rulesets.Osu.Replays state |= ReplayButtonState.Left1; if (Actions.Contains(OsuAction.RightButton)) state |= ReplayButtonState.Right1; + if (Actions.Contains(OsuAction.Smoke)) + state |= ReplayButtonState.Smoke; return new LegacyReplayFrame(Time, Position.X, Position.Y, state); } diff --git a/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs b/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs index dafe63a6d1..6f55e1790f 100644 --- a/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs +++ b/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs @@ -7,12 +7,17 @@ namespace osu.Game.Rulesets.Osu.Scoring { public class OsuHitWindows : HitWindows { + /// + /// osu! ruleset has a fixed miss window regardless of difficulty settings. + /// + public const double MISS_WINDOW = 400; + private static readonly DifficultyRange[] osu_ranges = { new DifficultyRange(HitResult.Great, 80, 50, 20), new DifficultyRange(HitResult.Ok, 140, 100, 60), new DifficultyRange(HitResult.Meh, 200, 150, 100), - new DifficultyRange(HitResult.Miss, 400, 400, 400), + new DifficultyRange(HitResult.Miss, MISS_WINDOW, MISS_WINDOW, MISS_WINDOW), }; public override bool IsHitResultAllowed(HitResult result) diff --git a/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs b/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs index ab0c0850dc..50d4eb6258 100644 --- a/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs +++ b/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Judgements; @@ -9,7 +11,7 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Scoring { - public class OsuScoreProcessor : ScoreProcessor + public partial class OsuScoreProcessor : ScoreProcessor { public OsuScoreProcessor() : base(new OsuRuleset()) @@ -25,7 +27,7 @@ namespace osu.Game.Rulesets.Osu.Scoring { switch (hitObject) { - case HitCircle _: + case HitCircle: return new OsuHitCircleJudgementResult(hitObject, judgement); default: diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonCursor.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonCursor.cs new file mode 100644 index 0000000000..4ca6abfdf7 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonCursor.cs @@ -0,0 +1,80 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Osu.UI.Cursor; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public partial class ArgonCursor : OsuCursorSprite + { + public ArgonCursor() + { + RelativeSizeAxes = Axes.Both; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + InternalChildren = new[] + { + ExpandTarget = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = 6, + BorderColour = ColourInfo.GradientVertical(Colour4.FromHex("FC618F"), Colour4.FromHex("BB1A41")), + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.4f, + Colour = Colour4.FromHex("FC618F").Darken(0.6f), + }, + new CircularContainer + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = 2, + BorderColour = Color4.White.Opacity(0.8f), + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + }, + }, + }, + }, + }, + new Circle + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Scale = new Vector2(0.2f), + Colour = new Color4(255, 255, 255, 255), + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 20, + Colour = new Color4(171, 255, 255, 100), + }, + }, + }; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonCursorTrail.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonCursorTrail.cs new file mode 100644 index 0000000000..28991b4a62 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonCursorTrail.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; +using osu.Game.Rulesets.Osu.UI.Cursor; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public partial class ArgonCursorTrail : CursorTrail + { + protected override float IntervalMultiplier => 0.4f; + + protected override float FadeExponent => 4; + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + Texture = textures.Get(@"Cursor/cursortrail"); + Scale = new Vector2(0.8f / Texture.ScaleAdjust); + + Blending = BlendingParameters.Additive; + + Alpha = 0.8f; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonFollowCircle.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonFollowCircle.cs new file mode 100644 index 0000000000..fca3e70236 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonFollowCircle.cs @@ -0,0 +1,100 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public partial class ArgonFollowCircle : FollowCircle + { + private readonly CircularContainer circleContainer; + private readonly Box circleFill; + + private readonly IBindable accentColour = new Bindable(); + + [Resolved(canBeNull: true)] + private DrawableHitObject? parentObject { get; set; } + + public ArgonFollowCircle() + { + InternalChild = circleContainer = new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = 4, + Blending = BlendingParameters.Additive, + Child = circleFill = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.3f, + } + }; + } + + [BackgroundDependencyLoader] + private void load() + { + if (parentObject != null) + accentColour.BindTo(parentObject.AccentColour); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + accentColour.BindValueChanged(colour => + { + circleContainer.BorderColour = ColourInfo.GradientVertical(colour.NewValue, colour.NewValue.Darken(0.5f)); + circleFill.Colour = ColourInfo.GradientVertical(colour.NewValue, colour.NewValue.Darken(0.5f)); + }, true); + } + + protected override void OnSliderPress() + { + const float duration = 300f; + + if (Precision.AlmostEquals(0, Alpha)) + this.ScaleTo(1); + + this.ScaleTo(DrawableSliderBall.FOLLOW_AREA, duration, Easing.OutQuint) + .FadeIn(duration, Easing.OutQuint); + } + + protected override void OnSliderRelease() + { + const float duration = 150; + + this.ScaleTo(DrawableSliderBall.FOLLOW_AREA * 1.2f, duration, Easing.OutQuint) + .FadeTo(0, duration, Easing.OutQuint); + } + + protected override void OnSliderEnd() + { + const float duration = 300; + + this.ScaleTo(1, duration, Easing.OutQuint) + .FadeOut(duration / 2, Easing.OutQuint); + } + + protected override void OnSliderTick() + { + this.ScaleTo(DrawableSliderBall.FOLLOW_AREA * 1.08f, 40, Easing.OutQuint) + .Then() + .ScaleTo(DrawableSliderBall.FOLLOW_AREA, 200f, Easing.OutQuint); + } + + protected override void OnSliderBreak() + { + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonFollowPoint.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonFollowPoint.cs new file mode 100644 index 0000000000..2f44264040 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonFollowPoint.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public partial class ArgonFollowPoint : CompositeDrawable + { + public ArgonFollowPoint() + { + Blending = BlendingParameters.Additive; + + Colour = ColourInfo.GradientVertical(Colour4.FromHex("FC618F"), Colour4.FromHex("BB1A41")); + AutoSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new SpriteIcon + { + Icon = FontAwesome.Solid.ChevronRight, + Size = new Vector2(8), + Colour = OsuColour.Gray(0.2f), + }, + new SpriteIcon + { + Icon = FontAwesome.Solid.ChevronRight, + Size = new Vector2(8), + X = 4, + }, + }; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonJudgementPiece.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonJudgementPiece.cs new file mode 100644 index 0000000000..6f55d93eff --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonJudgementPiece.cs @@ -0,0 +1,163 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Osu.Skinning.Default; +using osu.Game.Rulesets.Scoring; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public partial class ArgonJudgementPiece : JudgementPiece, IAnimatableJudgement + { + private RingExplosion? ringExplosion; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public ArgonJudgementPiece(HitResult result) + : base(result) + { + AutoSizeAxes = Axes.Both; + + Origin = Anchor.Centre; + } + + [BackgroundDependencyLoader] + private void load() + { + if (Result.IsHit()) + { + AddInternal(ringExplosion = new RingExplosion(Result) + { + Colour = colours.ForHitResult(Result), + }); + } + } + + protected override SpriteText CreateJudgementText() => + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Blending = BlendingParameters.Additive, + Spacing = new Vector2(5, 0), + Font = OsuFont.Default.With(size: 20, weight: FontWeight.Bold), + }; + + /// + /// Plays the default animation for this judgement piece. + /// + /// + /// The base implementation only handles fade (for all result types) and misses. + /// Individual rulesets are recommended to implement their appropriate hit animations. + /// + public virtual void PlayAnimation() + { + switch (Result) + { + default: + JudgementText + .FadeInFromZero(300, Easing.OutQuint) + .ScaleTo(Vector2.One) + .ScaleTo(new Vector2(1.2f), 1800, Easing.OutQuint); + break; + + case HitResult.Miss: + this.ScaleTo(1.6f); + this.ScaleTo(1, 100, Easing.In); + + this.MoveTo(Vector2.Zero); + this.MoveToOffset(new Vector2(0, 100), 800, Easing.InQuint); + + this.RotateTo(0); + this.RotateTo(40, 800, Easing.InQuint); + break; + } + + this.FadeOutFromOne(800); + + ringExplosion?.PlayAnimation(); + } + + public Drawable? GetAboveHitObjectsProxiedContent() => JudgementText.CreateProxy(); + + private partial class RingExplosion : CompositeDrawable + { + private readonly float travel = 52; + + public RingExplosion(HitResult result) + { + const float thickness = 4; + + const float small_size = 9; + const float large_size = 14; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Blending = BlendingParameters.Additive; + + int countSmall = 0; + int countLarge = 0; + + switch (result) + { + case HitResult.Meh: + countSmall = 3; + travel *= 0.3f; + break; + + case HitResult.Ok: + case HitResult.Good: + countSmall = 4; + travel *= 0.6f; + break; + + case HitResult.Great: + case HitResult.Perfect: + countSmall = 4; + countLarge = 4; + break; + } + + for (int i = 0; i < countSmall; i++) + AddInternal(new RingPiece(thickness) { Size = new Vector2(small_size) }); + + for (int i = 0; i < countLarge; i++) + AddInternal(new RingPiece(thickness) { Size = new Vector2(large_size) }); + } + + public void PlayAnimation() + { + foreach (var c in InternalChildren) + { + const float start_position_ratio = 0.3f; + + float direction = RNG.NextSingle(0, 360); + float distance = RNG.NextSingle(travel / 2, travel); + + c.MoveTo(new Vector2( + MathF.Cos(direction) * distance * start_position_ratio, + MathF.Sin(direction) * distance * start_position_ratio + )); + + c.MoveTo(new Vector2( + MathF.Cos(direction) * distance, + MathF.Sin(direction) * distance + ), 600, Easing.OutQuint); + } + + this.FadeOutFromOne(1000, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonMainCirclePiece.cs new file mode 100644 index 0000000000..1c5cf49625 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonMainCirclePiece.cs @@ -0,0 +1,271 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.Skinning.Default; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public partial class ArgonMainCirclePiece : CompositeDrawable + { + public const float BORDER_THICKNESS = (OsuHitObject.OBJECT_RADIUS * 2) * (2f / 58); + + public const float GRADIENT_THICKNESS = BORDER_THICKNESS * 2.5f; + + public const float OUTER_GRADIENT_SIZE = (OsuHitObject.OBJECT_RADIUS * 2) - BORDER_THICKNESS * 4; + + public const float INNER_GRADIENT_SIZE = OUTER_GRADIENT_SIZE - GRADIENT_THICKNESS * 2; + public const float INNER_FILL_SIZE = INNER_GRADIENT_SIZE - GRADIENT_THICKNESS * 2; + + private readonly Circle outerFill; + private readonly Circle outerGradient; + private readonly Circle innerGradient; + private readonly Circle innerFill; + + private readonly RingPiece border; + private readonly OsuSpriteText number; + + private readonly IBindable accentColour = new Bindable(); + private readonly IBindable indexInCurrentCombo = new Bindable(); + private readonly FlashPiece flash; + private readonly Container kiaiContainer; + + private Bindable configHitLighting = null!; + + [Resolved] + private DrawableHitObject drawableObject { get; set; } = null!; + + public ArgonMainCirclePiece(bool withOuterFill) + { + Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + InternalChildren = new Drawable[] + { + outerFill = new Circle // renders white outer border and dark fill + { + Size = Size, + Alpha = withOuterFill ? 1 : 0, + }, + outerGradient = new Circle // renders the outer bright gradient + { + Size = new Vector2(OUTER_GRADIENT_SIZE), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + innerGradient = new Circle // renders the inner bright gradient + { + Size = new Vector2(INNER_GRADIENT_SIZE), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + innerFill = new Circle // renders the inner dark fill + { + Size = new Vector2(INNER_FILL_SIZE), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + kiaiContainer = new CircularContainer + { + Masking = true, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = Size, + Child = new KiaiFlash + { + RelativeSizeAxes = Axes.Both, + } + }, + number = new OsuSpriteText + { + Font = OsuFont.Default.With(size: 52, weight: FontWeight.Bold), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Y = -2, + Text = @"1", + }, + flash = new FlashPiece(), + border = new RingPiece(BORDER_THICKNESS), + }; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + var drawableOsuObject = (DrawableOsuHitObject)drawableObject; + + accentColour.BindTo(drawableObject.AccentColour); + indexInCurrentCombo.BindTo(drawableOsuObject.IndexInCurrentComboBindable); + + configHitLighting = config.GetBindable(OsuSetting.HitLighting); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + indexInCurrentCombo.BindValueChanged(index => number.Text = (index.NewValue + 1).ToString(), true); + + accentColour.BindValueChanged(colour => + { + // A colour transform is applied. + // Without removing transforms first, when it is rewound it may apply an old colour. + outerGradient.ClearTransforms(targetMember: nameof(Colour)); + outerGradient.Colour = ColourInfo.GradientVertical(colour.NewValue, colour.NewValue.Darken(0.1f)); + + kiaiContainer.Colour = colour.NewValue; + outerFill.Colour = innerFill.Colour = colour.NewValue.Darken(4); + innerGradient.Colour = ColourInfo.GradientVertical(colour.NewValue.Darken(0.5f), colour.NewValue.Darken(0.6f)); + flash.Colour = colour.NewValue; + + // Accent colour may be changed many times during a paused gameplay state. + // Schedule the change to avoid transforms piling up. + Scheduler.AddOnce(() => + { + ApplyTransformsAt(double.MinValue, true); + ClearTransformsAfter(double.MinValue, true); + + updateStateTransforms(drawableObject, drawableObject.State.Value); + }); + }, true); + + drawableObject.ApplyCustomUpdateState += updateStateTransforms; + } + + private void updateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state) + { + using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime)) + { + switch (state) + { + case ArmedState.Hit: + // Fade out time is at a maximum of 800. Must match `DrawableHitCircle`'s arbitrary lifetime spec. + const double fade_out_time = 800; + const double flash_in_duration = 150; + const double resize_duration = 400; + + const float shrink_size = 0.8f; + + // Animating with the number present is distracting. + // The number disappearing is hidden by the bright flash. + number.FadeOut(flash_in_duration / 2); + + // The fill layers add too much noise during the explosion animation. + // They will be hidden by the additive effects anyway. + outerFill.FadeOut(flash_in_duration, Easing.OutQuint); + innerFill.FadeOut(flash_in_duration, Easing.OutQuint); + + // The inner-most gradient should actually be resizing, but is only visible for + // a few milliseconds before it's hidden by the flash, so it's pointless overhead to bother with it. + innerGradient.FadeOut(flash_in_duration, Easing.OutQuint); + + // The border is always white, but after hit it gets coloured by the skin/beatmap's colouring. + // A gradient is applied to make the border less prominent over the course of the animation. + // Without this, the border dominates the visual presence of the explosion animation in a bad way. + border.TransformTo(nameof + (BorderColour), ColourInfo.GradientVertical( + accentColour.Value.Opacity(0.5f), + accentColour.Value.Opacity(0)), fade_out_time); + + // The outer ring shrinks immediately, but accounts for its thickness so it doesn't overlap the inner + // gradient layers. + border.ResizeTo(Size * shrink_size + new Vector2(border.BorderThickness), resize_duration, Easing.OutElasticHalf); + + // Kiai flash should track the overall size but also be cleaned up quite fast, so we don't get additional + // flashes after the hit animation is already in a mostly-completed state. + kiaiContainer.ResizeTo(Size * shrink_size, resize_duration, Easing.OutElasticHalf); + kiaiContainer.FadeOut(flash_in_duration, Easing.OutQuint); + + // The outer gradient is resize with a slight delay from the border. + // This is to give it a bomb-like effect, with the border "triggering" its animation when getting close. + using (BeginDelayedSequence(flash_in_duration / 12)) + { + outerGradient.ResizeTo(OUTER_GRADIENT_SIZE * shrink_size, resize_duration, Easing.OutElasticHalf); + + outerGradient + .FadeColour(Color4.White, 80) + .Then() + .FadeOut(flash_in_duration); + } + + if (configHitLighting.Value) + { + flash.HitLighting = true; + flash.FadeTo(1, flash_in_duration, Easing.OutQuint); + + this.FadeOut(fade_out_time, Easing.OutQuad); + } + else + { + flash.HitLighting = false; + flash.FadeTo(1, flash_in_duration, Easing.OutQuint) + .Then() + .FadeOut(flash_in_duration, Easing.OutQuint); + + this.FadeOut(fade_out_time * 0.8f, Easing.OutQuad); + } + + break; + } + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableObject.IsNotNull()) + drawableObject.ApplyCustomUpdateState -= updateStateTransforms; + } + + private partial class FlashPiece : Circle + { + public FlashPiece() + { + Size = new Vector2(OsuHitObject.OBJECT_RADIUS); + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Alpha = 0; + Blending = BlendingParameters.Additive; + + // The edge effect provides the fill due to not being rendered hollow. + Child.Alpha = 0; + Child.AlwaysPresent = true; + } + + public bool HitLighting { get; set; } + + protected override void Update() + { + base.Update(); + + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Colour, + Radius = OsuHitObject.OBJECT_RADIUS * (HitLighting ? 1.2f : 0.6f), + }; + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs new file mode 100644 index 0000000000..f93e26b2ca --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs @@ -0,0 +1,54 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public partial class ArgonReverseArrow : CompositeDrawable + { + private Bindable accentColour = null!; + + private SpriteIcon icon = null!; + + [BackgroundDependencyLoader] + private void load(DrawableHitObject hitObject) + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + + InternalChildren = new Drawable[] + { + new Circle + { + Size = new Vector2(40, 20), + Colour = Color4.White, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + icon = new SpriteIcon + { + Icon = FontAwesome.Solid.AngleDoubleRight, + Size = new Vector2(16), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + }; + + accentColour = hitObject.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(accent => icon.Colour = accent.NewValue.Darken(4), true); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBall.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBall.cs new file mode 100644 index 0000000000..d6ce793c7e --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBall.cs @@ -0,0 +1,124 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public partial class ArgonSliderBall : CircularContainer + { + private readonly Box fill; + private readonly SpriteIcon icon; + + private readonly Vector2 defaultIconScale = new Vector2(0.6f, 0.8f); + + private readonly IBindable accentColour = new Bindable(); + + [Resolved(canBeNull: true)] + private DrawableHitObject? parentObject { get; set; } + + public ArgonSliderBall() + { + Size = new Vector2(ArgonMainCirclePiece.OUTER_GRADIENT_SIZE); + + Masking = true; + + BorderThickness = ArgonMainCirclePiece.GRADIENT_THICKNESS; + BorderColour = Color4.White; + + InternalChildren = new Drawable[] + { + fill = new Box + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + icon = new SpriteIcon + { + Size = new Vector2(48), + Scale = defaultIconScale, + Icon = FontAwesome.Solid.AngleRight, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }; + } + + [BackgroundDependencyLoader] + private void load() + { + if (parentObject != null) + accentColour.BindTo(parentObject.AccentColour); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + accentColour.BindValueChanged(colour => + { + fill.Colour = ColourInfo.GradientVertical(colour.NewValue, colour.NewValue.Darken(0.5f)); + }, true); + + if (parentObject != null) + { + parentObject.ApplyCustomUpdateState += updateStateTransforms; + updateStateTransforms(parentObject, parentObject.State.Value); + } + } + + private void updateStateTransforms(DrawableHitObject drawableObject, ArmedState _) + { + // Gets called by slider ticks, tails, etc., leading to duplicated + // animations which in this case have no visual impact (due to + // instant fade) but may negatively affect performance + if (drawableObject is not DrawableSlider) + return; + + const float duration = 200; + const float icon_scale = 0.9f; + + using (BeginAbsoluteSequence(drawableObject.StateUpdateTime)) + { + this.FadeInFromZero(duration, Easing.OutQuint); + icon.ScaleTo(0).Then().ScaleTo(defaultIconScale, duration, Easing.OutElasticHalf); + } + + using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime)) + { + this.FadeOut(duration, Easing.OutQuint); + icon.ScaleTo(defaultIconScale * icon_scale, duration, Easing.OutQuint); + } + } + + protected override void Update() + { + base.Update(); + + //undo rotation on layers which should not be rotated. + float appliedRotation = Parent.Rotation; + + fill.Rotation = -appliedRotation; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (parentObject != null) + parentObject.ApplyCustomUpdateState -= updateStateTransforms; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBody.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBody.cs new file mode 100644 index 0000000000..c3d08116ac --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderBody.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.Extensions.Color4Extensions; +using osu.Game.Rulesets.Osu.Skinning.Default; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public partial class ArgonSliderBody : PlaySliderBody + { + protected override void LoadComplete() + { + const float path_radius = ArgonMainCirclePiece.OUTER_GRADIENT_SIZE / 2; + + base.LoadComplete(); + + AccentColourBindable.BindValueChanged(accent => BorderColour = accent.NewValue, true); + ScaleBindable.BindValueChanged(scale => PathRadius = path_radius * scale.NewValue, true); + + // This border size thing is kind of weird, hey. + const float intended_thickness = ArgonMainCirclePiece.GRADIENT_THICKNESS / path_radius; + + BorderSize = intended_thickness / Default.DrawableSliderPath.BORDER_PORTION; + } + + protected override Default.DrawableSliderPath CreateSliderPath() => new DrawableSliderPath(); + + private partial class DrawableSliderPath : Default.DrawableSliderPath + { + protected override Color4 ColourAt(float position) + { + if (CalculatedBorderPortion != 0f && position <= CalculatedBorderPortion) + return BorderColour; + + return AccentColour.Darken(4); + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderScorePoint.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderScorePoint.cs new file mode 100644 index 0000000000..7479c2aced --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSliderScorePoint.cs @@ -0,0 +1,40 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public partial class ArgonSliderScorePoint : CircularContainer + { + private Bindable accentColour = null!; + + private const float size = 12; + + [BackgroundDependencyLoader] + private void load(DrawableHitObject hitObject) + { + Masking = true; + Origin = Anchor.Centre; + Size = new Vector2(size); + BorderThickness = 3; + BorderColour = Color4.White; + Child = new Box + { + RelativeSizeAxes = Axes.Both, + AlwaysPresent = true, + Alpha = 0, + }; + + accentColour = hitObject.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(accent => BorderColour = accent.NewValue, true); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinner.cs new file mode 100644 index 0000000000..d5a9cf46c5 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinner.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; +using System.Globalization; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public partial class ArgonSpinner : CompositeDrawable + { + private DrawableSpinner drawableSpinner = null!; + + private OsuSpriteText bonusCounter = null!; + + private Container spmContainer = null!; + private OsuSpriteText spmCounter = null!; + + [BackgroundDependencyLoader] + private void load(DrawableHitObject drawableHitObject) + { + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + drawableSpinner = (DrawableSpinner)drawableHitObject; + + InternalChildren = new Drawable[] + { + bonusCounter = new OsuSpriteText + { + Alpha = 0, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Default.With(size: 24), + Y = -120, + }, + new ArgonSpinnerDisc + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + bonusCounter = new OsuSpriteText + { + Alpha = 0, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Default.With(size: 28, weight: FontWeight.Bold), + Y = -100, + }, + spmContainer = new Container + { + Alpha = 0f, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Y = 60, + Children = new[] + { + spmCounter = new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = @"0", + Font = OsuFont.Default.With(size: 28, weight: FontWeight.SemiBold) + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = @"SPINS PER MINUTE", + Font = OsuFont.Default.With(size: 16, weight: FontWeight.SemiBold), + Y = 30 + } + } + } + }; + } + + private IBindable gainedBonus = null!; + private IBindable spinsPerMinute = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + gainedBonus = drawableSpinner.GainedBonus.GetBoundCopy(); + gainedBonus.BindValueChanged(bonus => + { + bonusCounter.Text = bonus.NewValue.ToString(NumberFormatInfo.InvariantInfo); + bonusCounter.FadeOutFromOne(1500); + bonusCounter.ScaleTo(1.5f).Then().ScaleTo(1f, 1000, Easing.OutQuint); + }); + + spinsPerMinute = drawableSpinner.SpinsPerMinute.GetBoundCopy(); + spinsPerMinute.BindValueChanged(spm => + { + spmCounter.Text = Math.Truncate(spm.NewValue).ToString(@"#0"); + }, true); + + drawableSpinner.ApplyCustomUpdateState += updateStateTransforms; + updateStateTransforms(drawableSpinner, drawableSpinner.State.Value); + } + + protected override void Update() + { + base.Update(); + + if (!spmContainer.IsPresent && drawableSpinner.Result?.TimeStarted != null) + fadeCounterOnTimeStart(); + } + + private void updateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state) + { + if (!(drawableHitObject is DrawableSpinner)) + return; + + fadeCounterOnTimeStart(); + } + + private void fadeCounterOnTimeStart() + { + if (drawableSpinner.Result?.TimeStarted is double startTime) + { + using (BeginAbsoluteSequence(startTime)) + spmContainer.FadeIn(drawableSpinner.HitObject.TimeFadeIn); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableSpinner.IsNotNull()) + drawableSpinner.ApplyCustomUpdateState -= updateStateTransforms; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerDisc.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerDisc.cs new file mode 100644 index 0000000000..bdc93eb63f --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerDisc.cs @@ -0,0 +1,278 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.Skinning.Default; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public partial class ArgonSpinnerDisc : CompositeDrawable + { + private const float initial_scale = 1f; + private const float idle_alpha = 0.2f; + private const float tracking_alpha = 0.4f; + + private const float idle_centre_size = 80f; + private const float tracking_centre_size = 40f; + + private DrawableSpinner drawableSpinner = null!; + + private readonly BindableBool complete = new BindableBool(); + + private int wholeRotationCount; + + private bool checkNewRotationCount + { + get + { + int rotations = (int)(drawableSpinner.Result.RateAdjustedRotation / 360); + + if (wholeRotationCount == rotations) return false; + + wholeRotationCount = rotations; + return true; + } + } + + private Container disc = null!; + private Container centre = null!; + private CircularContainer fill = null!; + + private Container ticksContainer = null!; + private ArgonSpinnerTicks ticks = null!; + + [BackgroundDependencyLoader] + private void load(DrawableHitObject drawableHitObject) + { + drawableSpinner = (DrawableSpinner)drawableHitObject; + + // we are slightly bigger than our parent, to clip the top and bottom of the circle + // this should probably be revisited when scaled spinners are a thing. + Scale = new Vector2(initial_scale); + + InternalChildren = new Drawable[] + { + disc = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(8f), + Children = new[] + { + fill = new CircularContainer + { + Name = @"Fill", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Colour = Colour4.FromHex("FC618F").Opacity(1f), + Radius = 40, + }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0f, + AlwaysPresent = true, + } + }, + ticksContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Child = ticks = new ArgonSpinnerTicks(), + } + }, + }, + new Container + { + Name = @"Ring", + Masking = true, + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(8f), + Children = new[] + { + new ArgonSpinnerRingArc + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Name = "Top Arc", + }, + new ArgonSpinnerRingArc + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Name = "Bottom Arc", + Scale = new Vector2(1, -1), + }, + } + }, + new Container + { + Name = @"Sides", + RelativeSizeAxes = Axes.Both, + Children = new[] + { + new ArgonSpinnerProgressArc + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Name = "Left Bar" + }, + new ArgonSpinnerProgressArc + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Name = "Right Bar", + Scale = new Vector2(-1, 1), + }, + } + }, + } + }, + centre = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(idle_centre_size), + Children = new[] + { + new RingPiece(10) + { + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.8f), + }, + new RingPiece(3) + { + RelativeSizeAxes = Axes.Both, + Size = new Vector2(1f), + } + }, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + drawableSpinner.ApplyCustomUpdateState += updateStateTransforms; + + updateStateTransforms(drawableSpinner, drawableSpinner.State.Value); + } + + private float trackingElementInterpolation; + + protected override void Update() + { + base.Update(); + + complete.Value = Time.Current >= drawableSpinner.Result.TimeCompleted; + + if (complete.Value) + { + if (checkNewRotationCount) + { + fill.FinishTransforms(false, nameof(Alpha)); + fill + .FadeTo(tracking_alpha + 0.2f, 60, Easing.OutExpo) + .Then() + .FadeTo(tracking_alpha, 250, Easing.OutQuint); + } + } + else + { + trackingElementInterpolation = + (float)Interpolation.Damp(trackingElementInterpolation, drawableSpinner.RotationTracker.Tracking ? 1 : 0, 0.985f, (float)Math.Abs(Clock.ElapsedFrameTime)); + + fill.Alpha = trackingElementInterpolation * (tracking_alpha - idle_alpha) + idle_alpha; + centre.Size = new Vector2(trackingElementInterpolation * (tracking_centre_size - idle_centre_size) + idle_centre_size); + } + + const float initial_fill_scale = 0.1f; + float targetScale = initial_fill_scale + (0.98f - initial_fill_scale) * drawableSpinner.Progress; + + fill.Scale = new Vector2((float)Interpolation.Lerp(fill.Scale.X, targetScale, Math.Clamp(Math.Abs(Time.Elapsed) / 100, 0, 1))); + ticks.Rotation = drawableSpinner.RotationTracker.Rotation; + } + + private void updateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state) + { + if (!(drawableHitObject is DrawableSpinner)) + return; + + Spinner spinner = drawableSpinner.HitObject; + + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt)) + { + this.ScaleTo(initial_scale); + ticksContainer.RotateTo(0); + centre.ScaleTo(0); + disc.ScaleTo(0); + + using (BeginDelayedSequence(spinner.TimePreempt / 2)) + { + // constant ambient rotation to give the spinner "spinning" character. + ticksContainer.RotateTo((float)(25 * spinner.Duration / 2000), spinner.TimePreempt + spinner.Duration); + } + + using (BeginDelayedSequence(spinner.TimePreempt / 2)) + { + centre.ScaleTo(0.3f, spinner.TimePreempt / 4, Easing.OutQuint); + disc.ScaleTo(0.2f, spinner.TimePreempt / 4, Easing.OutQuint); + + using (BeginDelayedSequence(spinner.TimePreempt / 2)) + { + centre.ScaleTo(0.8f, spinner.TimePreempt / 2, Easing.OutQuint); + disc.ScaleTo(1, spinner.TimePreempt / 2, Easing.OutQuint); + } + } + + using (BeginDelayedSequence(spinner.TimePreempt + spinner.Duration + drawableHitObject.Result.TimeOffset)) + { + switch (state) + { + case ArmedState.Hit: + disc.ScaleTo(initial_scale * 1.2f, 320, Easing.Out); + ticksContainer.RotateTo(ticksContainer.Rotation + 180, 320); + break; + + case ArmedState.Miss: + disc.ScaleTo(initial_scale * 0.8f, 320, Easing.In); + break; + } + } + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableSpinner.IsNotNull()) + drawableSpinner.ApplyCustomUpdateState -= updateStateTransforms; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerProgressArc.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerProgressArc.cs new file mode 100644 index 0000000000..31cdc0dc0f --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerProgressArc.cs @@ -0,0 +1,71 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Utils; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public partial class ArgonSpinnerProgressArc : CompositeDrawable + { + private const float arc_fill = 0.15f; + private const float arc_radius = 0.12f; + + private CircularProgress fill = null!; + + private DrawableSpinner spinner = null!; + + private CircularProgress background = null!; + + [BackgroundDependencyLoader] + private void load(DrawableHitObject drawableHitObject) + { + RelativeSizeAxes = Axes.Both; + + spinner = (DrawableSpinner)drawableHitObject; + + InternalChildren = new Drawable[] + { + background = new CircularProgress + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = Color4.White.Opacity(0.25f), + RelativeSizeAxes = Axes.Both, + Current = { Value = arc_fill }, + Rotation = 90 - arc_fill * 180, + InnerRadius = arc_radius, + RoundedCaps = true, + }, + fill = new CircularProgress + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + InnerRadius = arc_radius, + RoundedCaps = true, + } + }; + } + + protected override void Update() + { + base.Update(); + + background.Alpha = spinner.Progress >= 1 ? 0 : 1; + + fill.Alpha = (float)Interpolation.DampContinuously(fill.Alpha, spinner.Progress > 0 && spinner.Progress < 1 ? 1 : 0, 40f, (float)Math.Abs(Time.Elapsed)); + fill.Current.Value = (float)Interpolation.DampContinuously(fill.Current.Value, spinner.Progress >= 1 ? 0 : arc_fill * spinner.Progress, 40f, (float)Math.Abs(Time.Elapsed)); + + fill.Rotation = (float)(90 - fill.Current.Value * 180); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerRingArc.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerRingArc.cs new file mode 100644 index 0000000000..702c5c2675 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerRingArc.cs @@ -0,0 +1,53 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Utils; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public partial class ArgonSpinnerRingArc : CompositeDrawable + { + private const float arc_fill = 0.31f; + private const float arc_fill_complete = 0.50f; + + private const float arc_radius = 0.02f; + + private DrawableSpinner spinner = null!; + private CircularProgress fill = null!; + + [BackgroundDependencyLoader] + private void load(DrawableHitObject drawableHitObject) + { + RelativeSizeAxes = Axes.Both; + + spinner = (DrawableSpinner)drawableHitObject; + InternalChild = fill = new CircularProgress + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Current = { Value = arc_fill }, + Rotation = -arc_fill * 180, + InnerRadius = arc_radius, + RoundedCaps = true, + }; + } + + protected override void Update() + { + base.Update(); + + fill.Current.Value = (float)Interpolation.DampContinuously(fill.Current.Value, spinner.Progress >= 1 ? arc_fill_complete : arc_fill, 40f, (float)Math.Abs(Time.Elapsed)); + fill.InnerRadius = (float)Interpolation.DampContinuously(fill.InnerRadius, spinner.Progress >= 1 ? arc_radius * 2.2f : arc_radius, 40f, (float)Math.Abs(Time.Elapsed)); + + fill.Rotation = (float)(-fill.Current.Value * 180); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerTicks.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerTicks.cs new file mode 100644 index 0000000000..4f0e1256d4 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonSpinnerTicks.cs @@ -0,0 +1,61 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public partial class ArgonSpinnerTicks : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load() + { + Origin = Anchor.Centre; + Anchor = Anchor.Centre; + RelativeSizeAxes = Axes.Both; + + const float count = 25; + + for (float i = 0; i < count; i++) + { + AddInternal(new CircularContainer + { + RelativePositionAxes = Axes.Both, + Masking = true, + CornerRadius = 5, + BorderColour = Color4.White, + BorderThickness = 2f, + Size = new Vector2(30, 5), + Origin = Anchor.Centre, + Position = new Vector2( + 0.5f + MathF.Sin(i / count * 2 * MathF.PI) / 2 * 0.75f, + 0.5f + MathF.Cos(i / count * 2 * MathF.PI) / 2 * 0.75f + ), + Rotation = -i / count * 360 - 120, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Colour = Colour4.White.Opacity(0.2f), + Radius = 30, + }, + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + } + } + }); + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs new file mode 100644 index 0000000000..f98a47097d --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/OsuArgonSkinTransformer.cs @@ -0,0 +1,72 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Osu.Skinning.Argon +{ + public class OsuArgonSkinTransformer : SkinTransformer + { + public OsuArgonSkinTransformer(ISkin skin) + : base(skin) + { + } + + public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) + { + switch (lookup) + { + case GameplaySkinComponentLookup resultComponent: + // This should eventually be moved to a skin setting, when supported. + if (Skin is ArgonProSkin && resultComponent.Component >= HitResult.Great) + return Drawable.Empty(); + + return new ArgonJudgementPiece(resultComponent.Component); + + case OsuSkinComponentLookup osuComponent: + // TODO: Once everything is finalised, consider throwing UnsupportedSkinComponentException on missing entries. + switch (osuComponent.Component) + { + case OsuSkinComponents.HitCircle: + return new ArgonMainCirclePiece(true); + + case OsuSkinComponents.SliderHeadHitCircle: + return new ArgonMainCirclePiece(false); + + case OsuSkinComponents.SliderBody: + return new ArgonSliderBody(); + + case OsuSkinComponents.SliderBall: + return new ArgonSliderBall(); + + case OsuSkinComponents.SliderFollowCircle: + return new ArgonFollowCircle(); + + case OsuSkinComponents.SliderScorePoint: + return new ArgonSliderScorePoint(); + + case OsuSkinComponents.SpinnerBody: + return new ArgonSpinner(); + + case OsuSkinComponents.ReverseArrow: + return new ArgonReverseArrow(); + + case OsuSkinComponents.FollowPoint: + return new ArgonFollowPoint(); + + case OsuSkinComponents.Cursor: + return new ArgonCursor(); + + case OsuSkinComponents.CursorTrail: + return new ArgonCursorTrail(); + } + + break; + } + + return base.GetDrawableComponent(lookup); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/CirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/CirclePiece.cs index cb68d4b7a7..f4761e0ea8 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/CirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/CirclePiece.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -12,12 +13,12 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public class CirclePiece : CompositeDrawable + public partial class CirclePiece : CompositeDrawable { [Resolved] - private DrawableHitObject drawableObject { get; set; } + private DrawableHitObject drawableObject { get; set; } = null!; - private TrianglesPiece triangles; + private TrianglesPiece triangles = null!; public CirclePiece() { @@ -70,7 +71,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default { base.Dispose(isDisposing); - if (drawableObject != null) + if (drawableObject.IsNotNull()) drawableObject.HitObjectApplied -= onHitObjectApplied; } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultApproachCircle.cs b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultApproachCircle.cs index a522367fe6..b65f46c414 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultApproachCircle.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultApproachCircle.cs @@ -11,12 +11,12 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public class DefaultApproachCircle : SkinnableSprite + public partial class DefaultApproachCircle : SkinnableSprite { private readonly IBindable accentColour = new Bindable(); [Resolved] - private DrawableHitObject drawableObject { get; set; } + private DrawableHitObject drawableObject { get; set; } = null!; public DefaultApproachCircle() : base("Gameplay/osu/approachcircle") @@ -35,9 +35,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default accentColour.BindValueChanged(colour => Colour = colour.NewValue, true); } - protected override Drawable CreateDefault(ISkinComponent component) + protected override Drawable CreateDefault(ISkinComponentLookup lookup) { - var drawable = base.CreateDefault(component); + var drawable = base.CreateDefault(lookup); // Although this is a non-legacy component, osu-resources currently stores approach circle as a legacy-like texture. // See LegacyApproachCircle for documentation as to why this is required. diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultFollowCircle.cs b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultFollowCircle.cs new file mode 100644 index 0000000000..3c41d473f4 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultFollowCircle.cs @@ -0,0 +1,71 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Default +{ + public partial class DefaultFollowCircle : FollowCircle + { + public DefaultFollowCircle() + { + InternalChild = new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = 5, + BorderColour = Color4.Orange, + Blending = BlendingParameters.Additive, + Child = new Box + { + Colour = Color4.Orange, + RelativeSizeAxes = Axes.Both, + Alpha = 0.2f, + } + }; + } + + protected override void OnSliderPress() + { + const float duration = 300f; + + if (Precision.AlmostEquals(0, Alpha)) + this.ScaleTo(1); + + this.ScaleTo(DrawableSliderBall.FOLLOW_AREA, duration, Easing.OutQuint) + .FadeIn(duration, Easing.OutQuint); + } + + protected override void OnSliderRelease() + { + const float duration = 150; + + this.ScaleTo(DrawableSliderBall.FOLLOW_AREA * 1.2f, duration, Easing.OutQuint) + .FadeTo(0, duration, Easing.OutQuint); + } + + protected override void OnSliderEnd() + { + const float duration = 300; + + this.ScaleTo(1, duration, Easing.OutQuint) + .FadeOut(duration / 2, Easing.OutQuint); + } + + protected override void OnSliderTick() + { + this.ScaleTo(DrawableSliderBall.FOLLOW_AREA * 1.08f, 40, Easing.OutQuint) + .Then() + .ScaleTo(DrawableSliderBall.FOLLOW_AREA, 200f, Easing.OutQuint); + } + + protected override void OnSliderBreak() + { + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSliderBall.cs b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSliderBall.cs new file mode 100644 index 0000000000..c911943bfb --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSliderBall.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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Default +{ + public partial class DefaultSliderBall : CompositeDrawable + { + private Box box = null!; + + [Resolved(canBeNull: true)] + private DrawableHitObject? parentObject { get; set; } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + RelativeSizeAxes = Axes.Both; + + float radius = skin.GetConfig(OsuSkinConfiguration.SliderPathRadius)?.Value ?? OsuHitObject.OBJECT_RADIUS; + + InternalChild = new CircularContainer + { + Masking = true, + RelativeSizeAxes = Axes.Both, + Scale = new Vector2(radius / OsuHitObject.OBJECT_RADIUS), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Blending = BlendingParameters.Additive, + BorderThickness = 10, + BorderColour = Color4.White, + Alpha = 1, + Child = box = new Box + { + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + Colour = Color4.White, + AlwaysPresent = true, + Alpha = 0 + } + }; + + if (parentObject != null) + { + var slider = (DrawableSlider)parentObject; + slider.Tracking.BindValueChanged(trackingChanged, true); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (parentObject != null) + { + parentObject.ApplyCustomUpdateState += updateStateTransforms; + updateStateTransforms(parentObject, parentObject.State.Value); + } + } + + private void trackingChanged(ValueChangedEvent tracking) => + box.FadeTo(tracking.NewValue ? 0.3f : 0.05f, 200, Easing.OutQuint); + + private void updateStateTransforms(DrawableHitObject drawableObject, ArmedState state) + { + // Gets called by slider ticks, tails, etc., leading to duplicated + // animations which may negatively affect performance + if (drawableObject is not DrawableSlider) + return; + + const float fade_duration = 450f; + + using (BeginAbsoluteSequence(drawableObject.StateUpdateTime)) + { + this.FadeIn() + .ScaleTo(1f); + } + + using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime)) + { + // intentionally pile on an extra FadeOut to make it happen much faster + this.FadeOut(fade_duration / 4, Easing.Out); + + switch (state) + { + case ArmedState.Hit: + this.ScaleTo(1.4f, fade_duration, Easing.Out); + break; + } + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (parentObject != null) + parentObject.ApplyCustomUpdateState -= updateStateTransforms; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSmokeSegment.cs b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSmokeSegment.cs new file mode 100644 index 0000000000..47c4bcc52c --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSmokeSegment.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics.Textures; + +namespace osu.Game.Rulesets.Osu.Skinning.Default +{ + public partial class DefaultSmokeSegment : SmokeSegment + { + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + // ISkinSource doesn't currently fallback to global textures. + // We might want to change this in the future if the intention is to allow the user to skin this as per legacy skins. + Texture = textures.Get("Gameplay/osu/cursor-smoke"); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinner.cs index df33bf52be..071fbe6add 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinner.cs @@ -5,6 +5,7 @@ using System; using System.Globalization; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; @@ -14,14 +15,14 @@ using osu.Game.Rulesets.Osu.Objects.Drawables; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public class DefaultSpinner : CompositeDrawable + public partial class DefaultSpinner : CompositeDrawable { - private DrawableSpinner drawableSpinner; + private DrawableSpinner drawableSpinner = null!; - private OsuSpriteText bonusCounter; + private OsuSpriteText bonusCounter = null!; - private Container spmContainer; - private OsuSpriteText spmCounter; + private Container spmContainer = null!; + private OsuSpriteText spmCounter = null!; public DefaultSpinner() { @@ -79,8 +80,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default }); } - private IBindable gainedBonus; - private IBindable spinsPerMinute; + private IBindable gainedBonus = null!; + private IBindable spinsPerMinute = null!; protected override void LoadComplete() { @@ -133,7 +134,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default { base.Dispose(isDisposing); - if (drawableSpinner != null) + if (drawableSpinner.IsNotNull()) drawableSpinner.ApplyCustomUpdateState -= updateStateTransforms; } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinnerDisc.cs b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinnerDisc.cs index 4ea0831627..75f3247448 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinnerDisc.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinnerDisc.cs @@ -5,6 +5,7 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Utils; @@ -17,9 +18,9 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public class DefaultSpinnerDisc : CompositeDrawable + public partial class DefaultSpinnerDisc : CompositeDrawable { - private DrawableSpinner drawableSpinner; + private DrawableSpinner drawableSpinner = null!; private const float initial_scale = 1.3f; private const float idle_alpha = 0.2f; @@ -28,15 +29,15 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default private Color4 normalColour; private Color4 completeColour; - private SpinnerTicks ticks; + private SpinnerTicks ticks = null!; private int wholeRotationCount; private readonly BindableBool complete = new BindableBool(); - private SpinnerFill fill; - private Container mainContainer; - private SpinnerCentreLayer centre; - private SpinnerBackgroundLayer background; + private SpinnerFill fill = null!; + private Container mainContainer = null!; + private SpinnerCentreLayer centre = null!; + private SpinnerBackgroundLayer background = null!; public DefaultSpinnerDisc() { @@ -88,7 +89,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default { base.LoadComplete(); - complete.BindValueChanged(complete => updateComplete(complete.NewValue, 200)); + complete.BindValueChanged(complete => updateDiscColour(complete.NewValue, 200)); drawableSpinner.ApplyCustomUpdateState += updateStateTransforms; updateStateTransforms(drawableSpinner, drawableSpinner.State.Value); @@ -135,6 +136,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default this.ScaleTo(initial_scale); this.RotateTo(0); + updateDiscColour(false); + using (BeginDelayedSequence(spinner.TimePreempt / 2)) { // constant ambient rotation to give the spinner "spinning" character. @@ -175,12 +178,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default } } - // transforms we have from completing the spinner will be rolled back, so reapply immediately. - using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt)) - updateComplete(state == ArmedState.Hit, 0); + if (drawableSpinner.Result?.TimeCompleted is double completionTime) + { + using (BeginAbsoluteSequence(completionTime)) + updateDiscColour(true, 200); + } } - private void updateComplete(bool complete, double duration) + private void updateDiscColour(bool complete, double duration = 0) { var colour = complete ? completeColour : normalColour; @@ -208,7 +213,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default { base.Dispose(isDisposing); - if (drawableSpinner != null) + if (drawableSpinner.IsNotNull()) drawableSpinner.ApplyCustomUpdateState -= updateStateTransforms; } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/DrawableSliderPath.cs b/osu.Game.Rulesets.Osu/Skinning/Default/DrawableSliderPath.cs index db077f009d..6f41d33c3d 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/DrawableSliderPath.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/DrawableSliderPath.cs @@ -6,10 +6,10 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public abstract class DrawableSliderPath : SmoothPath + public abstract partial class DrawableSliderPath : SmoothPath { - protected const float BORDER_PORTION = 0.128f; - protected const float GRADIENT_PORTION = 1 - BORDER_PORTION; + public const float BORDER_PORTION = 0.128f; + public const float GRADIENT_PORTION = 1 - BORDER_PORTION; private const float border_max_size = 8f; private const float border_min_size = 0f; diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/ExplodePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/ExplodePiece.cs index 510ed225a8..91bf75617a 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/ExplodePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/ExplodePiece.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects.Drawables; @@ -10,12 +11,12 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public class ExplodePiece : Container + public partial class ExplodePiece : Container { [Resolved] - private DrawableHitObject drawableObject { get; set; } + private DrawableHitObject drawableObject { get; set; } = null!; - private TrianglesPiece triangles; + private TrianglesPiece triangles = null!; public ExplodePiece() { @@ -54,7 +55,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default { base.Dispose(isDisposing); - if (drawableObject != null) + if (drawableObject.IsNotNull()) drawableObject.HitObjectApplied -= onHitObjectApplied; } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/FlashPiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/FlashPiece.cs index 06ee64d8b3..789137117e 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/FlashPiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/FlashPiece.cs @@ -9,7 +9,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public class FlashPiece : Container + public partial class FlashPiece : Container { public FlashPiece() { diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/GlowPiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/GlowPiece.cs index f5e01b802e..65cbdc3d2f 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/GlowPiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/GlowPiece.cs @@ -9,7 +9,7 @@ using osu.Framework.Graphics.Textures; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public class GlowPiece : Container + public partial class GlowPiece : Container { public GlowPiece() { diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/IHasMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/IHasMainCirclePiece.cs deleted file mode 100644 index 8bb7629542..0000000000 --- a/osu.Game.Rulesets.Osu/Skinning/Default/IHasMainCirclePiece.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Skinning; - -namespace osu.Game.Rulesets.Osu.Skinning.Default -{ - public interface IHasMainCirclePiece - { - SkinnableDrawable CirclePiece { get; } - } -} diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/KiaiFlash.cs b/osu.Game.Rulesets.Osu/Skinning/Default/KiaiFlash.cs index 506f679836..11c2fd97e8 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/KiaiFlash.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/KiaiFlash.cs @@ -11,7 +11,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public class KiaiFlash : BeatSyncedContainer + public partial class KiaiFlash : BeatSyncedContainer { private const double fade_length = 80; diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs index d7ebe9333d..20fa4e5342 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects.Drawables; @@ -13,7 +14,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public class MainCirclePiece : CompositeDrawable + public partial class MainCirclePiece : CompositeDrawable { private readonly CirclePiece circle; private readonly RingPiece ring; @@ -44,7 +45,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default private readonly IBindable indexInCurrentCombo = new Bindable(); [Resolved] - private DrawableHitObject drawableObject { get; set; } + private DrawableHitObject drawableObject { get; set; } = null!; [BackgroundDependencyLoader] private void load() @@ -111,7 +112,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default { base.Dispose(isDisposing); - if (drawableObject != null) + if (drawableObject.IsNotNull()) drawableObject.ApplyCustomUpdateState -= updateStateTransforms; } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/ManualSliderBody.cs b/osu.Game.Rulesets.Osu/Skinning/Default/ManualSliderBody.cs index d73c94eb9b..d171f56f40 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/ManualSliderBody.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/ManualSliderBody.cs @@ -9,7 +9,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default /// /// A with the ability to set the drawn vertices manually. /// - public class ManualSliderBody : SliderBody + public partial class ManualSliderBody : SliderBody { public new void SetVertices(IReadOnlyList vertices) { diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/NumberPiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/NumberPiece.cs index 43d8d1e27f..eb49b7b852 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/NumberPiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/NumberPiece.cs @@ -12,7 +12,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public class NumberPiece : Container + public partial class NumberPiece : Container { private readonly SkinnableSpriteText number; @@ -39,7 +39,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default Colour = Color4.White.Opacity(0.5f), }, }, - number = new SkinnableSpriteText(new OsuSkinComponent(OsuSkinComponents.HitCircleText), _ => new OsuSpriteText + number = new SkinnableSpriteText(new OsuSkinComponentLookup(OsuSkinComponents.HitCircleText), _ => new OsuSpriteText { Font = OsuFont.Numeric.With(size: 40), UseFullGlyphHeight = false, diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs b/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs index 8602ebc88b..539777dd6b 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/PlaySliderBody.cs @@ -12,14 +12,16 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public abstract class PlaySliderBody : SnakingSliderBody + public abstract partial class PlaySliderBody : SnakingSliderBody { - private IBindable scaleBindable; - private IBindable pathVersion; - private IBindable accentColour; + protected IBindable ScaleBindable { get; private set; } = null!; + + protected IBindable AccentColourBindable { get; private set; } = null!; + + private IBindable pathVersion = null!; [Resolved(CanBeNull = true)] - private OsuRulesetConfigManager config { get; set; } + private OsuRulesetConfigManager? config { get; set; } private readonly Bindable configSnakingOut = new Bindable(); @@ -28,14 +30,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default { var drawableSlider = (DrawableSlider)drawableObject; - scaleBindable = drawableSlider.ScaleBindable.GetBoundCopy(); - scaleBindable.BindValueChanged(scale => PathRadius = OsuHitObject.OBJECT_RADIUS * scale.NewValue, true); + ScaleBindable = drawableSlider.ScaleBindable.GetBoundCopy(); + ScaleBindable.BindValueChanged(scale => PathRadius = OsuHitObject.OBJECT_RADIUS * scale.NewValue, true); pathVersion = drawableSlider.PathVersion.GetBoundCopy(); pathVersion.BindValueChanged(_ => Refresh()); - accentColour = drawableObject.AccentColour.GetBoundCopy(); - accentColour.BindValueChanged(accent => AccentColour = GetBodyAccentColour(skin, accent.NewValue), true); + AccentColourBindable = drawableObject.AccentColour.GetBoundCopy(); + AccentColourBindable.BindValueChanged(accent => AccentColour = GetBodyAccentColour(skin, accent.NewValue), true); config?.BindWith(OsuRulesetSetting.SnakingInSliders, SnakingIn); config?.BindWith(OsuRulesetSetting.SnakingOutSliders, configSnakingOut); diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/ReverseArrowPiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/ReverseArrowPiece.cs index 0009ffc586..3fe7872ff7 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/ReverseArrowPiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/ReverseArrowPiece.cs @@ -14,10 +14,10 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public class ReverseArrowPiece : BeatSyncedContainer + public partial class ReverseArrowPiece : BeatSyncedContainer { [Resolved] - private DrawableHitObject drawableRepeat { get; set; } + private DrawableHitObject drawableRepeat { get; set; } = null!; public ReverseArrowPiece() { @@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); - Child = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.ReverseArrow), _ => new SpriteIcon + Child = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.ReverseArrow), _ => new SpriteIcon { RelativeSizeAxes = Axes.Both, Blending = BlendingParameters.Additive, diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/RingPiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/RingPiece.cs index 7f10a7bf56..46d48f62e7 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/RingPiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/RingPiece.cs @@ -10,9 +10,9 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public class RingPiece : CircularContainer + public partial class RingPiece : CircularContainer { - public RingPiece() + public RingPiece(float thickness = 9) { Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default Origin = Anchor.Centre; Masking = true; - BorderThickness = 9; // roughly matches slider borders and makes stacked circles distinctly visible from each other. + BorderThickness = thickness; BorderColour = Color4.White; Child = new Box diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerBackgroundLayer.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerBackgroundLayer.cs index a1184a15cd..039c4825fa 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerBackgroundLayer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerBackgroundLayer.cs @@ -6,7 +6,7 @@ using osu.Framework.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public class SpinnerBackgroundLayer : SpinnerFill + public partial class SpinnerBackgroundLayer : SpinnerFill { [BackgroundDependencyLoader] private void load() diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerCentreLayer.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerCentreLayer.cs index 67b5ed5410..f5c90bafbf 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerCentreLayer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerCentreLayer.cs @@ -15,13 +15,13 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public class SpinnerCentreLayer : CompositeDrawable, IHasAccentColour + public partial class SpinnerCentreLayer : CompositeDrawable, IHasAccentColour { - private DrawableSpinner spinner; + private DrawableSpinner spinner = null!; - private CirclePiece circle; - private GlowPiece glow; - private SpriteIcon symbol; + private CirclePiece circle = null!; + private GlowPiece glow = null!; + private SpriteIcon symbol = null!; [BackgroundDependencyLoader] private void load(DrawableHitObject drawableHitObject) diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerFill.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerFill.cs index f574ae589e..93f93b8521 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerFill.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerFill.cs @@ -10,7 +10,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public class SpinnerFill : CircularContainer, IHasAccentColour + public partial class SpinnerFill : CircularContainer, IHasAccentColour { public readonly Box Disc; diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs index 4313e99b13..bf06f513b7 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs @@ -4,6 +4,7 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; @@ -15,12 +16,22 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public class SpinnerRotationTracker : CircularContainer + public partial class SpinnerRotationTracker : CircularContainer { public override bool IsPresent => true; // handle input when hidden private readonly DrawableSpinner drawableSpinner; + private Vector2 mousePosition; + + private float lastAngle; + private float currentRotation; + + private bool rotationTransferred; + + [Resolved(canBeNull: true)] + private IGameplayClock? gameplayClock { get; set; } + public SpinnerRotationTracker(DrawableSpinner drawableSpinner) { this.drawableSpinner = drawableSpinner; @@ -49,16 +60,6 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default return base.OnMouseMove(e); } - private Vector2 mousePosition; - - private float lastAngle; - private float currentRotation; - - private bool rotationTransferred; - - [Resolved(canBeNull: true)] - private GameplayClock gameplayClock { get; set; } - protected override void Update() { base.Update(); @@ -108,7 +109,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default currentRotation += angle; // rate has to be applied each frame, because it's not guaranteed to be constant throughout playback // (see: ModTimeRamp) - drawableSpinner.Result.RateAdjustedRotation += (float)(Math.Abs(angle) * (gameplayClock?.TrueGameplayRate ?? Clock.Rate)); + drawableSpinner.Result.RateAdjustedRotation += (float)(Math.Abs(angle) * (gameplayClock?.GetTrueGameplayRate() ?? Clock.Rate)); } private void resetState(DrawableHitObject obj) @@ -124,7 +125,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default { base.Dispose(isDisposing); - if (drawableSpinner != null) + if (drawableSpinner.IsNotNull()) drawableSpinner.HitObjectApplied -= resetState; } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerSpmCalculator.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerSpmCalculator.cs index a5205bbb8c..44962c8548 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerSpmCalculator.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerSpmCalculator.cs @@ -5,13 +5,14 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Utils; using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public class SpinnerSpmCalculator : Component + public partial class SpinnerSpmCalculator : Component { private readonly Queue records = new Queue(); private const double spm_count_duration = 595; // not using hundreds to avoid frame rounding issues @@ -24,7 +25,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default private readonly Bindable result = new BindableDouble(); [Resolved] - private DrawableHitObject drawableSpinner { get; set; } + private DrawableHitObject drawableSpinner { get; set; } = null!; protected override void LoadComplete() { @@ -34,14 +35,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default public void SetRotation(float currentRotation) { - // Never calculate SPM by same time of record to avoid 0 / 0 = NaN or X / 0 = Infinity result. - if (Precision.AlmostEquals(0, Time.Elapsed)) - return; - // If we've gone back in time, it's fine to work with a fresh set of records for now if (records.Count > 0 && Time.Current < records.Last().Time) records.Clear(); + // Never calculate SPM by same time of record to avoid 0 / 0 = NaN or X / 0 = Infinity result. + if (records.Count > 0 && Precision.AlmostEquals(Time.Current, records.Last().Time)) + return; + if (records.Count > 0) { var record = records.Peek(); @@ -64,7 +65,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default { base.Dispose(isDisposing); - if (drawableSpinner != null) + if (drawableSpinner.IsNotNull()) drawableSpinner.HitObjectApplied -= resetState; } diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerTicks.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerTicks.cs index e518ae1da8..f5f8d456f3 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerTicks.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerTicks.cs @@ -14,7 +14,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public class SpinnerTicks : Container, IHasAccentColour + public partial class SpinnerTicks : Container, IHasAccentColour { public SpinnerTicks() { diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/TrianglesPiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/TrianglesPiece.cs index fa23c60d57..f1143cf14d 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/TrianglesPiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/TrianglesPiece.cs @@ -5,7 +5,7 @@ using osu.Game.Graphics.Backgrounds; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public class TrianglesPiece : Triangles + public partial class TrianglesPiece : Triangles { protected override bool CreateNewTriangles => false; protected override float SpawnRatio => 0.5f; diff --git a/osu.Game.Rulesets.Osu/Skinning/FollowCircle.cs b/osu.Game.Rulesets.Osu/Skinning/FollowCircle.cs new file mode 100644 index 0000000000..355d3f9a2f --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/FollowCircle.cs @@ -0,0 +1,124 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; + +namespace osu.Game.Rulesets.Osu.Skinning +{ + public abstract partial class FollowCircle : CompositeDrawable + { + [Resolved] + protected DrawableHitObject? ParentObject { get; private set; } + + protected FollowCircle() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + ((DrawableSlider?)ParentObject)?.Tracking.BindValueChanged(tracking => + { + Debug.Assert(ParentObject != null); + if (ParentObject.Judged) + return; + + if (tracking.NewValue) + OnSliderPress(); + else + OnSliderRelease(); + }, true); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (ParentObject != null) + { + ParentObject.HitObjectApplied += onHitObjectApplied; + onHitObjectApplied(ParentObject); + + ParentObject.ApplyCustomUpdateState += updateStateTransforms; + updateStateTransforms(ParentObject, ParentObject.State.Value); + } + } + + private void onHitObjectApplied(DrawableHitObject drawableObject) + { + this.ScaleTo(1f) + .FadeOut(); + } + + private void updateStateTransforms(DrawableHitObject drawableObject, ArmedState state) + { + Debug.Assert(ParentObject != null); + + switch (state) + { + case ArmedState.Hit: + switch (drawableObject) + { + case DrawableSliderTail: + // Use ParentObject instead of drawableObject because slider tail's + // HitStateUpdateTime is ~36ms before the actual slider end (aka slider + // tail leniency) + using (BeginAbsoluteSequence(ParentObject.HitStateUpdateTime)) + OnSliderEnd(); + break; + + case DrawableSliderTick: + case DrawableSliderRepeat: + using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime)) + OnSliderTick(); + break; + } + + break; + + case ArmedState.Miss: + switch (drawableObject) + { + case DrawableSliderTail: + case DrawableSliderTick: + case DrawableSliderRepeat: + // Despite above comment, ok to use drawableObject.HitStateUpdateTime + // here, since on stable, the break anim plays right when the tail is + // missed, not when the slider ends + using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime)) + OnSliderBreak(); + break; + } + + break; + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (ParentObject != null) + { + ParentObject.HitObjectApplied -= onHitObjectApplied; + ParentObject.ApplyCustomUpdateState -= updateStateTransforms; + } + } + + protected abstract void OnSliderPress(); + + protected abstract void OnSliderRelease(); + + protected abstract void OnSliderEnd(); + + protected abstract void OnSliderTick(); + + protected abstract void OnSliderBreak(); + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/IHasApproachCircle.cs b/osu.Game.Rulesets.Osu/Skinning/IHasApproachCircle.cs index 7fbc5b144b..5ddca03fa1 100644 --- a/osu.Game.Rulesets.Osu/Skinning/IHasApproachCircle.cs +++ b/osu.Game.Rulesets.Osu/Skinning/IHasApproachCircle.cs @@ -13,6 +13,6 @@ namespace osu.Game.Rulesets.Osu.Skinning /// /// The approach circle drawable. /// - Drawable ApproachCircle { get; } + Drawable? ApproachCircle { get; } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyApproachCircle.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyApproachCircle.cs index 6a2cb871b1..e9342bbdbb 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyApproachCircle.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyApproachCircle.cs @@ -11,12 +11,12 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Legacy { - public class LegacyApproachCircle : SkinnableSprite + public partial class LegacyApproachCircle : SkinnableSprite { private readonly IBindable accentColour = new Bindable(); [Resolved] - private DrawableHitObject drawableObject { get; set; } + private DrawableHitObject drawableObject { get; set; } = null!; public LegacyApproachCircle() : base("Gameplay/osu/approachcircle") @@ -35,9 +35,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy accentColour.BindValueChanged(colour => Colour = LegacyColourCompatibility.DisallowZeroAlpha(colour.NewValue), true); } - protected override Drawable CreateDefault(ISkinComponent component) + protected override Drawable CreateDefault(ISkinComponentLookup lookup) { - var drawable = base.CreateDefault(component); + var drawable = base.CreateDefault(lookup); // account for the sprite being used for the default approach circle being taken from stable, // when hitcircles have 5px padding on each size. this should be removed if we update the sprite. diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs index b2ffc171be..b0c01d2925 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs @@ -9,7 +9,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Skinning.Legacy { - public class LegacyCursor : OsuCursorSprite + public partial class LegacyCursor : OsuCursorSprite { private readonly ISkin skin; private bool spin; diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorParticles.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorParticles.cs index b511444c44..e022e5534d 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorParticles.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorParticles.cs @@ -23,21 +23,21 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Legacy { - public class LegacyCursorParticles : CompositeDrawable, IKeyBindingHandler + public partial class LegacyCursorParticles : CompositeDrawable, IKeyBindingHandler { - public bool Active => breakSpewer?.Active.Value == true || kiaiSpewer?.Active.Value == true; + public bool Active => breakSpewer.Active.Value || kiaiSpewer.Active.Value; - private LegacyCursorParticleSpewer breakSpewer; - private LegacyCursorParticleSpewer kiaiSpewer; + private LegacyCursorParticleSpewer breakSpewer = null!; + private LegacyCursorParticleSpewer kiaiSpewer = null!; [Resolved(canBeNull: true)] - private Player player { get; set; } + private Player? player { get; set; } [Resolved(canBeNull: true)] - private OsuPlayfield playfield { get; set; } + private OsuPlayfield? playfield { get; set; } [Resolved(canBeNull: true)] - private GameplayState gameplayState { get; set; } + private GameplayState? gameplayState { get; set; } [BackgroundDependencyLoader] private void load(ISkinSource skin) @@ -77,7 +77,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { if (playfield == null || gameplayState == null) return; - DrawableHitObject kiaiHitObject = null; + DrawableHitObject? kiaiHitObject = null; // Check whether currently in a kiai section first. This is only done as an optimisation to avoid enumerating AliveObjects when not necessary. if (gameplayState.Beatmap.ControlPointInfo.EffectPointAt(Time.Current).KiaiMode) @@ -140,7 +140,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy breakSpewer.Direction = SpewDirection.None; } - private class LegacyCursorParticleSpewer : ParticleSpewer, IRequireHighFrequencyMousePosition + private partial class LegacyCursorParticleSpewer : ParticleSpewer, IRequireHighFrequencyMousePosition { private const int particle_duration_min = 300; private const int particle_duration_max = 1000; @@ -150,7 +150,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy protected override bool CanSpawnParticles => base.CanSpawnParticles && cursorScreenPosition.HasValue; protected override float ParticleGravity => 240; - public LegacyCursorParticleSpewer(Texture texture, int perSecond) + public LegacyCursorParticleSpewer(Texture? texture, int perSecond) : base(texture, perSecond, particle_duration_max) { Active.BindValueChanged(_ => resetVelocityCalculation()); diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs index 75d847d54d..af71e2a5d9 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs @@ -13,14 +13,15 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Skinning.Legacy { - public class LegacyCursorTrail : CursorTrail + public partial class LegacyCursorTrail : CursorTrail { private readonly ISkin skin; private const double disjoint_trail_time_separation = 1000 / 60.0; private bool disjointTrail; private double lastTrailTime; - private IBindable cursorSize; + + private IBindable cursorSize = null!; private Vector2? currentPosition; @@ -32,6 +33,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy [BackgroundDependencyLoader] private void load(OsuConfigManager config) { + cursorSize = config.GetBindable(OsuSetting.GameplayCursorSize).GetBoundCopy(); + Texture = skin.GetTexture("cursortrail"); disjointTrail = skin.GetTexture("cursormiddle") == null; @@ -52,8 +55,6 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy // stable "magic ratio". see OsuPlayfieldAdjustmentContainer for full explanation. Texture.ScaleAdjust *= 1.6f; } - - cursorSize = config.GetBindable(OsuSetting.GameplayCursorSize).GetBoundCopy(); } protected override double FadeDuration => disjointTrail ? 150 : 500; diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyFollowCircle.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyFollowCircle.cs new file mode 100644 index 0000000000..f8dcb9e8a2 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyFollowCircle.cs @@ -0,0 +1,57 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using osu.Framework.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Legacy +{ + public partial class LegacyFollowCircle : FollowCircle + { + public LegacyFollowCircle(Drawable animationContent) + { + // follow circles are 2x the hitcircle resolution in legacy skins (since they are scaled down from >1x + animationContent.Scale *= 0.5f; + animationContent.Anchor = Anchor.Centre; + animationContent.Origin = Anchor.Centre; + + RelativeSizeAxes = Axes.Both; + InternalChild = animationContent; + } + + protected override void OnSliderPress() + { + Debug.Assert(ParentObject != null); + + double remainingTime = Math.Max(0, ParentObject.HitStateUpdateTime - Time.Current); + + // Note that the scale adjust here is 2 instead of DrawableSliderBall.FOLLOW_AREA to match legacy behaviour. + // This means the actual tracking area for gameplay purposes is larger than the sprite (but skins may be accounting for this). + this.ScaleTo(0.5f).ScaleTo(2f, Math.Min(180f, remainingTime), Easing.Out) + .FadeTo(0).FadeTo(1f, Math.Min(60f, remainingTime)); + } + + protected override void OnSliderRelease() + { + } + + protected override void OnSliderEnd() + { + this.ScaleTo(1.6f, 200, Easing.Out) + .FadeOut(200, Easing.In); + } + + protected override void OnSliderTick() + { + this.ScaleTo(2.2f) + .ScaleTo(2f, 200); + } + + protected override void OnSliderBreak() + { + this.ScaleTo(4f, 100) + .FadeTo(0f, 100); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs index dd6226e19b..cadac4d319 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; @@ -16,11 +17,9 @@ using osu.Game.Skinning; using osuTK; using osuTK.Graphics; -#nullable enable - namespace osu.Game.Rulesets.Osu.Skinning.Legacy { - public class LegacyMainCirclePiece : CompositeDrawable + public partial class LegacyMainCirclePiece : CompositeDrawable { public override bool RemoveCompletedTransforms => false; @@ -31,8 +30,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy private readonly bool hasNumber; - protected Drawable CircleSprite = null!; - protected Drawable OverlaySprite = null!; + protected LegacyKiaiFlashingDrawable CircleSprite = null!; + protected LegacyKiaiFlashingDrawable OverlaySprite = null!; protected Container OverlayLayer { get; private set; } = null!; @@ -67,10 +66,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy // at this point, any further texture fetches should be correctly using the priority source if the base texture was retrieved using it. // the conditional above handles the case where a sliderendcircle.png is retrieved from the skin, but sliderendcircleoverlay.png doesn't exist. // expected behaviour in this scenario is not showing the overlay, rather than using hitcircleoverlay.png. - InternalChildren = new[] { - CircleSprite = new KiaiFlashingDrawable(() => new Sprite { Texture = skin.GetTexture(circleName) }) + CircleSprite = new LegacyKiaiFlashingDrawable(() => new Sprite { Texture = skin.GetTexture(circleName) }) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -79,7 +77,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Child = OverlaySprite = new KiaiFlashingDrawable(() => skin.GetAnimation(@$"{circleName}overlay", true, true, frameLength: 1000 / 2d)) + Child = OverlaySprite = new LegacyKiaiFlashingDrawable(() => skin.GetAnimation(@$"{circleName}overlay", true, true, frameLength: 1000 / 2d)) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -89,7 +87,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy if (hasNumber) { - OverlayLayer.Add(hitCircleText = new SkinnableSpriteText(new OsuSkinComponent(OsuSkinComponents.HitCircleText), _ => new OsuSpriteText + OverlayLayer.Add(hitCircleText = new SkinnableSpriteText(new OsuSkinComponentLookup(OsuSkinComponents.HitCircleText), _ => new OsuSpriteText { Font = OsuFont.Numeric.With(size: 40), UseFullGlyphHeight = false, @@ -116,7 +114,21 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { base.LoadComplete(); - accentColour.BindValueChanged(colour => CircleSprite.Colour = LegacyColourCompatibility.DisallowZeroAlpha(colour.NewValue), true); + accentColour.BindValueChanged(colour => + { + Color4 objectColour = colour.NewValue; + int add = Math.Max(25, 300 - (int)(objectColour.R * 255) - (int)(objectColour.G * 255) - (int)(objectColour.B * 255)); + + var kiaiTintColour = new Color4( + (byte)Math.Min((byte)(objectColour.R * 255) + add, 255), + (byte)Math.Min((byte)(objectColour.G * 255) + add, 255), + (byte)Math.Min((byte)(objectColour.B * 255) + add, 255), + 255); + + CircleSprite.Colour = LegacyColourCompatibility.DisallowZeroAlpha(colour.NewValue); + OverlaySprite.KiaiGlowColour = CircleSprite.KiaiGlowColour = LegacyColourCompatibility.DisallowZeroAlpha(kiaiTintColour); + }, true); + if (hasNumber) indexInCurrentCombo.BindValueChanged(index => hitCircleText.Text = (index.NewValue + 1).ToString(), true); @@ -136,10 +148,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy switch (state) { case ArmedState.Hit: - CircleSprite.FadeOut(legacy_fade_duration, Easing.Out); + CircleSprite.FadeOut(legacy_fade_duration); CircleSprite.ScaleTo(1.4f, legacy_fade_duration, Easing.Out); - OverlaySprite.FadeOut(legacy_fade_duration, Easing.Out); + OverlaySprite.FadeOut(legacy_fade_duration); OverlaySprite.ScaleTo(1.4f, legacy_fade_duration, Easing.Out); if (hasNumber) @@ -148,11 +160,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy if (legacyVersion >= 2.0m) // legacy skins of version 2.0 and newer only apply very short fade out to the number piece. - hitCircleText.FadeOut(legacy_fade_duration / 4, Easing.Out); + hitCircleText.FadeOut(legacy_fade_duration / 4); else { // old skins scale and fade it normally along other pieces. - hitCircleText.FadeOut(legacy_fade_duration, Easing.Out); + hitCircleText.FadeOut(legacy_fade_duration); hitCircleText.ScaleTo(1.4f, legacy_fade_duration, Easing.Out); } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs index a58f62736b..67a6d5e41a 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs @@ -19,17 +19,17 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy /// Legacy skinned spinner with two main spinning layers, one fixed overlay and one final spinning overlay. /// No background layer. /// - public class LegacyNewStyleSpinner : LegacySpinner + public partial class LegacyNewStyleSpinner : LegacySpinner { - private Sprite glow; - private Sprite discBottom; - private Sprite discTop; - private Sprite spinningMiddle; - private Sprite fixedMiddle; + private Sprite glow = null!; + private Sprite discBottom = null!; + private Sprite discTop = null!; + private Sprite spinningMiddle = null!; + private Sprite fixedMiddle = null!; private readonly Color4 glowColour = new Color4(3, 151, 255, 255); - private Container scaleContainer; + private Container scaleContainer = null!; [BackgroundDependencyLoader] private void load(ISkinSource source) @@ -80,7 +80,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy var topProvider = source.FindProvider(s => s.GetTexture("spinner-top") != null); - if (topProvider is LegacySkinTransformer transformer && !(transformer.Skin is DefaultLegacySkin)) + if (topProvider is ISkinTransformer transformer && !(transformer.Skin is DefaultLegacySkin)) { AddInternal(ApproachCircle = new Sprite { @@ -105,8 +105,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt)) this.FadeOut(); - using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimeFadeIn / 2)) - this.FadeInFromZero(spinner.TimeFadeIn / 2); + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimeFadeIn)) + this.FadeInFromZero(spinner.TimeFadeIn); using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt)) { @@ -124,7 +124,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy break; - case DrawableSpinnerBonusTick _: + case DrawableSpinnerBonusTick: if (state == ArmedState.Hit) glow.FlashColour(Color4.White, 200); diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyOldStyleSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyOldStyleSpinner.cs index ea122d47bb..c57487cf75 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyOldStyleSpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyOldStyleSpinner.cs @@ -19,11 +19,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy /// /// Legacy skinned spinner with one main spinning layer and a background layer. /// - public class LegacyOldStyleSpinner : LegacySpinner + public partial class LegacyOldStyleSpinner : LegacySpinner { - private Sprite disc; - private Sprite metreSprite; - private Container metre; + private Sprite disc = null!; + private Sprite metreSprite = null!; + private Container metre = null!; private bool spinnerBlink; diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs index 7a071b5a03..fbe094ef81 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs @@ -11,19 +11,19 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Osu.Skinning.Legacy { - public class LegacyReverseArrow : CompositeDrawable + public partial class LegacyReverseArrow : CompositeDrawable { [Resolved(canBeNull: true)] - private DrawableHitObject drawableHitObject { get; set; } + private DrawableHitObject? drawableHitObject { get; set; } - private Drawable proxy; + private Drawable proxy = null!; [BackgroundDependencyLoader] private void load(ISkinSource skinSource) { AutoSizeAxes = Axes.Both; - string lookupName = new OsuSkinComponent(OsuSkinComponents.ReverseArrow).LookupName; + string lookupName = new OsuSkinComponentLookup(OsuSkinComponents.ReverseArrow).LookupName; var skin = skinSource.FindProvider(s => s.GetTexture(lookupName) != null); InternalChild = skin?.GetAnimation(lookupName, true, true) ?? Empty(); diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBall.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBall.cs index e4e1483665..2aa843581e 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBall.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBall.cs @@ -2,22 +2,30 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Skinning; using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Legacy { - public class LegacySliderBall : CompositeDrawable + public partial class LegacySliderBall : CompositeDrawable { private readonly Drawable animationContent; private readonly ISkin skin; - private Sprite layerNd; - private Sprite layerSpec; + [Resolved(canBeNull: true)] + private DrawableHitObject? parentObject { get; set; } + + public Color4 BallColour => animationContent.Colour; + + private Sprite layerNd = null!; + private Sprite layerSpec = null!; public LegacySliderBall(Drawable animationContent, ISkin skin) { @@ -56,6 +64,25 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy }; } + private readonly IBindable accentColour = new Bindable(); + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (parentObject != null) + { + parentObject.ApplyCustomUpdateState += updateStateTransforms; + updateStateTransforms(parentObject, parentObject.State.Value); + + if (skin.GetConfig(SkinConfiguration.LegacySetting.AllowSliderBallTint)?.Value == true) + { + accentColour.BindTo(parentObject.AccentColour); + accentColour.BindValueChanged(a => animationContent.Colour = a.NewValue, true); + } + } + } + protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); @@ -66,5 +93,28 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy layerNd.Rotation = -appliedRotation; layerSpec.Rotation = -appliedRotation; } + + private void updateStateTransforms(DrawableHitObject drawableObject, ArmedState _) + { + // Gets called by slider ticks, tails, etc., leading to duplicated + // animations which in this case have no visual impact (due to + // instant fade) but may negatively affect performance + if (drawableObject is not DrawableSlider) + return; + + using (BeginAbsoluteSequence(drawableObject.StateUpdateTime)) + this.FadeIn(); + + using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime)) + this.FadeOut(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (parentObject != null) + parentObject.ApplyCustomUpdateState -= updateStateTransforms; + } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBody.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBody.cs index 29a0745193..b39092a467 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBody.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBody.cs @@ -11,7 +11,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Skinning.Legacy { - public class LegacySliderBody : PlaySliderBody + public partial class LegacySliderBody : PlaySliderBody { protected override DrawableSliderPath CreateSliderPath() => new LegacyDrawableSliderPath(); @@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy return base.GetBodyAccentColour(skin, hitObjectAccentColour).Opacity(0.7f); } - private class LegacyDrawableSliderPath : DrawableSliderPath + private partial class LegacyDrawableSliderPath : DrawableSliderPath { private const float shadow_portion = 1 - (OsuLegacySkinTransformer.LEGACY_CIRCLE_RADIUS / OsuHitObject.OBJECT_RADIUS); diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderHeadHitCircle.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderHeadHitCircle.cs index 7de2b8c7fa..bc69e66fb3 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderHeadHitCircle.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderHeadHitCircle.cs @@ -9,12 +9,12 @@ using osu.Game.Rulesets.Osu.Objects.Drawables; namespace osu.Game.Rulesets.Osu.Skinning.Legacy { - public class LegacySliderHeadHitCircle : LegacyMainCirclePiece + public partial class LegacySliderHeadHitCircle : LegacyMainCirclePiece { [Resolved(canBeNull: true)] - private DrawableHitObject drawableHitObject { get; set; } + private DrawableHitObject? drawableHitObject { get; set; } - private Drawable proxiedOverlayLayer; + private Drawable proxiedOverlayLayer = null!; public LegacySliderHeadHitCircle() : base("sliderstartcircle") diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySmokeSegment.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySmokeSegment.cs new file mode 100644 index 0000000000..0e93ef7ad5 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySmokeSegment.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Osu.Skinning.Legacy +{ + public partial class LegacySmokeSegment : SmokeSegment + { + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + base.LoadComplete(); + + Texture = skin.GetTexture("cursor-smoke"); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs index 93aba608e6..d8f837ae5e 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs @@ -5,6 +5,7 @@ using System; using System.Globalization; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -15,7 +16,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Skinning.Legacy { - public abstract class LegacySpinner : CompositeDrawable, IHasApproachCircle + public abstract partial class LegacySpinner : CompositeDrawable, IHasApproachCircle { public const float SPRITE_SCALE = 0.625f; @@ -30,17 +31,17 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy private const float spm_hide_offset = 50f; - protected DrawableSpinner DrawableSpinner { get; private set; } + protected DrawableSpinner DrawableSpinner { get; private set; } = null!; - public Drawable ApproachCircle { get; protected set; } + public Drawable? ApproachCircle { get; protected set; } - private Sprite spin; - private Sprite clear; + private Sprite spin = null!; + private Sprite clear = null!; - private LegacySpriteText bonusCounter; + private LegacySpriteText bonusCounter = null!; - private Sprite spmBackground; - private LegacySpriteText spmCounter; + private Sprite spmBackground = null!; + private LegacySpriteText spmCounter = null!; [BackgroundDependencyLoader] private void load(DrawableHitObject drawableHitObject, ISkinSource source) @@ -63,6 +64,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { spin = new Sprite { + Alpha = 0, Anchor = Anchor.TopCentre, Origin = Anchor.Centre, Texture = source.GetTexture("spinner-spin"), @@ -80,7 +82,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy }, bonusCounter = new LegacySpriteText(LegacyFont.Score) { - Alpha = 0f, + Alpha = 0, Anchor = Anchor.TopCentre, Origin = Anchor.Centre, Scale = new Vector2(SPRITE_SCALE), @@ -105,8 +107,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy }); } - private IBindable gainedBonus; - private IBindable spinsPerMinute; + private IBindable gainedBonus = null!; + private IBindable spinsPerMinute = null!; private readonly Bindable completed = new Bindable(); @@ -177,6 +179,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy spmCounter.MoveToOffset(new Vector2(0, -spm_hide_offset), d.HitObject.TimeFadeIn, Easing.Out); } + using (BeginAbsoluteSequence(d.HitObject.StartTime - d.HitObject.TimeFadeIn / 2)) + spin.FadeInFromZero(d.HitObject.TimeFadeIn / 2); + using (BeginAbsoluteSequence(d.HitObject.StartTime)) ApproachCircle?.ScaleTo(SPRITE_SCALE * 0.1f, d.HitObject.Duration); @@ -201,7 +206,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { base.Dispose(isDisposing); - if (DrawableSpinner != null) + if (DrawableSpinner.IsNotNull()) DrawableSpinner.ApplyCustomUpdateState -= UpdateStateTransforms; } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs index 572185e6e1..620540b8ef 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs @@ -11,6 +11,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { public class OsuLegacySkinTransformer : LegacySkinTransformer { + public override bool IsProvidingLegacyResources => base.IsProvidingLegacyResources || hasHitCircle.Value; + private readonly Lazy hasHitCircle; /// @@ -26,24 +28,24 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy hasHitCircle = new Lazy(() => GetTexture("hitcircle") != null); } - public override Drawable GetDrawableComponent(ISkinComponent component) + public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) { - if (component is OsuSkinComponent osuComponent) + if (lookup is OsuSkinComponentLookup osuComponent) { switch (osuComponent.Component) { case OsuSkinComponents.FollowPoint: - return this.GetAnimation(component.LookupName, true, true, true, startAtCurrentTime: false); + return this.GetAnimation("followpoint", true, true, true, startAtCurrentTime: false); case OsuSkinComponents.SliderScorePoint: - return this.GetAnimation(component.LookupName, false, false); + return this.GetAnimation("sliderscorepoint", false, false); case OsuSkinComponents.SliderFollowCircle: - var followCircle = this.GetAnimation("sliderfollowcircle", true, true, true); - if (followCircle != null) - // follow circles are 2x the hitcircle resolution in legacy skins (since they are scaled down from >1x - followCircle.Scale *= 0.5f; - return followCircle; + var followCircleContent = this.GetAnimation("sliderfollowcircle", true, true, true); + if (followCircleContent != null) + return new LegacyFollowCircle(followCircleContent); + + return null; case OsuSkinComponents.SliderBall: var sliderBallContent = this.GetAnimation("sliderb", true, true, animationSeparator: ""); @@ -104,6 +106,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy return null; + case OsuSkinComponents.CursorSmoke: + if (GetTexture("cursor-smoke") != null) + return new LegacySmokeSegment(); + + return null; + case OsuSkinComponents.HitCircleText: if (!this.HasFont(LegacyFont.HitCircle)) return null; @@ -128,14 +136,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy return new LegacyApproachCircle(); default: - throw new UnsupportedSkinComponentException(component); + throw new UnsupportedSkinComponentException(lookup); } } - return base.GetDrawableComponent(component); + return base.GetDrawableComponent(lookup); } - public override IBindable GetConfig(TLookup lookup) + public override IBindable? GetConfig(TLookup lookup) { switch (lookup) { diff --git a/osu.Game.Rulesets.Osu/Skinning/NonPlayfieldSprite.cs b/osu.Game.Rulesets.Osu/Skinning/NonPlayfieldSprite.cs index 55257106e2..548a14f5eb 100644 --- a/osu.Game.Rulesets.Osu/Skinning/NonPlayfieldSprite.cs +++ b/osu.Game.Rulesets.Osu/Skinning/NonPlayfieldSprite.cs @@ -11,9 +11,9 @@ namespace osu.Game.Rulesets.Osu.Skinning /// A sprite which is displayed within the playfield, but historically was not considered part of the playfield. /// Performs scale adjustment to undo the scale applied by (osu! ruleset specifically). /// - public class NonPlayfieldSprite : Sprite + public partial class NonPlayfieldSprite : Sprite { - public override Texture Texture + public override Texture? Texture { get => base.Texture; set diff --git a/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs b/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs index 7b9cf8e1d1..77fea9d8f7 100644 --- a/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs +++ b/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs @@ -7,7 +7,6 @@ namespace osu.Game.Rulesets.Osu.Skinning { SliderBorderSize, SliderPathRadius, - AllowSliderBallTint, CursorCentre, CursorExpand, CursorRotate, diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SliderBody.cs b/osu.Game.Rulesets.Osu/Skinning/SliderBody.cs similarity index 89% rename from osu.Game.Rulesets.Osu/Skinning/Default/SliderBody.cs rename to osu.Game.Rulesets.Osu/Skinning/SliderBody.cs index 7e6df759f8..e7885e65de 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SliderBody.cs +++ b/osu.Game.Rulesets.Osu/Skinning/SliderBody.cs @@ -1,17 +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; using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Lines; +using osu.Game.Rulesets.Osu.Skinning.Default; using osuTK; using osuTK.Graphics; -namespace osu.Game.Rulesets.Osu.Skinning.Default +namespace osu.Game.Rulesets.Osu.Skinning { - public abstract class SliderBody : CompositeDrawable + public abstract partial class SliderBody : CompositeDrawable { private DrawableSliderPath path; @@ -28,6 +31,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default /// public virtual Vector2 PathOffset => path.PositionInBoundingBox(path.Vertices[0]); + /// + /// Offset in absolute coordinates from the end of the curve. + /// + public virtual Vector2 PathEndOffset => path.PositionInBoundingBox(path.Vertices[^1]); + /// /// Used to colour the path. /// @@ -104,7 +112,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default protected virtual DrawableSliderPath CreateSliderPath() => new DefaultDrawableSliderPath(); - private class DefaultDrawableSliderPath : DrawableSliderPath + private partial class DefaultDrawableSliderPath : DrawableSliderPath { private const float opacity_at_centre = 0.3f; private const float opacity_at_edge = 0.8f; diff --git a/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs b/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs new file mode 100644 index 0000000000..9d64c354e2 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs @@ -0,0 +1,383 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Rendering.Vertices; +using osu.Framework.Graphics.Shaders; +using osu.Framework.Graphics.Textures; +using osu.Framework.Utils; +using osu.Game.Utils; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning +{ + public abstract partial class SmokeSegment : Drawable, ITexturedShaderDrawable + { + // fade anim values + private const double initial_fade_out_duration = 4000; + + private const double re_fade_in_speed = 3; + private const double re_fade_in_duration = 50; + + private const double final_fade_out_speed = 2; + private const double final_fade_out_duration = 8000; + + private const float initial_alpha = 0.6f; + private const float re_fade_in_alpha = 1f; + + private readonly int rotationSeed = RNG.Next(); + + // scale anim values + private const double scale_duration = 1200; + + private const float initial_scale = 0.65f; + private const float final_scale = 1f; + + // rotation anim values + private const double rotation_duration = 500; + + private const float max_rotation = 0.25f; + + public IShader? TextureShader { get; private set; } + + protected Texture? Texture { get; set; } + + private float height => Texture?.DisplayHeight * 0.165f ?? 3; + + private float width => Texture?.DisplayWidth * 0.165f ?? 3; + + protected readonly List SmokePoints = new List(); + + private float pointInterval => width * 7f / 8; + + private double smokeStartTime { get; set; } = double.MinValue; + + private double smokeEndTime { get; set; } = double.MaxValue; + + private float totalDistance; + private Vector2? lastPosition; + + [BackgroundDependencyLoader] + private void load(ShaderManager shaders) + { + TextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + RelativeSizeAxes = Axes.Both; + + LifetimeStart = smokeStartTime = Time.Current; + + totalDistance = pointInterval; + } + + public void AddPosition(Vector2 position, double time) + { + lastPosition ??= position; + + float delta = (position - (Vector2)lastPosition).LengthFast; + totalDistance += delta; + int count = (int)(totalDistance / pointInterval); + + if (count > 0) + { + Vector2 increment = position - (Vector2)lastPosition; + increment.NormalizeFast(); + + Vector2 pointPos = (pointInterval - (totalDistance - delta)) * increment + (Vector2)lastPosition; + increment *= pointInterval; + + totalDistance %= pointInterval; + + if (SmokePoints.Count == 0 || SmokePoints[^1].Time <= time) + { + for (int i = 0; i < count; i++) + { + SmokePoints.Add(new SmokePoint + { + Position = pointPos, + Time = time, + Angle = RNG.NextSingle(0, 2 * MathF.PI), + }); + + pointPos += increment; + } + } + + Invalidate(Invalidation.DrawNode); + } + + lastPosition = position; + } + + public void FinishDrawing(double time) + { + smokeEndTime = time; + + double initialFadeOutDurationTrunc = Math.Min(initial_fade_out_duration, smokeEndTime - smokeStartTime); + LifetimeEnd = smokeEndTime + final_fade_out_duration + initialFadeOutDurationTrunc / re_fade_in_speed + initialFadeOutDurationTrunc / final_fade_out_speed; + } + + protected override DrawNode CreateDrawNode() => new SmokeDrawNode(this); + + protected override void Update() + { + base.Update(); + + Invalidate(Invalidation.DrawNode); + } + + protected struct SmokePoint + { + public Vector2 Position; + public double Time; + public float Angle; + + public struct UpperBoundComparer : IComparer + { + public int Compare(SmokePoint x, SmokePoint target) + { + // By returning -1 when the target value is equal to x, guarantees that the + // element at BinarySearch's returned index will always be the first element + // larger. Since 0 is never returned, the target is never "found", so the return + // value will be the index's complement. + + return x.Time > target.Time ? 1 : -1; + } + } + + public struct LowerBoundComparer : IComparer + { + public int Compare(SmokePoint x, SmokePoint target) + { + // Similar logic as UpperBoundComparer, except returned index will always be + // the first element larger or equal + + return x.Time < target.Time ? -1 : 1; + } + } + } + + protected class SmokeDrawNode : TexturedShaderDrawNode + { + protected new SmokeSegment Source => (SmokeSegment)base.Source; + + protected double SmokeStartTime { get; private set; } + protected double SmokeEndTime { get; private set; } + protected double CurrentTime { get; private set; } + + private readonly List points = new List(); + private IVertexBatch? quadBatch; + private float width; + private float height; + private Vector2 drawSize; + private Texture? texture; + private int rotationSeed; + private int firstVisiblePointIndex; + + // anim calculation vars (color, scale, direction) + private double initialFadeOutDurationTrunc; + private double firstVisiblePointTimeAfterSmokeEnded; + + private double initialFadeOutTime; + private double reFadeInTime; + private double finalFadeOutTime; + + public SmokeDrawNode(ITexturedShaderDrawable source) + : base(source) + { + } + + public override void ApplyState() + { + base.ApplyState(); + + width = Source.width; + height = Source.height; + drawSize = Source.DrawSize; + texture = Source.Texture; + + SmokeStartTime = Source.smokeStartTime; + SmokeEndTime = Source.smokeEndTime; + CurrentTime = Source.Clock.CurrentTime; + + rotationSeed = Source.rotationSeed; + + initialFadeOutDurationTrunc = Math.Min(initial_fade_out_duration, SmokeEndTime - SmokeStartTime); + firstVisiblePointTimeAfterSmokeEnded = SmokeEndTime - initialFadeOutDurationTrunc; + + initialFadeOutTime = Math.Min(CurrentTime, SmokeEndTime); + reFadeInTime = CurrentTime - initialFadeOutDurationTrunc - firstVisiblePointTimeAfterSmokeEnded * (1 - 1 / re_fade_in_speed); + finalFadeOutTime = CurrentTime - initialFadeOutDurationTrunc - firstVisiblePointTimeAfterSmokeEnded * (1 - 1 / final_fade_out_speed); + + double firstVisiblePointTime = Math.Min(SmokeEndTime, CurrentTime) - initialFadeOutDurationTrunc; + firstVisiblePointIndex = ~Source.SmokePoints.BinarySearch(new SmokePoint { Time = firstVisiblePointTime }, new SmokePoint.LowerBoundComparer()); + int futurePointIndex = ~Source.SmokePoints.BinarySearch(new SmokePoint { Time = CurrentTime }, new SmokePoint.UpperBoundComparer()); + + points.Clear(); + points.AddRange(Source.SmokePoints.Skip(firstVisiblePointIndex).Take(futurePointIndex - firstVisiblePointIndex)); + } + + public sealed override void Draw(IRenderer renderer) + { + base.Draw(renderer); + + if (points.Count == 0) + return; + + quadBatch ??= renderer.CreateQuadBatch(200, 4); + + if (points.Count > quadBatch.Size && quadBatch.Size != IRenderer.MAX_QUADS) + { + int batchSize = Math.Min(quadBatch.Size * 2, IRenderer.MAX_QUADS); + quadBatch = renderer.CreateQuadBatch(batchSize, 4); + } + + texture ??= renderer.WhitePixel; + RectangleF textureRect = texture.GetTextureRect(); + + renderer.SetBlend(BlendingParameters.Additive); + renderer.PushLocalMatrix(DrawInfo.Matrix); + + BindTextureShader(renderer); + + texture.Bind(); + + for (int i = 0; i < points.Count; i++) + drawPointQuad(points[i], textureRect, i + firstVisiblePointIndex); + + UnbindTextureShader(renderer); + renderer.PopLocalMatrix(); + } + + protected Color4 ColourAtPosition(Vector2 localPos) => DrawColourInfo.Colour.HasSingleColour + ? ((SRGBColour)DrawColourInfo.Colour).Linear + : DrawColourInfo.Colour.Interpolate(Vector2.Divide(localPos, drawSize)).Linear; + + protected virtual Color4 PointColour(SmokePoint point) + { + var color = Color4.White; + + double timeDoingFinalFadeOut = finalFadeOutTime - point.Time / final_fade_out_speed; + + if (timeDoingFinalFadeOut > 0 && point.Time >= firstVisiblePointTimeAfterSmokeEnded) + { + float fraction = Math.Clamp((float)(timeDoingFinalFadeOut / final_fade_out_duration), 0, 1); + fraction = MathF.Pow(fraction, 5); + color.A = (1 - fraction) * re_fade_in_alpha; + } + else + { + double timeDoingInitialFadeOut = initialFadeOutTime - point.Time; + + if (timeDoingInitialFadeOut > 0) + { + float fraction = Math.Clamp((float)(timeDoingInitialFadeOut / initial_fade_out_duration), 0, 1); + color.A = (1 - fraction) * initial_alpha; + } + + if (point.Time > firstVisiblePointTimeAfterSmokeEnded) + { + double timeDoingReFadeIn = reFadeInTime - point.Time / re_fade_in_speed; + + if (timeDoingReFadeIn > 0) + { + float fraction = Math.Clamp((float)(timeDoingReFadeIn / re_fade_in_duration), 0, 1); + fraction = 1 - MathF.Pow(1 - fraction, 5); + color.A = fraction * (re_fade_in_alpha - color.A) + color.A; + } + } + } + + return color; + } + + protected virtual float PointScale(SmokePoint point) + { + double timeDoingScale = CurrentTime - point.Time; + float fraction = Math.Clamp((float)(timeDoingScale / scale_duration), 0, 1); + fraction = 1 - MathF.Pow(1 - fraction, 5); + return fraction * (final_scale - initial_scale) + initial_scale; + } + + protected virtual Vector2 PointDirection(SmokePoint point, int index) + { + double timeDoingRotation = CurrentTime - point.Time; + float fraction = Math.Clamp((float)(timeDoingRotation / rotation_duration), 0, 1); + fraction = 1 - MathF.Pow(1 - fraction, 5); + float angle = fraction * getRotation(index) + point.Angle; + + return new Vector2(MathF.Sin(angle), -MathF.Cos(angle)); + } + + private float getRotation(int index) => max_rotation * (StatelessRNG.NextSingle(rotationSeed, index) * 2 - 1); + + private void drawPointQuad(SmokePoint point, RectangleF textureRect, int index) + { + Debug.Assert(quadBatch != null); + + var colour = PointColour(point); + if (colour.A == 0) + return; + + float scale = PointScale(point); + if (scale == 0) + return; + + var dir = PointDirection(point, index); + var ortho = dir.PerpendicularLeft; + dir *= scale * width; + ortho *= scale * height; + + var localTopLeft = point.Position - ortho - dir; + var localTopRight = point.Position - ortho + dir; + var localBotLeft = point.Position + ortho - dir; + var localBotRight = point.Position + ortho + dir; + + quadBatch.Add(new TexturedVertex2D + { + Position = localTopLeft, + TexturePosition = textureRect.TopLeft, + Colour = Color4Extensions.Multiply(ColourAtPosition(localTopLeft), colour), + }); + quadBatch.Add(new TexturedVertex2D + { + Position = localTopRight, + TexturePosition = textureRect.TopRight, + Colour = Color4Extensions.Multiply(ColourAtPosition(localTopRight), colour), + }); + quadBatch.Add(new TexturedVertex2D + { + Position = localBotRight, + TexturePosition = textureRect.BottomRight, + Colour = Color4Extensions.Multiply(ColourAtPosition(localBotRight), colour), + }); + quadBatch.Add(new TexturedVertex2D + { + Position = localBotLeft, + TexturePosition = textureRect.BottomLeft, + Colour = Color4Extensions.Multiply(ColourAtPosition(localBotLeft), colour), + }); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + quadBatch?.Dispose(); + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SnakingSliderBody.cs b/osu.Game.Rulesets.Osu/Skinning/SnakingSliderBody.cs similarity index 88% rename from osu.Game.Rulesets.Osu/Skinning/Default/SnakingSliderBody.cs rename to osu.Game.Rulesets.Osu/Skinning/SnakingSliderBody.cs index 42d3840158..0b7acc1f47 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SnakingSliderBody.cs +++ b/osu.Game.Rulesets.Osu/Skinning/SnakingSliderBody.cs @@ -12,12 +12,12 @@ using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; using osuTK; -namespace osu.Game.Rulesets.Osu.Skinning.Default +namespace osu.Game.Rulesets.Osu.Skinning { /// /// A which changes its curve depending on the snaking progress. /// - public abstract class SnakingSliderBody : SliderBody, ISliderProgress + public abstract partial class SnakingSliderBody : SliderBody, ISliderProgress { public readonly List CurrentCurve = new List(); @@ -43,6 +43,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default public override Vector2 PathOffset => snakedPathOffset; + public override Vector2 PathEndOffset => snakedPathEndOffset; + /// /// The top-left position of the path when fully snaked. /// @@ -53,7 +55,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default /// private Vector2 snakedPathOffset; - private DrawableSlider drawableSlider; + /// + /// The offset of the end of path from when fully snaked. + /// + private Vector2 snakedPathEndOffset; + + private DrawableSlider drawableSlider = null!; [BackgroundDependencyLoader] private void load(DrawableHitObject drawableObject) @@ -65,7 +72,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default public void UpdateProgress(double completionProgress) { - if (drawableSlider?.HitObject == null) + if (drawableSlider.HitObject == null) return; Slider slider = drawableSlider.HitObject; @@ -94,7 +101,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default public void Refresh() { - if (drawableSlider?.HitObject == null) + if (drawableSlider.HitObject == null) return; // Generate the entire curve @@ -109,6 +116,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default snakedPosition = Path.PositionInBoundingBox(Vector2.Zero); snakedPathOffset = Path.PositionInBoundingBox(Path.Vertices[0]); + snakedPathEndOffset = Path.PositionInBoundingBox(Path.Vertices[^1]); double lastSnakedStart = SnakedStart ?? 0; double lastSnakedEnd = SnakedEnd ?? 0; diff --git a/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs b/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs index 6c76da7925..0249b6d9b1 100644 --- a/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs +++ b/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using System.Linq; @@ -18,7 +20,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Statistics { - public class AccuracyHeatmap : CompositeDrawable + public partial class AccuracyHeatmap : CompositeDrawable { /// /// Size of the inner circle containing the "hit" points, relative to the size of this . @@ -231,7 +233,7 @@ namespace osu.Game.Rulesets.Osu.Statistics bufferedGrid.ForceRedraw(); } - private class HitPoint : Circle + private partial class HitPoint : Circle { /// /// The base colour which will be lightened/darkened depending on the value of this . diff --git a/osu.Game.Rulesets.Osu/UI/AnyOrderHitPolicy.cs b/osu.Game.Rulesets.Osu/UI/AnyOrderHitPolicy.cs index b4de91562b..afa54c2dfb 100644 --- a/osu.Game.Rulesets.Osu/UI/AnyOrderHitPolicy.cs +++ b/osu.Game.Rulesets.Osu/UI/AnyOrderHitPolicy.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI; diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs index 62cab4d6d7..a29faac5a0 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs @@ -1,16 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using System.Runtime.InteropServices; using osu.Framework.Allocation; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Batches; -using osu.Framework.Graphics.OpenGL.Vertices; using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Rendering.Vertices; using osu.Framework.Graphics.Shaders; +using osu.Framework.Graphics.Shaders.Types; using osu.Framework.Graphics.Textures; using osu.Framework.Input; using osu.Framework.Input.Events; @@ -22,7 +25,7 @@ using osuTK.Graphics.ES30; namespace osu.Game.Rulesets.Osu.UI.Cursor { - public class CursorTrail : Drawable, IRequireHighFrequencyMousePosition + public partial class CursorTrail : Drawable, IRequireHighFrequencyMousePosition { private const int max_sprites = 2048; @@ -66,8 +69,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor } [BackgroundDependencyLoader] - private void load(ShaderManager shaders) + private void load(IRenderer renderer, ShaderManager shaders) { + texture ??= renderer.WhitePixel; shader = shaders.Load(@"CursorTrail", FragmentShaderDescriptor.TEXTURE); } @@ -77,7 +81,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor resetTime(); } - private Texture texture = Texture.WhitePixel; + private Texture texture; public Texture Texture { @@ -220,7 +224,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor private Vector2 size; private Vector2 originPosition; - private readonly QuadBatch vertexBatch = new QuadBatch(max_sprites, 1); + private IVertexBatch vertexBatch; public TrailDrawNode(CursorTrail source) : base(source) @@ -252,15 +256,25 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor Source.parts.CopyTo(parts, 0); } - public override void Draw(Action vertexAction) + private IUniformBuffer cursorTrailParameters; + + public override void Draw(IRenderer renderer) { - base.Draw(vertexAction); + base.Draw(renderer); + + vertexBatch ??= renderer.CreateQuadBatch(max_sprites, 1); + + cursorTrailParameters ??= renderer.CreateUniformBuffer(); + cursorTrailParameters.Data = cursorTrailParameters.Data with + { + FadeClock = time, + FadeExponent = fadeExponent + }; shader.Bind(); - shader.GetUniform("g_FadeClock").UpdateValue(ref time); - shader.GetUniform("g_FadeExponent").UpdateValue(ref fadeExponent); + shader.BindUniformBlock("m_CursorTrailParameters", cursorTrailParameters); - texture.TextureGL.Bind(); + texture.Bind(); RectangleF textureRect = texture.GetTextureRect(); @@ -317,7 +331,16 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor { base.Dispose(isDisposing); - vertexBatch.Dispose(); + vertexBatch?.Dispose(); + cursorTrailParameters?.Dispose(); + } + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + private record struct CursorTrailParameters + { + public UniformFloat FadeClock; + public UniformFloat FadeExponent; + private readonly UniformPadding8 pad1; } } diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs index 0e7d7cdcf3..66c86ee09d 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -14,7 +16,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.UI.Cursor { - public class OsuCursor : SkinReloadableDrawable + public partial class OsuCursor : SkinReloadableDrawable { private const float size = 28; @@ -44,7 +46,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor RelativeSizeAxes = Axes.Both, Origin = Anchor.Centre, Anchor = Anchor.Centre, - Child = cursorSprite = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.Cursor), _ => new DefaultCursor(), confineMode: ConfineMode.NoScaling) + Child = cursorSprite = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.Cursor), _ => new DefaultCursor(), confineMode: ConfineMode.NoScaling) { Origin = Anchor.Centre, Anchor = Anchor.Centre, @@ -64,7 +66,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor public void Contract() => expandTarget.ScaleTo(released_scale, 400, Easing.OutQuad); - private class DefaultCursor : OsuCursorSprite + private partial class DefaultCursor : OsuCursorSprite { public DefaultCursor() { diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs index b60ea5da21..5d7648b073 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -19,7 +21,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.UI.Cursor { - public class OsuCursorContainer : GameplayCursorContainer, IKeyBindingHandler + public partial class OsuCursorContainer : GameplayCursorContainer, IKeyBindingHandler { protected override Drawable CreateCursor() => new OsuCursor(); @@ -45,8 +47,8 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor RelativeSizeAxes = Axes.Both, Children = new[] { - cursorTrail = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.CursorTrail), _ => new DefaultCursorTrail(), confineMode: ConfineMode.NoScaling), - new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.CursorParticles), confineMode: ConfineMode.NoScaling), + cursorTrail = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.CursorTrail), _ => new DefaultCursorTrail(), confineMode: ConfineMode.NoScaling), + new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.CursorParticles), confineMode: ConfineMode.NoScaling), } }; } @@ -163,7 +165,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor ActiveCursor.ScaleTo(CursorScale.Value * 0.8f, 450, Easing.OutQuint); } - private class DefaultCursorTrail : CursorTrail + private partial class DefaultCursorTrail : CursorTrail { [BackgroundDependencyLoader] private void load(TextureStore textures) diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorSprite.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorSprite.cs index 573c408a78..aaf8949084 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorSprite.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorSprite.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; namespace osu.Game.Rulesets.Osu.UI.Cursor { - public abstract class OsuCursorSprite : CompositeDrawable + public abstract partial class OsuCursorSprite : CompositeDrawable { /// /// The an optional piece of the cursor to expand when in a clicked state. diff --git a/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs b/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs index df3f7c64e4..c3efd48053 100644 --- a/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -20,10 +22,12 @@ using osuTK; namespace osu.Game.Rulesets.Osu.UI { - public class DrawableOsuRuleset : DrawableRuleset + public partial class DrawableOsuRuleset : DrawableRuleset { protected new OsuRulesetConfigManager Config => (OsuRulesetConfigManager)base.Config; + public new OsuInputManager KeyBindingInputManager => (OsuInputManager)base.KeyBindingInputManager; + public new OsuPlayfield Playfield => (OsuPlayfield)base.Playfield; public DrawableOsuRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) diff --git a/osu.Game.Rulesets.Osu/UI/IHitPolicy.cs b/osu.Game.Rulesets.Osu/UI/IHitPolicy.cs index 5d8ea035a7..0dac3307c2 100644 --- a/osu.Game.Rulesets.Osu/UI/IHitPolicy.cs +++ b/osu.Game.Rulesets.Osu/UI/IHitPolicy.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI; diff --git a/osu.Game.Rulesets.Osu/UI/ObjectOrderedHitPolicy.cs b/osu.Game.Rulesets.Osu/UI/ObjectOrderedHitPolicy.cs index 83f205deac..6330208d37 100644 --- a/osu.Game.Rulesets.Osu/UI/ObjectOrderedHitPolicy.cs +++ b/osu.Game.Rulesets.Osu/UI/ObjectOrderedHitPolicy.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Game.Rulesets.Objects; @@ -35,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.UI switch (obj) { - case DrawableSpinner _: + case DrawableSpinner: continue; case DrawableSlider slider: diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs index bc1e80cd12..ed02284a4b 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; @@ -9,9 +11,11 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Configuration; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; @@ -25,13 +29,14 @@ using osuTK; namespace osu.Game.Rulesets.Osu.UI { [Cached] - public class OsuPlayfield : Playfield + public partial class OsuPlayfield : Playfield { private readonly PlayfieldBorder playfieldBorder; private readonly ProxyContainer approachCircles; private readonly ProxyContainer spinnerProxies; private readonly JudgementContainer judgementLayer; + public SmokeContainer Smoke { get; } public FollowPointRenderer FollowPoints { get; } public static readonly Vector2 BASE_SIZE = new Vector2(512, 384); @@ -50,6 +55,7 @@ namespace osu.Game.Rulesets.Osu.UI InternalChildren = new Drawable[] { playfieldBorder = new PlayfieldBorder { RelativeSizeAxes = Axes.Both }, + Smoke = new SmokeContainer { RelativeSizeAxes = Axes.Both }, spinnerProxies = new ProxyContainer { RelativeSizeAxes = Axes.Both }, FollowPoints = new FollowPointRenderer { RelativeSizeAxes = Axes.Both }, judgementLayer = new JudgementContainer { RelativeSizeAxes = Axes.Both }, @@ -61,7 +67,7 @@ namespace osu.Game.Rulesets.Osu.UI HitPolicy = new StartTimeOrderedHitPolicy(); var hitWindows = new OsuHitWindows(); - foreach (var result in Enum.GetValues(typeof(HitResult)).OfType().Where(r => r > HitResult.None && hitWindows.IsHitResultAllowed(r))) + foreach (var result in Enum.GetValues().Where(r => r > HitResult.None && hitWindows.IsHitResultAllowed(r))) poolDictionary.Add(result, new DrawableJudgementPool(result, onJudgementLoaded)); AddRangeInternal(poolDictionary.Values); @@ -94,7 +100,7 @@ namespace osu.Game.Rulesets.Osu.UI // note: `Slider`'s `ProxiedLayer` is added when its nested `DrawableHitCircle` is loaded. switch (drawable) { - case DrawableSpinner _: + case DrawableSpinner: spinnerProxies.Add(drawable.CreateProxy()); break; @@ -110,21 +116,36 @@ namespace osu.Game.Rulesets.Osu.UI } [BackgroundDependencyLoader(true)] - private void load(OsuRulesetConfigManager config) + private void load(OsuRulesetConfigManager config, IBeatmap beatmap) { config?.BindWith(OsuRulesetSetting.PlayfieldBorderStyle, playfieldBorder.PlayfieldBorderStyle); - RegisterPool(10, 100); + var osuBeatmap = (OsuBeatmap)beatmap; - RegisterPool(10, 100); - RegisterPool(10, 100); - RegisterPool(10, 100); - RegisterPool(10, 100); - RegisterPool(5, 50); + RegisterPool(20, 100); + + // handle edge cases where a beatmap has a slider with many repeats. + int maxRepeatsOnOneSlider = 0; + int maxTicksOnOneSlider = 0; + + if (osuBeatmap != null) + { + foreach (var slider in osuBeatmap.HitObjects.OfType()) + { + maxRepeatsOnOneSlider = Math.Max(maxRepeatsOnOneSlider, slider.RepeatCount); + maxTicksOnOneSlider = Math.Max(maxTicksOnOneSlider, slider.NestedHitObjects.OfType().Count()); + } + } + + RegisterPool(20, 100); + RegisterPool(20, 100); + RegisterPool(20, 100); + RegisterPool(Math.Max(maxTicksOnOneSlider, 20), Math.Max(maxTicksOnOneSlider, 200)); + RegisterPool(Math.Max(maxRepeatsOnOneSlider, 20), Math.Max(maxRepeatsOnOneSlider, 200)); RegisterPool(2, 20); - RegisterPool(10, 100); - RegisterPool(10, 100); + RegisterPool(10, 200); + RegisterPool(10, 200); } protected override HitObjectLifetimeEntry CreateLifetimeEntry(HitObject hitObject) => new OsuHitObjectLifetimeEntry(hitObject); @@ -160,18 +181,18 @@ namespace osu.Game.Rulesets.Osu.UI public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => HitObjectContainer.ReceivePositionalInputAt(screenSpacePos); - private class ProxyContainer : LifetimeManagementContainer + private partial class ProxyContainer : LifetimeManagementContainer { public void Add(Drawable proxy) => AddInternal(proxy); } - private class DrawableJudgementPool : DrawablePool + private partial class DrawableJudgementPool : DrawablePool { private readonly HitResult result; private readonly Action onLoaded; public DrawableJudgementPool(HitResult result, Action onLoaded) - : base(10) + : base(20) { this.result = result; this.onLoaded = onLoaded; diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfieldAdjustmentContainer.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfieldAdjustmentContainer.cs index 0d1a5a8304..b45d552c7f 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuPlayfieldAdjustmentContainer.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfieldAdjustmentContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.UI; @@ -8,7 +10,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.UI { - public class OsuPlayfieldAdjustmentContainer : PlayfieldAdjustmentContainer + public partial class OsuPlayfieldAdjustmentContainer : PlayfieldAdjustmentContainer { protected override Container Content => content; private readonly ScalingContainer content; @@ -46,7 +48,7 @@ namespace osu.Game.Rulesets.Osu.UI /// /// A which scales its content relative to a target width. /// - private class ScalingContainer : Container + private partial class ScalingContainer : Container { internal bool PlayfieldShift { get; set; } diff --git a/osu.Game.Rulesets.Osu/UI/OsuReplayRecorder.cs b/osu.Game.Rulesets.Osu/UI/OsuReplayRecorder.cs index 1304dfe416..66a4f467a9 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuReplayRecorder.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuReplayRecorder.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.Replays; @@ -10,7 +12,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.UI { - public class OsuReplayRecorder : ReplayRecorder + public partial class OsuReplayRecorder : ReplayRecorder { public OsuReplayRecorder(Score score) : base(score) diff --git a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs index 4d4340936d..e951197643 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -9,6 +11,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Rulesets.Osu.UI.Cursor; using osu.Game.Screens.Play; using osuTK; @@ -16,7 +19,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.UI { - public class OsuResumeOverlay : ResumeOverlay + public partial class OsuResumeOverlay : ResumeOverlay { private Container cursorScaleContainer; private OsuClickToResumeCursor clickToResumeCursor; @@ -26,7 +29,7 @@ namespace osu.Game.Rulesets.Osu.UI public override CursorContainer LocalCursor => State.Value == Visibility.Visible ? localCursorContainer : null; - protected override string Message => "Click the orange cursor to resume"; + protected override LocalisableString Message => "Click the orange cursor to resume"; [BackgroundDependencyLoader] private void load() @@ -66,7 +69,7 @@ namespace osu.Game.Rulesets.Osu.UI protected override bool OnHover(HoverEvent e) => true; - public class OsuClickToResumeCursor : OsuCursor, IKeyBindingHandler + public partial class OsuClickToResumeCursor : OsuCursor, IKeyBindingHandler { public override bool HandlePositionalInput => true; diff --git a/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs b/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs index a638019e69..64c4e7eef6 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs @@ -4,13 +4,14 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; +using osu.Game.Localisation; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Osu.Configuration; using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Osu.UI { - public class OsuSettingsSubsection : RulesetSettingsSubsection + public partial class OsuSettingsSubsection : RulesetSettingsSubsection { protected override LocalisableString Header => "osu!"; @@ -28,23 +29,23 @@ namespace osu.Game.Rulesets.Osu.UI { new SettingsCheckbox { - LabelText = "Snaking in sliders", + LabelText = RulesetSettingsStrings.SnakingInSliders, Current = config.GetBindable(OsuRulesetSetting.SnakingInSliders) }, new SettingsCheckbox { ClassicDefault = false, - LabelText = "Snaking out sliders", + LabelText = RulesetSettingsStrings.SnakingOutSliders, Current = config.GetBindable(OsuRulesetSetting.SnakingOutSliders) }, new SettingsCheckbox { - LabelText = "Cursor trail", + LabelText = RulesetSettingsStrings.CursorTrail, Current = config.GetBindable(OsuRulesetSetting.ShowCursorTrail) }, new SettingsEnumDropdown { - LabelText = "Playfield border style", + LabelText = RulesetSettingsStrings.PlayfieldBorderStyle, Current = config.GetBindable(OsuRulesetSetting.PlayfieldBorderStyle), }, }; diff --git a/osu.Game.Rulesets.Osu/UI/OsuTouchInputMapper.cs b/osu.Game.Rulesets.Osu/UI/OsuTouchInputMapper.cs new file mode 100644 index 0000000000..5277a1f7d6 --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/OsuTouchInputMapper.cs @@ -0,0 +1,182 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Input; +using osu.Framework.Input.Events; +using osu.Framework.Input.StateChanges; +using osu.Game.Configuration; +using osuTK; + +namespace osu.Game.Rulesets.Osu.UI +{ + public partial class OsuTouchInputMapper : Drawable + { + /// + /// All the active s and the that it triggered (if any). + /// Ordered from oldest to newest touch chronologically. + /// + 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; + + private Bindable mouseDisabled = null!; + + public OsuTouchInputMapper(OsuInputManager inputManager) + { + osuInputManager = inputManager; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + // The mouse button disable setting affects touch. It's a bit weird. + // This is mostly just doing the same as what is done in RulesetInputManager to match behaviour. + mouseDisabled = config.GetBindable(OsuSetting.MouseDisableButtons); + } + + // Required to handle touches outside of the playfield when screen scaling is enabled. + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + + protected override void OnTouchMove(TouchMoveEvent e) + { + base.OnTouchMove(e); + handleTouchMovement(e); + } + + protected override bool OnTouchDown(TouchDownEvent e) + { + OsuAction action = trackedTouches.Any(t => t.Action == OsuAction.LeftButton) + ? OsuAction.RightButton + : OsuAction.LeftButton; + + // Ignore any taps which trigger an action which is already handled. But track them for potential positional input in the future. + bool shouldResultInAction = osuInputManager.AllowGameplayInputs && !mouseDisabled.Value && trackedTouches.All(t => t.Action != action); + + // If we can actually accept as an action, check whether this tap was on a circle's receptor. + // This case gets special handling to allow for empty-space stream tapping. + bool isDirectCircleTouch = osuInputManager.CheckScreenSpaceActionPressJudgeable(e.ScreenSpaceTouchDownPosition); + + var newTouch = new TrackedTouch(e.Touch.Source, shouldResultInAction ? action : null, isDirectCircleTouch); + + updatePositionTracking(newTouch); + + trackedTouches.Add(newTouch); + + // Important to update position before triggering the pressed action. + handleTouchMovement(e); + + if (shouldResultInAction) + osuInputManager.KeyBindingContainer.TriggerPressed(action); + + return true; + } + + /// + /// Given a new touch, update the positional tracking state and any related operations. + /// + private void updatePositionTracking(TrackedTouch newTouch) + { + // If the new touch directly interacted with a circle's receptor, it always becomes the current touch for positional tracking. + if (newTouch.DirectTouch) + { + positionTrackingTouch = newTouch; + return; + } + + // Otherwise, we only want to use the new touch for position tracking if no other touch is tracking position yet.. + if (positionTrackingTouch == null) + { + positionTrackingTouch = newTouch; + return; + } + + // ..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 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.Action is OsuAction touchAction) + { + 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; + + if (!osuInputManager.AllowUserCursorMovement) + return; + + new MousePositionAbsoluteInput { Position = touchEvent.ScreenSpaceTouch.Position }.Apply(osuInputManager.CurrentState, osuInputManager); + } + + protected override void OnTouchUp(TouchUpEvent e) + { + var tracked = trackedTouches.Single(t => t.Source == e.Touch.Source); + + if (tracked.Action is OsuAction action) + osuInputManager.KeyBindingContainer.TriggerReleased(action); + + if (positionTrackingTouch == tracked) + positionTrackingTouch = null; + + trackedTouches.Remove(tracked); + + base.OnTouchUp(e); + } + + private class TrackedTouch + { + public readonly TouchSource Source; + + 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; + Action = action; + DirectTouch = directTouch; + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/SmokeContainer.cs b/osu.Game.Rulesets.Osu/UI/SmokeContainer.cs new file mode 100644 index 0000000000..389440ba2d --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/SmokeContainer.cs @@ -0,0 +1,77 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Osu.Skinning; +using osu.Game.Rulesets.Osu.Skinning.Default; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Osu.UI +{ + /// + /// Manages smoke trails generated from user input. + /// + public partial class SmokeContainer : Container, IRequireHighFrequencyMousePosition, IKeyBindingHandler + { + private SmokeSkinnableDrawable? currentSegmentSkinnable; + + private Vector2 lastMousePosition; + + public override bool ReceivePositionalInputAt(Vector2 _) => true; + + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == OsuAction.Smoke) + { + AddInternal(currentSegmentSkinnable = new SmokeSkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.CursorSmoke), _ => new DefaultSmokeSegment())); + + // Add initial position immediately. + addPosition(); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + if (e.Action == OsuAction.Smoke) + { + if (currentSegmentSkinnable?.Drawable is SmokeSegment segment) + { + segment.FinishDrawing(Time.Current); + currentSegmentSkinnable = null; + } + } + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + lastMousePosition = e.MousePosition; + addPosition(); + + return base.OnMouseMove(e); + } + + private void addPosition() => (currentSegmentSkinnable?.Drawable as SmokeSegment)?.AddPosition(lastMousePosition, Time.Current); + + private partial class SmokeSkinnableDrawable : SkinnableDrawable + { + public override bool RemoveWhenNotAlive => true; + + public override double LifetimeStart => Drawable.LifetimeStart; + public override double LifetimeEnd => Drawable.LifetimeEnd; + + public SmokeSkinnableDrawable(ISkinComponentLookup lookup, Func? defaultImplementation = null, ConfineMode confineMode = ConfineMode.NoScaling) + : base(lookup, defaultImplementation, confineMode) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/StartTimeOrderedHitPolicy.cs b/osu.Game.Rulesets.Osu/UI/StartTimeOrderedHitPolicy.cs index 0173156246..edc3ba0818 100644 --- a/osu.Game.Rulesets.Osu/UI/StartTimeOrderedHitPolicy.cs +++ b/osu.Game.Rulesets.Osu/UI/StartTimeOrderedHitPolicy.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using osu.Game.Rulesets.Objects; diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs index 266f7d1251..aa4cd0af14 100644 --- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs +++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs @@ -1,11 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Objects; using osuTK; @@ -109,44 +112,46 @@ namespace osu.Game.Rulesets.Osu.Utils /// Reflects the position of the in the playfield horizontally. /// /// The object to reflect. - public static void ReflectHorizontally(OsuHitObject osuObject) + public static void ReflectHorizontallyAlongPlayfield(OsuHitObject osuObject) { osuObject.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - osuObject.X, osuObject.Position.Y); - if (!(osuObject is Slider slider)) + if (osuObject is not Slider slider) return; - // No need to update the head and tail circles, since slider handles that when the new slider path is set - slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - h.Position.X, h.Position.Y)); - slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - h.Position.X, h.Position.Y)); + void reflectNestedObject(OsuHitObject nested) => nested.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - nested.Position.X, nested.Position.Y); + static void reflectControlPoint(PathControlPoint point) => point.Position = new Vector2(-point.Position.X, point.Position.Y); - var controlPoints = slider.Path.ControlPoints.Select(p => new PathControlPoint(p.Position, p.Type)).ToArray(); - foreach (var point in controlPoints) - point.Position = new Vector2(-point.Position.X, point.Position.Y); - - slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value); + modifySlider(slider, reflectNestedObject, reflectControlPoint); } /// /// Reflects the position of the in the playfield vertically. /// /// The object to reflect. - public static void ReflectVertically(OsuHitObject osuObject) + public static void ReflectVerticallyAlongPlayfield(OsuHitObject osuObject) { osuObject.Position = new Vector2(osuObject.Position.X, OsuPlayfield.BASE_SIZE.Y - osuObject.Y); - if (!(osuObject is Slider slider)) + if (osuObject is not Slider slider) return; - // No need to update the head and tail circles, since slider handles that when the new slider path is set - slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y)); - slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y)); + void reflectNestedObject(OsuHitObject nested) => nested.Position = new Vector2(nested.Position.X, OsuPlayfield.BASE_SIZE.Y - nested.Position.Y); + static void reflectControlPoint(PathControlPoint point) => point.Position = new Vector2(point.Position.X, -point.Position.Y); - var controlPoints = slider.Path.ControlPoints.Select(p => new PathControlPoint(p.Position, p.Type)).ToArray(); - foreach (var point in controlPoints) - point.Position = new Vector2(point.Position.X, -point.Position.Y); + modifySlider(slider, reflectNestedObject, reflectControlPoint); + } - slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value); + /// + /// Flips the position of the around its start position horizontally. + /// + /// The slider to be flipped. + public static void FlipSliderInPlaceHorizontally(Slider slider) + { + void flipNestedObject(OsuHitObject nested) => nested.Position = new Vector2(slider.X - (nested.X - slider.X), nested.Y); + static void flipControlPoint(PathControlPoint point) => point.Position = new Vector2(-point.Position.X, point.Position.Y); + + modifySlider(slider, flipNestedObject, flipControlPoint); } /// @@ -157,14 +162,20 @@ namespace osu.Game.Rulesets.Osu.Utils public static void RotateSlider(Slider slider, float rotation) { void rotateNestedObject(OsuHitObject nested) => nested.Position = rotateVector(nested.Position - slider.Position, rotation) + slider.Position; + void rotateControlPoint(PathControlPoint point) => point.Position = rotateVector(point.Position, rotation); + modifySlider(slider, rotateNestedObject, rotateControlPoint); + } + + private static void modifySlider(Slider slider, Action modifyNestedObject, Action modifyControlPoint) + { // No need to update the head and tail circles, since slider handles that when the new slider path is set - slider.NestedHitObjects.OfType().ForEach(rotateNestedObject); - slider.NestedHitObjects.OfType().ForEach(rotateNestedObject); + slider.NestedHitObjects.OfType().ForEach(modifyNestedObject); + slider.NestedHitObjects.OfType().ForEach(modifyNestedObject); var controlPoints = slider.Path.ControlPoints.Select(p => new PathControlPoint(p.Position, p.Type)).ToArray(); foreach (var point in controlPoints) - point.Position = rotateVector(point.Position, rotation); + modifyControlPoint(point); slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value); } @@ -184,5 +195,39 @@ namespace osu.Game.Rulesets.Osu.Utils length * MathF.Sin(angle) ); } + + /// The beatmap hitObject is a part of. + /// The that should be checked. + /// If true, this method only returns true if hitObject is on a downbeat. + /// If false, it returns true if hitObject is on any beat. + /// true if hitObject is on a (down-)beat, false otherwise. + public static bool IsHitObjectOnBeat(OsuBeatmap beatmap, OsuHitObject hitObject, bool downbeatsOnly = false) + { + var timingPoint = beatmap.ControlPointInfo.TimingPointAt(hitObject.StartTime); + + double timeSinceTimingPoint = hitObject.StartTime - timingPoint.Time; + + double beatLength = timingPoint.BeatLength; + + if (downbeatsOnly) + beatLength *= timingPoint.TimeSignature.Numerator; + + // Ensure within 1ms of expected location. + return Math.Abs(timeSinceTimingPoint + 1) % beatLength < 2; + } + + /// + /// Generates a random number from a normal distribution using the Box-Muller transform. + /// + public static float RandomGaussian(Random rng, float mean = 0, float stdDev = 1) + { + // Generate 2 random numbers in the interval (0,1]. + // x1 must not be 0 since log(0) = undefined. + double x1 = 1 - rng.NextDouble(); + double x2 = 1 - rng.NextDouble(); + + double stdNormal = Math.Sqrt(-2 * Math.Log(x1)) * Math.Sin(2 * Math.PI * x2); + return mean + stdDev * (float)stdNormal; + } } } diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs index a77d1f8b0f..a9ae313a31 100644 --- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs +++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils_Reposition.cs @@ -10,8 +10,6 @@ using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; using osuTK; -#nullable enable - namespace osu.Game.Rulesets.Osu.Utils { public static partial class OsuHitObjectGenerationUtils @@ -90,11 +88,11 @@ namespace osu.Game.Rulesets.Osu.Utils switch (hitObject) { - case HitCircle _: + case HitCircle: shift = clampHitCircleToPlayfield(current); break; - case Slider _: + case Slider: shift = clampSliderToPlayfield(current); break; } @@ -196,7 +194,28 @@ namespace osu.Game.Rulesets.Osu.Utils private static Vector2 clampSliderToPlayfield(WorkingObject workingObject) { var slider = (Slider)workingObject.HitObject; - var possibleMovementBounds = calculatePossibleMovementBounds(slider); + var possibleMovementBounds = CalculatePossibleMovementBounds(slider); + + // The slider rotation applied in computeModifiedPosition might make it impossible to fit the slider into the playfield + // For example, a long horizontal slider will be off-screen when rotated by 90 degrees + // In this case, limit the rotation to either 0 or 180 degrees + if (possibleMovementBounds.Width < 0 || possibleMovementBounds.Height < 0) + { + float currentRotation = getSliderRotation(slider); + float diff1 = getAngleDifference(workingObject.RotationOriginal, currentRotation); + float diff2 = getAngleDifference(workingObject.RotationOriginal + MathF.PI, currentRotation); + + if (diff1 < diff2) + { + RotateSlider(slider, workingObject.RotationOriginal - getSliderRotation(slider)); + } + else + { + RotateSlider(slider, workingObject.RotationOriginal + MathF.PI - getSliderRotation(slider)); + } + + possibleMovementBounds = CalculatePossibleMovementBounds(slider); + } var previousPosition = workingObject.PositionModified; @@ -241,10 +260,12 @@ namespace osu.Game.Rulesets.Osu.Utils /// Calculates a which contains all of the possible movements of the slider (in relative X/Y coordinates) /// such that the entire slider is inside the playfield. /// + /// The for which to calculate a movement bounding box. + /// A which contains all of the possible movements of the slider such that the entire slider is inside the playfield. /// /// If the slider is larger than the playfield, the returned may have negative width/height. /// - private static RectangleF calculatePossibleMovementBounds(Slider slider) + public static RectangleF CalculatePossibleMovementBounds(Slider slider) { var pathPositions = new List(); slider.Path.GetPathToProgress(pathPositions, 0, 1); @@ -355,6 +376,18 @@ namespace osu.Game.Rulesets.Osu.Utils return MathF.Atan2(endPositionVector.Y, endPositionVector.X); } + /// + /// Get the absolute difference between 2 angles measured in Radians. + /// + /// The first angle + /// The second angle + /// The absolute difference with interval [0, MathF.PI) + private static float getAngleDifference(float angle1, float angle2) + { + float diff = MathF.Abs(angle1 - angle2) % (MathF.PI * 2); + return MathF.Min(diff, MathF.PI * 2 - diff); + } + public class ObjectPositionInfo { /// @@ -397,6 +430,7 @@ namespace osu.Game.Rulesets.Osu.Utils private class WorkingObject { + public float RotationOriginal { get; } public Vector2 PositionOriginal { get; } public Vector2 PositionModified { get; set; } public Vector2 EndPositionModified { get; set; } @@ -407,6 +441,7 @@ namespace osu.Game.Rulesets.Osu.Utils public WorkingObject(ObjectPositionInfo positionInfo) { PositionInfo = positionInfo; + RotationOriginal = HitObject is Slider slider ? getSliderRotation(slider) : 0; PositionModified = PositionOriginal = HitObject.Position; EndPositionModified = HitObject.EndPosition; } diff --git a/osu.Game.Rulesets.Osu/osu.Game.Rulesets.Osu.csproj b/osu.Game.Rulesets.Osu/osu.Game.Rulesets.Osu.csproj index 98f1e69bd1..75656e2976 100644 --- a/osu.Game.Rulesets.Osu/osu.Game.Rulesets.Osu.csproj +++ b/osu.Game.Rulesets.Osu/osu.Game.Rulesets.Osu.csproj @@ -1,9 +1,10 @@  - netstandard2.1 + net6.0 Library true click the circles. to the beat. + 10 @@ -15,4 +16,4 @@ - \ No newline at end of file + diff --git a/osu.Game.Rulesets.Taiko.Tests.Android/Properties/AndroidManifest.xml b/osu.Game.Rulesets.Taiko.Tests.Android/AndroidManifest.xml similarity index 95% rename from osu.Game.Rulesets.Taiko.Tests.Android/Properties/AndroidManifest.xml rename to osu.Game.Rulesets.Taiko.Tests.Android/AndroidManifest.xml index d9de0fde4e..452b9683ec 100644 --- a/osu.Game.Rulesets.Taiko.Tests.Android/Properties/AndroidManifest.xml +++ b/osu.Game.Rulesets.Taiko.Tests.Android/AndroidManifest.xml @@ -1,5 +1,5 @@  - + \ No newline at end of file diff --git a/osu.Game.Rulesets.Taiko.Tests.Android/MainActivity.cs b/osu.Game.Rulesets.Taiko.Tests.Android/MainActivity.cs index e4f4bbfd53..a55b461876 100644 --- a/osu.Game.Rulesets.Taiko.Tests.Android/MainActivity.cs +++ b/osu.Game.Rulesets.Taiko.Tests.Android/MainActivity.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Android.App; using osu.Framework.Android; using osu.Game.Tests; diff --git a/osu.Game.Rulesets.Taiko.Tests.Android/osu.Game.Rulesets.Taiko.Tests.Android.csproj b/osu.Game.Rulesets.Taiko.Tests.Android/osu.Game.Rulesets.Taiko.Tests.Android.csproj index 4d4dabebe6..a639326ebd 100644 --- a/osu.Game.Rulesets.Taiko.Tests.Android/osu.Game.Rulesets.Taiko.Tests.Android.csproj +++ b/osu.Game.Rulesets.Taiko.Tests.Android/osu.Game.Rulesets.Taiko.Tests.Android.csproj @@ -1,49 +1,24 @@ - - + - Debug - AnyCPU - 8.0.30703 - 2.0 - {3701A0A1-8476-42C6-B5C4-D24129B4A484} - {EFBA0AD7-5A72-4C68-AF49-83D382785DCF};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - {122416d6-6b49-4ee2-a1e8-b825f31c79fe} + net6.0-android + Exe osu.Game.Rulesets.Taiko.Tests osu.Game.Rulesets.Taiko.Tests.Android - Properties\AndroidManifest.xml - armeabi-v7a;x86;arm64-v8a - - - None - cjk;mideast;other;rare;west - true - - - - - - - + %(RecursiveDir)%(Filename)%(Extension) + + + %(RecursiveDir)%(Filename)%(Extension) + Android\%(RecursiveDir)%(Filename)%(Extension) + - - {f167e17a-7de6-4af5-b920-a5112296c695} - osu.Game.Rulesets.Taiko - - - {2a66dd92-adb1-4994-89e2-c94e04acda0d} - osu.Game - + + - - - 5.0.0 - - - \ No newline at end of file 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 567220f316..0000000000 --- a/osu.Game.Rulesets.Taiko.Tests.iOS/AppDelegate.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using 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 9fde65a074..0b6a11d8c2 100644 --- a/osu.Game.Rulesets.Taiko.Tests.iOS/Application.cs +++ b/osu.Game.Rulesets.Taiko.Tests.iOS/Application.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.iOS; -using UIKit; +using osu.Game.Tests; namespace osu.Game.Rulesets.Taiko.Tests.iOS { @@ -10,7 +10,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.iOS { public static void Main(string[] args) { - UIApplication.Main(args, typeof(GameUIApplication), typeof(AppDelegate)); + GameApplication.Main(new OsuTestBrowser()); } } } diff --git a/osu.Game.Rulesets.Taiko.Tests.iOS/Info.plist b/osu.Game.Rulesets.Taiko.Tests.iOS/Info.plist index 9628475b3e..76cb3c0db0 100644 --- a/osu.Game.Rulesets.Taiko.Tests.iOS/Info.plist +++ b/osu.Game.Rulesets.Taiko.Tests.iOS/Info.plist @@ -13,7 +13,7 @@ LSRequiresIPhoneOS MinimumOSVersion - 10.0 + 13.4 UIDeviceFamily 1 diff --git a/osu.Game.Rulesets.Taiko.Tests.iOS/osu.Game.Rulesets.Taiko.Tests.iOS.csproj b/osu.Game.Rulesets.Taiko.Tests.iOS/osu.Game.Rulesets.Taiko.Tests.iOS.csproj index 8ee640cd99..e648a11299 100644 --- a/osu.Game.Rulesets.Taiko.Tests.iOS/osu.Game.Rulesets.Taiko.Tests.iOS.csproj +++ b/osu.Game.Rulesets.Taiko.Tests.iOS/osu.Game.Rulesets.Taiko.Tests.iOS.csproj @@ -1,35 +1,19 @@ - - + - Debug - iPhoneSimulator - {7E408809-66AC-49D1-AF4D-98834F9B979A} Exe + net6.0-ios + 13.4 osu.Game.Rulesets.Taiko.Tests osu.Game.Rulesets.Taiko.Tests.iOS - - - - Linker.xml - - - %(RecursiveDir)%(Filename)%(Extension) - - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D} - osu.Game - - - {F167E17A-7DE6-4AF5-B920-A5112296C695} - osu.Game.Rulesets.Taiko - + + - - \ No newline at end of file + diff --git a/osu.Game.Rulesets.Taiko.Tests/DrawableTaikoRulesetTestScene.cs b/osu.Game.Rulesets.Taiko.Tests/DrawableTaikoRulesetTestScene.cs index f5e7304c12..a222fe8275 100644 --- a/osu.Game.Rulesets.Taiko.Tests/DrawableTaikoRulesetTestScene.cs +++ b/osu.Game.Rulesets.Taiko.Tests/DrawableTaikoRulesetTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -14,20 +16,36 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Taiko.Tests { - public abstract class DrawableTaikoRulesetTestScene : OsuTestScene + public abstract partial class DrawableTaikoRulesetTestScene : OsuTestScene { protected const int DEFAULT_PLAYFIELD_CONTAINER_HEIGHT = 768; protected DrawableTaikoRuleset DrawableRuleset { get; private set; } protected Container PlayfieldContainer { get; private set; } + private ControlPointInfo controlPointInfo { get; set; } + [BackgroundDependencyLoader] private void load() { - var controlPointInfo = new ControlPointInfo(); + controlPointInfo = new ControlPointInfo(); controlPointInfo.Add(0, new TimingControlPoint()); - IWorkingBeatmap beatmap = CreateWorkingBeatmap(new Beatmap + IWorkingBeatmap beatmap = CreateWorkingBeatmap(CreateBeatmap(new TaikoRuleset().RulesetInfo)); + + Add(PlayfieldContainer = new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + Height = DEFAULT_PLAYFIELD_CONTAINER_HEIGHT, + Children = new[] { DrawableRuleset = new DrawableTaikoRuleset(new TaikoRuleset(), beatmap.GetPlayableBeatmap(beatmap.BeatmapInfo.Ruleset)) } + }); + } + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) + { + return new Beatmap { HitObjects = new List { new Hit { Type = HitType.Centre } }, BeatmapInfo = new BeatmapInfo @@ -39,19 +57,10 @@ namespace osu.Game.Rulesets.Taiko.Tests Title = @"Sample Beatmap", Author = { Username = @"peppy" }, }, - Ruleset = new TaikoRuleset().RulesetInfo + Ruleset = ruleset }, ControlPointInfo = controlPointInfo - }); - - Add(PlayfieldContainer = new Container - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - Height = DEFAULT_PLAYFIELD_CONTAINER_HEIGHT, - Children = new[] { DrawableRuleset = new DrawableTaikoRuleset(new TaikoRuleset(), beatmap.GetPlayableBeatmap(new TaikoRuleset().RulesetInfo)) } - }); + }; } } } diff --git a/osu.Game.Rulesets.Taiko.Tests/DrawableTestHit.cs b/osu.Game.Rulesets.Taiko.Tests/DrawableTestHit.cs index 6d4cac0ebe..157a96eec8 100644 --- a/osu.Game.Rulesets.Taiko.Tests/DrawableTestHit.cs +++ b/osu.Game.Rulesets.Taiko.Tests/DrawableTestHit.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Input.Events; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -10,7 +12,7 @@ using osu.Game.Rulesets.Taiko.Objects.Drawables; namespace osu.Game.Rulesets.Taiko.Tests { - public class DrawableTestHit : DrawableHit + public partial class DrawableTestHit : DrawableHit { public readonly HitResult Type; diff --git a/osu.Game.Rulesets.Taiko.Tests/DrawableTestStrongHit.cs b/osu.Game.Rulesets.Taiko.Tests/DrawableTestStrongHit.cs index ea877c9e17..747c599721 100644 --- a/osu.Game.Rulesets.Taiko.Tests/DrawableTestStrongHit.cs +++ b/osu.Game.Rulesets.Taiko.Tests/DrawableTestStrongHit.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Input.Events; using osu.Game.Rulesets.Scoring; @@ -9,7 +11,7 @@ using osu.Game.Rulesets.Taiko.Objects.Drawables; namespace osu.Game.Rulesets.Taiko.Tests { - public class DrawableTestStrongHit : DrawableTestHit + public partial class DrawableTestStrongHit : DrawableTestHit { private readonly bool hitBoth; diff --git a/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneEditor.cs b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneEditor.cs index e3c1613bd9..3ee9171e7e 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneEditor.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneEditor.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Taiko.Tests.Editor { [TestFixture] - public class TestSceneEditor : EditorTestScene + public partial class TestSceneEditor : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new TaikoRuleset(); } diff --git a/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoEditorSaving.cs b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoEditorSaving.cs index 33c2ba532e..93b26624de 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoEditorSaving.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoEditorSaving.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Utils; using osu.Game.Rulesets.Taiko.Beatmaps; @@ -8,7 +10,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Taiko.Tests.Editor { - public class TestSceneTaikoEditorSaving : EditorSavingTestScene + public partial class TestSceneTaikoEditorSaving : EditorSavingTestScene { protected override Ruleset CreateRuleset() => new TaikoRuleset(); diff --git a/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoHitObjectComposer.cs b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoHitObjectComposer.cs index 55eb2fa66b..ed73730c4a 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoHitObjectComposer.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoHitObjectComposer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -14,13 +16,13 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Taiko.Tests.Editor { - public class TestSceneTaikoHitObjectComposer : EditorClockTestScene + public partial class TestSceneTaikoHitObjectComposer : EditorClockTestScene { [SetUp] public void Setup() => Schedule(() => { BeatDivisor.Value = 8; - Clock.Seek(0); + EditorClock.Seek(0); Child = new TestComposer { RelativeSizeAxes = Axes.Both }; }); @@ -30,7 +32,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Editor { } - private class TestComposer : CompositeDrawable + private partial class TestComposer : CompositeDrawable { [Cached(typeof(EditorBeatmap))] [Cached(typeof(IBeatSnapProvider))] diff --git a/osu.Game.Rulesets.Taiko.Tests/HitObjectApplicationTestScene.cs b/osu.Game.Rulesets.Taiko.Tests/HitObjectApplicationTestScene.cs index ac01508081..e8688f36bc 100644 --- a/osu.Game.Rulesets.Taiko.Tests/HitObjectApplicationTestScene.cs +++ b/osu.Game.Rulesets.Taiko.Tests/HitObjectApplicationTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Testing; @@ -14,7 +16,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Taiko.Tests { - public abstract class HitObjectApplicationTestScene : OsuTestScene + public abstract partial class HitObjectApplicationTestScene : OsuTestScene { [Cached(typeof(IScrollingInfo))] private ScrollingTestContainer.TestScrollingInfo info = new ScrollingTestContainer.TestScrollingInfo diff --git a/osu.Game.Rulesets.Taiko.Tests/Judgements/JudgementTest.cs b/osu.Game.Rulesets.Taiko.Tests/Judgements/JudgementTest.cs new file mode 100644 index 0000000000..eb2d96ec51 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Judgements/JudgementTest.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 System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Extensions.TypeExtensions; +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.Replays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests.Judgements +{ + public partial class JudgementTest : RateAdjustedBeatmapTestScene + { + private ScoreAccessibleReplayPlayer currentPlayer = null!; + protected List JudgementResults { get; private set; } = null!; + + protected void AssertJudgementCount(int count) + { + AddAssert($"{count} judgement{(count > 0 ? "s" : "")}", () => JudgementResults, () => Has.Count.EqualTo(count)); + } + + protected void AssertResult(int index, HitResult expectedResult) + { + AddAssert($"{typeof(T).ReadableName()} ({index}) judged as {expectedResult}", + () => JudgementResults.Where(j => j.HitObject is T).OrderBy(j => j.HitObject.StartTime).ElementAt(index).Type, + () => Is.EqualTo(expectedResult)); + } + + protected void PerformTest(List frames, Beatmap? beatmap = null) + { + AddStep("load player", () => + { + Beatmap.Value = CreateWorkingBeatmap(beatmap); + + var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); + + p.OnLoadComplete += _ => + { + p.ScoreProcessor.NewJudgement += result => + { + if (currentPlayer == p) JudgementResults.Add(result); + }; + }; + + LoadScreen(currentPlayer = p); + JudgementResults = new List(); + }); + + AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0); + AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen()); + AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor.HasCompleted.Value); + } + + protected Beatmap CreateBeatmap(params TaikoHitObject[] hitObjects) + { + var beatmap = new Beatmap + { + HitObjects = hitObjects.ToList(), + BeatmapInfo = + { + Difficulty = new BeatmapDifficulty { SliderTickRate = 4 }, + Ruleset = new TaikoRuleset().RulesetInfo + }, + }; + + beatmap.ControlPointInfo.Add(0, new EffectControlPoint { ScrollSpeed = 0.1f }); + return beatmap; + } + + private partial class ScoreAccessibleReplayPlayer : ReplayPlayer + { + public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; + + protected override bool PauseOnFocusLost => false; + + public ScoreAccessibleReplayPlayer(Score score) + : base(score, new PlayerConfiguration + { + AllowPause = false, + ShowResults = false, + }) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneDrumRollJudgements.cs b/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneDrumRollJudgements.cs new file mode 100644 index 0000000000..a9231b4783 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneDrumRollJudgements.cs @@ -0,0 +1,201 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using NUnit.Framework; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Replays; + +namespace osu.Game.Rulesets.Taiko.Tests.Judgements +{ + public partial class TestSceneDrumRollJudgements : JudgementTest + { + [Test] + public void TestHitAllDrumRoll() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(1000, TaikoAction.LeftCentre), + new TaikoReplayFrame(1001), + new TaikoReplayFrame(2000, TaikoAction.LeftCentre), + new TaikoReplayFrame(2001), + }, CreateBeatmap(new DrumRoll + { + StartTime = hit_time, + Duration = 1000 + })); + + AssertJudgementCount(3); + AssertResult(0, HitResult.SmallBonus); + AssertResult(1, HitResult.SmallBonus); + AssertResult(0, HitResult.IgnoreHit); + } + + [Test] + public void TestHitSomeDrumRoll() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(2000, TaikoAction.LeftCentre), + new TaikoReplayFrame(2001), + }, CreateBeatmap(new DrumRoll + { + StartTime = hit_time, + Duration = 1000 + })); + + AssertJudgementCount(3); + AssertResult(0, HitResult.IgnoreMiss); + AssertResult(1, HitResult.SmallBonus); + AssertResult(0, HitResult.IgnoreHit); + } + + [Test] + public void TestHitNoneDrumRoll() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + }, CreateBeatmap(new DrumRoll + { + StartTime = hit_time, + Duration = 1000 + })); + + AssertJudgementCount(3); + AssertResult(0, HitResult.IgnoreMiss); + AssertResult(1, HitResult.IgnoreMiss); + AssertResult(0, HitResult.IgnoreHit); + } + + [Test] + public void TestHitAllStrongDrumRollWithOneKey() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(1000, TaikoAction.LeftCentre), + new TaikoReplayFrame(1001), + new TaikoReplayFrame(2000, TaikoAction.LeftCentre), + new TaikoReplayFrame(2001), + }, CreateBeatmap(new DrumRoll + { + StartTime = hit_time, + Duration = 1000, + IsStrong = true + })); + + AssertJudgementCount(6); + + AssertResult(0, HitResult.SmallBonus); + AssertResult(0, HitResult.LargeBonus); + + AssertResult(1, HitResult.SmallBonus); + AssertResult(1, HitResult.LargeBonus); + + AssertResult(0, HitResult.IgnoreHit); + AssertResult(2, HitResult.IgnoreHit); + } + + [Test] + public void TestHitSomeStrongDrumRollWithOneKey() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(2000, TaikoAction.LeftCentre), + new TaikoReplayFrame(2001), + }, CreateBeatmap(new DrumRoll + { + StartTime = hit_time, + Duration = 1000, + IsStrong = true + })); + + AssertJudgementCount(6); + + AssertResult(0, HitResult.IgnoreMiss); + AssertResult(0, HitResult.IgnoreMiss); + + AssertResult(1, HitResult.SmallBonus); + AssertResult(1, HitResult.LargeBonus); + + AssertResult(0, HitResult.IgnoreHit); + AssertResult(2, HitResult.IgnoreHit); + } + + [Test] + public void TestHitAllStrongDrumRollWithBothKeys() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(1000, TaikoAction.LeftCentre, TaikoAction.RightCentre), + new TaikoReplayFrame(1001), + new TaikoReplayFrame(2000, TaikoAction.LeftCentre, TaikoAction.RightCentre), + new TaikoReplayFrame(2001), + }, CreateBeatmap(new DrumRoll + { + StartTime = hit_time, + Duration = 1000, + IsStrong = true + })); + + AssertJudgementCount(6); + + AssertResult(0, HitResult.SmallBonus); + AssertResult(0, HitResult.LargeBonus); + + AssertResult(1, HitResult.SmallBonus); + AssertResult(1, HitResult.LargeBonus); + + AssertResult(0, HitResult.IgnoreHit); + AssertResult(2, HitResult.IgnoreHit); + } + + [Test] + public void TestHitSomeStrongDrumRollWithBothKeys() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(2000, TaikoAction.LeftCentre, TaikoAction.RightCentre), + new TaikoReplayFrame(2001), + }, CreateBeatmap(new DrumRoll + { + StartTime = hit_time, + Duration = 1000, + IsStrong = true + })); + + AssertJudgementCount(6); + + AssertResult(0, HitResult.IgnoreMiss); + AssertResult(0, HitResult.IgnoreMiss); + + AssertResult(1, HitResult.SmallBonus); + AssertResult(1, HitResult.LargeBonus); + + AssertResult(0, HitResult.IgnoreHit); + AssertResult(2, HitResult.IgnoreHit); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneHitJudgements.cs b/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneHitJudgements.cs new file mode 100644 index 0000000000..3bf94eb62e --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneHitJudgements.cs @@ -0,0 +1,161 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using NUnit.Framework; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Replays; + +namespace osu.Game.Rulesets.Taiko.Tests.Judgements +{ + public partial class TestSceneHitJudgements : JudgementTest + { + [Test] + public void TestHitCentreHit() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(hit_time, TaikoAction.LeftCentre), + }, CreateBeatmap(new Hit + { + Type = HitType.Centre, + StartTime = hit_time + })); + + AssertJudgementCount(1); + AssertResult(0, HitResult.Great); + } + + [Test] + public void TestHitRimHit() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(hit_time, TaikoAction.LeftRim), + }, CreateBeatmap(new Hit + { + Type = HitType.Rim, + StartTime = hit_time + })); + + AssertJudgementCount(1); + AssertResult(0, HitResult.Great); + } + + [Test] + public void TestMissHit() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0) + }, CreateBeatmap(new Hit + { + Type = HitType.Centre, + StartTime = hit_time + })); + + AssertJudgementCount(1); + AssertResult(0, HitResult.Miss); + } + + [Test] + public void TestHitStrongHitWithOneKey() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(hit_time, TaikoAction.LeftCentre), + }, CreateBeatmap(new Hit + { + Type = HitType.Centre, + StartTime = hit_time, + IsStrong = true + })); + + AssertJudgementCount(2); + AssertResult(0, HitResult.Great); + AssertResult(0, HitResult.IgnoreMiss); + } + + [Test] + public void TestHitStrongHitWithBothKeys() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(hit_time, TaikoAction.LeftCentre, TaikoAction.RightCentre), + }, CreateBeatmap(new Hit + { + Type = HitType.Centre, + StartTime = hit_time, + IsStrong = true + })); + + AssertJudgementCount(2); + AssertResult(0, HitResult.Great); + AssertResult(0, HitResult.LargeBonus); + } + + [Test] + public void TestMissStrongHit() + { + const double hit_time = 1000; + + PerformTest(new List + { + new TaikoReplayFrame(0), + }, CreateBeatmap(new Hit + { + Type = HitType.Centre, + StartTime = hit_time, + IsStrong = true + })); + + AssertJudgementCount(2); + AssertResult(0, HitResult.Miss); + AssertResult(0, HitResult.IgnoreMiss); + } + + [Test] + public void TestHighVelocityHit() + { + const double hit_time = 1000; + + var beatmap = CreateBeatmap(new Hit + { + Type = HitType.Centre, + StartTime = hit_time, + }); + + beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 6 }); + beatmap.ControlPointInfo.Add(0, new EffectControlPoint { ScrollSpeed = 10 }); + + var hitWindows = new HitWindows(); + hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); + + PerformTest(new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(hit_time - hitWindows.WindowFor(HitResult.Great), TaikoAction.LeftCentre), + }, beatmap); + + AssertJudgementCount(1); + AssertResult(0, HitResult.Ok); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneSwellJudgements.cs b/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneSwellJudgements.cs new file mode 100644 index 0000000000..ccc829f09e --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneSwellJudgements.cs @@ -0,0 +1,118 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Replays; + +namespace osu.Game.Rulesets.Taiko.Tests.Judgements +{ + public partial class TestSceneSwellJudgements : JudgementTest + { + [Test] + public void TestHitAllSwell() + { + const double hit_time = 1000; + + Swell swell = new Swell + { + StartTime = hit_time, + Duration = 1000, + RequiredHits = 10 + }; + + List frames = new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(2001), + }; + + for (int i = 0; i < swell.RequiredHits; i++) + { + double frameTime = 1000 + i * 50; + frames.Add(new TaikoReplayFrame(frameTime, i % 2 == 0 ? TaikoAction.LeftCentre : TaikoAction.LeftRim)); + frames.Add(new TaikoReplayFrame(frameTime + 10)); + } + + PerformTest(frames, CreateBeatmap(swell)); + + AssertJudgementCount(11); + + for (int i = 0; i < swell.RequiredHits; i++) + AssertResult(i, HitResult.IgnoreHit); + + AssertResult(0, HitResult.LargeBonus); + } + + [Test] + public void TestHitSomeSwell() + { + const double hit_time = 1000; + + Swell swell = new Swell + { + StartTime = hit_time, + Duration = 1000, + RequiredHits = 10 + }; + + List frames = new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(2001), + }; + + for (int i = 0; i < swell.RequiredHits / 2; i++) + { + double frameTime = 1000 + i * 50; + frames.Add(new TaikoReplayFrame(frameTime, i % 2 == 0 ? TaikoAction.LeftCentre : TaikoAction.LeftRim)); + frames.Add(new TaikoReplayFrame(frameTime + 10)); + } + + PerformTest(frames, CreateBeatmap(swell)); + + AssertJudgementCount(11); + + for (int i = 0; i < swell.RequiredHits / 2; i++) + AssertResult(i, HitResult.IgnoreHit); + for (int i = swell.RequiredHits / 2; i < swell.RequiredHits; i++) + AssertResult(i, HitResult.IgnoreMiss); + + AssertResult(0, HitResult.IgnoreMiss); + } + + [Test] + public void TestHitNoneSwell() + { + const double hit_time = 1000; + + Swell swell = new Swell + { + StartTime = hit_time, + Duration = 1000, + RequiredHits = 10 + }; + + List frames = new List + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(2001), + }; + + PerformTest(frames, CreateBeatmap(swell)); + + AssertJudgementCount(11); + + for (int i = 0; i < swell.RequiredHits; i++) + AssertResult(i, HitResult.IgnoreMiss); + + AssertResult(0, HitResult.IgnoreMiss); + + AddAssert("all tick offsets are 0", () => JudgementResults.Where(r => r.HitObject is SwellTick).All(r => r.TimeOffset == 0)); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TaikoModTestScene.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TaikoModTestScene.cs index 3090facf8c..7760c5a30b 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Mods/TaikoModTestScene.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TaikoModTestScene.cs @@ -5,7 +5,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Taiko.Tests.Mods { - public abstract class TaikoModTestScene : ModTestScene + public abstract partial class TaikoModTestScene : ModTestScene { protected sealed override Ruleset CreatePlayerRuleset() => new TaikoRuleset(); } diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModFlashlight.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModFlashlight.cs new file mode 100644 index 0000000000..05a408c621 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModFlashlight.cs @@ -0,0 +1,56 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Rulesets.Taiko.Mods; +using osu.Game.Rulesets.Taiko.UI; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.Tests.Mods +{ + public partial class TestSceneTaikoModFlashlight : TaikoModTestScene + { + [TestCase(1f)] + [TestCase(0.5f)] + [TestCase(1.25f)] + [TestCase(1.5f)] + public void TestSizeMultiplier(float sizeMultiplier) => CreateModTest(new ModTestData { Mod = new TaikoModFlashlight { SizeMultiplier = { Value = sizeMultiplier } }, PassCondition = () => true }); + + [Test] + public void TestComboBasedSize([Values] bool comboBasedSize) => CreateModTest(new ModTestData { Mod = new TaikoModFlashlight { ComboBasedSize = { Value = comboBasedSize } }, PassCondition = () => true }); + + [Test] + public void TestFlashlightAlwaysHasNonZeroSize() + { + bool failed = false; + + CreateModTest(new ModTestData + { + Mod = new TestTaikoModFlashlight { ComboBasedSize = { Value = true } }, + Autoplay = false, + PassCondition = () => + { + failed |= this.ChildrenOfType().SingleOrDefault()?.FlashlightSize.Y == 0; + return !failed; + } + }); + } + + private partial class TestTaikoModFlashlight : TaikoModFlashlight + { + protected override Flashlight CreateFlashlight() => new TestTaikoFlashlight(this, Playfield); + + public partial class TestTaikoFlashlight : TaikoFlashlight + { + public TestTaikoFlashlight(TaikoModFlashlight modFlashlight, TaikoPlayfield taikoPlayfield) + : base(modFlashlight, taikoPlayfield) + { + } + + public new Vector2 FlashlightSize => base.FlashlightSize; + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModHidden.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModHidden.cs index 7abbb9d186..edc53429b1 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModHidden.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModHidden.cs @@ -7,7 +7,7 @@ using osu.Game.Rulesets.Taiko.Mods; namespace osu.Game.Rulesets.Taiko.Tests.Mods { - public class TestSceneTaikoModHidden : TaikoModTestScene + public partial class TestSceneTaikoModHidden : TaikoModTestScene { [Test] public void TestDefaultBeatmapTest() => CreateModTest(new ModTestData diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModPerfect.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModPerfect.cs index a83cc16413..aed08f33e0 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModPerfect.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModPerfect.cs @@ -10,7 +10,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Taiko.Tests.Mods { - public class TestSceneTaikoModPerfect : ModPerfectTestScene + public partial class TestSceneTaikoModPerfect : ModPerfectTestScene { protected override Ruleset CreatePlayerRuleset() => new TestTaikoRuleset(); @@ -25,17 +25,17 @@ namespace osu.Game.Rulesets.Taiko.Tests.Mods [TestCase(false)] [TestCase(true)] - public void TestDrumRoll(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new DrumRoll { StartTime = 1000, EndTime = 3000 }), shouldMiss); + public void TestDrumRoll(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new DrumRoll { StartTime = 1000, EndTime = 3000 }, false), shouldMiss); [TestCase(false)] [TestCase(true)] - public void TestSwell(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new Swell { StartTime = 1000, EndTime = 3000 }), shouldMiss); + public void TestSwell(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new Swell { StartTime = 1000, EndTime = 3000 }, false), shouldMiss); - private class TestTaikoRuleset : TaikoRuleset + private partial class TestTaikoRuleset : TaikoRuleset { public override HealthProcessor CreateHealthProcessor(double drainStartTime) => new TestTaikoHealthProcessor(); - private class TestTaikoHealthProcessor : TaikoHealthProcessor + private partial class TestTaikoHealthProcessor : TaikoHealthProcessor { protected override void Reset(bool storeResults) { diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModSingleTap.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModSingleTap.cs new file mode 100644 index 0000000000..0cd3b85f8e --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModSingleTap.cs @@ -0,0 +1,212 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Taiko.Mods; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Replays; + +namespace osu.Game.Rulesets.Taiko.Tests.Mods +{ + public partial class TestSceneTaikoModSingleTap : TaikoModTestScene + { + [Test] + public void TestInputAlternate() => CreateModTest(new ModTestData + { + Mod = new TaikoModSingleTap(), + Autoplay = false, + Beatmap = new Beatmap + { + HitObjects = new List + { + new Hit + { + StartTime = 100, + Type = HitType.Rim + }, + new Hit + { + StartTime = 300, + Type = HitType.Rim + }, + new Hit + { + StartTime = 500, + Type = HitType.Rim + }, + new Hit + { + StartTime = 700, + Type = HitType.Rim + }, + }, + }, + ReplayFrames = new List + { + new TaikoReplayFrame(100, TaikoAction.RightRim), + new TaikoReplayFrame(120), + new TaikoReplayFrame(300, TaikoAction.LeftRim), + new TaikoReplayFrame(320), + new TaikoReplayFrame(500, TaikoAction.RightRim), + new TaikoReplayFrame(520), + new TaikoReplayFrame(700, TaikoAction.LeftRim), + new TaikoReplayFrame(720), + }, + PassCondition = () => Player.ScoreProcessor.Combo.Value == 0 && Player.ScoreProcessor.HighestCombo.Value == 1 + }); + + [Test] + public void TestInputSameKey() => CreateModTest(new ModTestData + { + Mod = new TaikoModSingleTap(), + Autoplay = false, + Beatmap = new Beatmap + { + HitObjects = new List + { + new Hit + { + StartTime = 100, + Type = HitType.Rim + }, + new Hit + { + StartTime = 300, + Type = HitType.Rim + }, + new Hit + { + StartTime = 500, + Type = HitType.Rim + }, + new Hit + { + StartTime = 700, + Type = HitType.Rim + }, + }, + }, + ReplayFrames = new List + { + new TaikoReplayFrame(100, TaikoAction.RightRim), + new TaikoReplayFrame(120), + new TaikoReplayFrame(300, TaikoAction.RightRim), + new TaikoReplayFrame(320), + new TaikoReplayFrame(500, TaikoAction.RightRim), + new TaikoReplayFrame(520), + new TaikoReplayFrame(700, TaikoAction.RightRim), + new TaikoReplayFrame(720), + }, + PassCondition = () => Player.ScoreProcessor.Combo.Value == 4 + }); + + [Test] + public void TestInputIntro() => CreateModTest(new ModTestData + { + Mod = new TaikoModSingleTap(), + Autoplay = false, + Beatmap = new Beatmap + { + HitObjects = new List + { + new Hit + { + StartTime = 100, + Type = HitType.Rim + }, + }, + }, + ReplayFrames = new List + { + new TaikoReplayFrame(0, TaikoAction.RightRim), + new TaikoReplayFrame(20), + new TaikoReplayFrame(100, TaikoAction.LeftRim), + new TaikoReplayFrame(120), + }, + PassCondition = () => Player.ScoreProcessor.Combo.Value == 1 + }); + + [Test] + public void TestInputStrong() => CreateModTest(new ModTestData + { + Mod = new TaikoModSingleTap(), + Autoplay = false, + Beatmap = new Beatmap + { + HitObjects = new List + { + new Hit + { + StartTime = 100, + Type = HitType.Rim + }, + new Hit + { + StartTime = 300, + Type = HitType.Rim, + IsStrong = true + }, + new Hit + { + StartTime = 500, + Type = HitType.Rim, + }, + }, + }, + ReplayFrames = new List + { + new TaikoReplayFrame(100, TaikoAction.RightRim), + new TaikoReplayFrame(120), + new TaikoReplayFrame(300, TaikoAction.LeftRim), + new TaikoReplayFrame(320), + new TaikoReplayFrame(500, TaikoAction.LeftRim), + new TaikoReplayFrame(520), + }, + PassCondition = () => Player.ScoreProcessor.Combo.Value == 0 && Player.ScoreProcessor.HighestCombo.Value == 2 + }); + + [Test] + public void TestInputBreaks() => CreateModTest(new ModTestData + { + Mod = new TaikoModSingleTap(), + Autoplay = false, + Beatmap = new Beatmap + { + Breaks = new List + { + new BreakPeriod(100, 1600), + }, + HitObjects = new List + { + new Hit + { + StartTime = 100, + Type = HitType.Rim + }, + new Hit + { + StartTime = 2000, + Type = HitType.Rim, + }, + }, + }, + ReplayFrames = new List + { + new TaikoReplayFrame(100, TaikoAction.RightRim), + new TaikoReplayFrame(120), + // Press different key after break but before hit object. + new TaikoReplayFrame(1900, TaikoAction.LeftRim), + new TaikoReplayFrame(1820), + // Press original key at second hitobject and ensure it has been hit. + new TaikoReplayFrame(2000, TaikoAction.RightRim), + new TaikoReplayFrame(2020), + }, + PassCondition = () => Player.ScoreProcessor.Combo.Value == 2 + }); + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TaikoSkinnableTestScene.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TaikoSkinnableTestScene.cs index 69250a14e1..38530282b7 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TaikoSkinnableTestScene.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TaikoSkinnableTestScene.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Taiko.Tests.Skinning { - public abstract class TaikoSkinnableTestScene : SkinnableTestScene + public abstract partial class TaikoSkinnableTestScene : SkinnableTestScene { protected override Ruleset CreateRulesetForSkinProvider() => new TaikoRuleset(); } diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs index 16c4148d15..7fd90685e3 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -16,7 +18,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Taiko.Tests.Skinning { [TestFixture] - public class TestSceneDrawableBarLine : TaikoSkinnableTestScene + public partial class TestSceneDrawableBarLine : TaikoSkinnableTestScene { [Cached(typeof(IScrollingInfo))] private ScrollingTestContainer.TestScrollingInfo info = new ScrollingTestContainer.TestScrollingInfo @@ -35,7 +37,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning var cont = new Container { RelativeSizeAxes = Axes.Both, - Height = 0.8f, + Height = 0.2f, Anchor = Anchor.Centre, Origin = Anchor.Centre, Children = new Drawable[] @@ -61,7 +63,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning var cont = new Container { RelativeSizeAxes = Axes.Both, - Height = 0.8f, + Height = 0.2f, Anchor = Anchor.Centre, Origin = Anchor.Centre, Children = new Drawable[] @@ -86,7 +88,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning var barLine = new BarLine { Major = major, - StartTime = Time.Current + 2000, + StartTime = Time.Current + 5000, }; var cpi = new ControlPointInfo(); diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRoll.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRoll.cs index 26a4e85fe5..3b2f4f2fb2 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRoll.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -14,7 +16,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Taiko.Tests.Skinning { [TestFixture] - public class TestSceneDrawableDrumRoll : TaikoSkinnableTestScene + public partial class TestSceneDrawableDrumRoll : TaikoSkinnableTestScene { [Cached(typeof(IScrollingInfo))] private ScrollingTestContainer.TestScrollingInfo info = new ScrollingTestContainer.TestScrollingInfo @@ -23,9 +25,11 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning TimeRange = { Value = 5000 }, }; - [BackgroundDependencyLoader] - private void load() + [Test] + public void TestDrumroll([Values] bool withKiai) { + AddStep("set up beatmap", () => setUpBeatmap(withKiai)); + AddStep("Drum roll", () => SetContents(_ => { var hoc = new ScrollingHitObjectContainer(); @@ -71,5 +75,22 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning return drumroll; } + + private void setUpBeatmap(bool withKiai) + { + var controlPointInfo = new ControlPointInfo(); + + controlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 }); + + if (withKiai) + controlPointInfo.Add(0, new EffectControlPoint { KiaiMode = true }); + + Beatmap.Value = CreateWorkingBeatmap(new Beatmap + { + ControlPointInfo = controlPointInfo + }); + + Beatmap.Value.Track.Start(); + } } } diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHit.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHit.cs index c4ee68206c..9567eac80f 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHit.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHit.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -8,14 +10,45 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osu.Game.Screens.Play; +using osu.Game.Tests.Gameplay; namespace osu.Game.Rulesets.Taiko.Tests.Skinning { [TestFixture] - public class TestSceneDrawableHit : TaikoSkinnableTestScene + public partial class TestSceneDrawableHit : TaikoSkinnableTestScene { - [BackgroundDependencyLoader] - private void load() + [Cached] + private GameplayState gameplayState = TestGameplayState.Create(new TaikoRuleset()); + + [Test] + public void TestHits([Values] bool withKiai) + { + AddStep("Create beatmap", () => setUpBeatmap(withKiai)); + addHitSteps(); + } + + [Test] + public void TestHitAnimationSlow() + { + AddStep("Create beatmap", () => setUpBeatmap(false)); + + AddStep("Set 50 combo", () => gameplayState.ScoreProcessor.Combo.Value = 50); + addHitSteps(); + AddStep("Reset combo", () => gameplayState.ScoreProcessor.Combo.Value = 0); + } + + [Test] + public void TestHitAnimationFast() + { + AddStep("Create beatmap", () => setUpBeatmap(false)); + + AddStep("Set 150 combo", () => gameplayState.ScoreProcessor.Combo.Value = 150); + addHitSteps(); + AddStep("Reset combo", () => gameplayState.ScoreProcessor.Combo.Value = 0); + } + + private void addHitSteps() { AddStep("Centre hit", () => SetContents(_ => new DrawableHit(createHitAtCurrentTime()) { @@ -29,23 +62,24 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning Origin = Anchor.Centre, })); - AddStep("Rim hit", () => SetContents(_ => new DrawableHit(createHitAtCurrentTime()) + AddStep("Rim hit", () => SetContents(_ => new DrawableHit(createHitAtCurrentTime(rim: true)) { Anchor = Anchor.Centre, Origin = Anchor.Centre, })); - AddStep("Rim hit (strong)", () => SetContents(_ => new DrawableHit(createHitAtCurrentTime(true)) + AddStep("Rim hit (strong)", () => SetContents(_ => new DrawableHit(createHitAtCurrentTime(true, true)) { Anchor = Anchor.Centre, Origin = Anchor.Centre, })); } - private Hit createHitAtCurrentTime(bool strong = false) + private Hit createHitAtCurrentTime(bool strong = false, bool rim = false) { var hit = new Hit { + Type = rim ? HitType.Rim : HitType.Centre, IsStrong = strong, StartTime = Time.Current + 3000, }; @@ -54,5 +88,22 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning return hit; } + + private void setUpBeatmap(bool withKiai) + { + var controlPointInfo = new ControlPointInfo(); + + controlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 }); + + if (withKiai) + controlPointInfo.Add(0, new EffectControlPoint { KiaiMode = true }); + + Beatmap.Value = CreateWorkingBeatmap(new Beatmap + { + ControlPointInfo = controlPointInfo + }); + + Beatmap.Value.Track.Start(); + } } } diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableSwell.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableSwell.cs new file mode 100644 index 0000000000..c130b5f366 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableSwell.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; + +namespace osu.Game.Rulesets.Taiko.Tests.Skinning +{ + [TestFixture] + public partial class TestSceneDrawableSwell : TaikoSkinnableTestScene + { + [Test] + public void TestHits() + { + AddStep("Centre hit", () => SetContents(_ => new DrawableSwell(createHitAtCurrentTime()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + })); + } + + private Swell createHitAtCurrentTime() + { + var hit = new Swell + { + StartTime = Time.Current + 3000, + EndTime = Time.Current + 6000, + }; + + hit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + return hit; + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs index 920a7cd1a1..e4e68c7207 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs @@ -25,7 +25,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Taiko.Tests.Skinning { [TestFixture] - public class TestSceneDrawableTaikoMascot : TaikoSkinnableTestScene + public partial class TestSceneDrawableTaikoMascot : TaikoSkinnableTestScene { [Cached(typeof(IScrollingInfo))] private ScrollingTestContainer.TestScrollingInfo info = new ScrollingTestContainer.TestScrollingInfo @@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning TimeRange = { Value = 5000 }, }; - private TaikoScoreProcessor scoreProcessor; + private TaikoScoreProcessor scoreProcessor = null!; private IEnumerable mascots => this.ChildrenOfType(); @@ -63,6 +63,8 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [Test] public void TestInitialState() { + AddStep("set beatmap", () => setBeatmap()); + AddStep("create mascot", () => SetContents(_ => new DrawableTaikoMascot { RelativeSizeAxes = Axes.Both })); AddAssert("mascot initially idle", () => allMascotsIn(TaikoMascotAnimationState.Idle)); @@ -87,9 +89,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [Test] public void TestIdleState() { - AddStep("set beatmap", () => setBeatmap()); - - createDrawableRuleset(); + 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); @@ -98,9 +98,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [Test] public void TestKiaiState() { - AddStep("set beatmap", () => setBeatmap(true)); - - createDrawableRuleset(); + prepareDrawableRulesetAndBeatmap(true); assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Ok }, TaikoMascotAnimationState.Kiai); assertStateAfterResult(new JudgementResult(new Hit(), new TaikoStrongJudgement()) { Type = HitResult.IgnoreMiss }, TaikoMascotAnimationState.Kiai); @@ -110,13 +108,10 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [Test] public void TestMissState() { - AddStep("set beatmap", () => setBeatmap()); - - createDrawableRuleset(); + prepareDrawableRulesetAndBeatmap(false); assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Idle); assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Miss }, TaikoMascotAnimationState.Fail); - assertStateAfterResult(new JudgementResult(new DrumRoll(), new TaikoDrumRollJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Idle); assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Ok }, TaikoMascotAnimationState.Idle); } @@ -124,9 +119,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [TestCase(false)] public void TestClearStateOnComboMilestone(bool kiai) { - AddStep("set beatmap", () => setBeatmap(kiai)); - - createDrawableRuleset(); + prepareDrawableRulesetAndBeatmap(kiai); AddRepeatStep("reach 49 combo", () => applyNewResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Great }), 49); @@ -137,12 +130,10 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [TestCase(false, TaikoMascotAnimationState.Idle)] public void TestClearStateOnClearedSwell(bool kiai, TaikoMascotAnimationState expectedStateAfterClear) { - AddStep("set beatmap", () => setBeatmap(kiai)); - - createDrawableRuleset(); + prepareDrawableRulesetAndBeatmap(kiai); assertStateAfterResult(new JudgementResult(new Swell(), new TaikoSwellJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Clear); - AddUntilStep($"state reverts to {expectedStateAfterClear.ToString().ToLower()}", () => allMascotsIn(expectedStateAfterClear)); + AddUntilStep($"state reverts to {expectedStateAfterClear.ToString().ToLowerInvariant()}", () => allMascotsIn(expectedStateAfterClear)); } private void setBeatmap(bool kiai = false) @@ -173,27 +164,29 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning scoreProcessor.ApplyBeatmap(Beatmap.Value.Beatmap); } - private void createDrawableRuleset() + private void prepareDrawableRulesetAndBeatmap(bool kiai) { - AddUntilStep("wait for beatmap to be loaded", () => Beatmap.Value.Track.IsLoaded); + AddStep("set beatmap", () => setBeatmap(kiai)); AddStep("create drawable ruleset", () => { - Beatmap.Value.Track.Start(); - SetContents(_ => { var ruleset = new TaikoRuleset(); return new DrawableTaikoRuleset(ruleset, Beatmap.Value.GetPlayableBeatmap(ruleset.RulesetInfo)); }); }); + + AddUntilStep("wait for track to be loaded", () => MusicController.TrackLoaded); + AddStep("start track", () => MusicController.CurrentTrack.Restart()); + AddUntilStep("wait for track started", () => MusicController.IsPlaying); } private void assertStateAfterResult(JudgementResult judgementResult, TaikoMascotAnimationState expectedState) { - TaikoMascotAnimationState[] mascotStates = null; + TaikoMascotAnimationState[] mascotStates = null!; - AddStep($"{judgementResult.Type.ToString().ToLower()} result for {judgementResult.Judgement.GetType().Name.Humanize(LetterCasing.LowerCase)}", + AddStep($"{judgementResult.Type.ToString().ToLowerInvariant()} result for {judgementResult.Judgement.GetType().Name.Humanize(LetterCasing.LowerCase)}", () => { applyNewResult(judgementResult); @@ -202,7 +195,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning Schedule(() => mascotStates = animatedMascots.Select(mascot => mascot.State.Value).ToArray()); }); - AddAssert($"state is {expectedState.ToString().ToLower()}", () => mascotStates.All(state => state == expectedState)); + AddAssert($"state is {expectedState.ToString().ToLowerInvariant()}", () => mascotStates.Distinct(), () => Is.EquivalentTo(new[] { expectedState })); } private void applyNewResult(JudgementResult judgementResult) diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs index 1cba6c9008..924f903ce9 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; @@ -11,11 +13,12 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Screens.Ranking; namespace osu.Game.Rulesets.Taiko.Tests.Skinning { [TestFixture] - public class TestSceneHitExplosion : TaikoSkinnableTestScene + public partial class TestSceneHitExplosion : TaikoSkinnableTestScene { protected override double TimePerAction => 100; @@ -47,11 +50,19 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning // the hit needs to be added to hierarchy in order for nested objects to be created correctly. // setting zero alpha is supposed to prevent the test from looking broken. hit.With(h => h.Alpha = 0), - new HitExplosion(hit.Type) + + new AspectContainer { + RelativeSizeAxes = Axes.X, Anchor = Anchor.Centre, Origin = Anchor.Centre, - }.With(explosion => explosion.Apply(hit)) + Child = + new HitExplosion(hit.Type) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }.With(explosion => explosion.Apply(hit)) + } } }; } diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs index 24db046748..cb5d0d1f91 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osuTK; namespace osu.Game.Rulesets.Taiko.Tests.Skinning { [TestFixture] - public class TestSceneInputDrum : TaikoSkinnableTestScene + public partial class TestSceneInputDrum : TaikoSkinnableTestScene { [BackgroundDependencyLoader] private void load() @@ -31,7 +33,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(200), - Child = new InputDrum(playfield.HitObjectContainer) + Child = new InputDrum() } }); } diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneKiaiHitExplosion.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneKiaiHitExplosion.cs index 419e100296..5f98f2f27a 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneKiaiHitExplosion.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneKiaiHitExplosion.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,7 +12,7 @@ using osu.Game.Rulesets.Taiko.UI; namespace osu.Game.Rulesets.Taiko.Tests.Skinning { [TestFixture] - public class TestSceneKiaiHitExplosion : TaikoSkinnableTestScene + public partial class TestSceneKiaiHitExplosion : TaikoSkinnableTestScene { [Test] public void TestKiaiHits() diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoKiaiGlow.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoKiaiGlow.cs new file mode 100644 index 0000000000..a5e2eb0dbb --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoKiaiGlow.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Taiko.Skinning.Legacy; + +namespace osu.Game.Rulesets.Taiko.Tests.Skinning +{ + public partial class TestSceneTaikoKiaiGlow : TaikoSkinnableTestScene + { + [Test] + public void TestKiaiGlow() + { + AddStep("Create kiai glow", () => SetContents(_ => new LegacyKiaiGlow())); + AddToggleStep("Toggle kiai mode", setUpBeatmap); + } + + private void setUpBeatmap(bool withKiai) + { + var controlPointInfo = new ControlPointInfo(); + + controlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 }); + + if (withKiai) + controlPointInfo.Add(0, new EffectControlPoint { KiaiMode = true }); + + Beatmap.Value = CreateWorkingBeatmap(new Beatmap + { + ControlPointInfo = controlPointInfo + }); + + Beatmap.Value.Track.Start(); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs index 6f2fcd08f1..eb2762cb2d 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs @@ -1,7 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; +using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; @@ -14,7 +17,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Taiko.Tests.Skinning { - public class TestSceneTaikoPlayfield : TaikoSkinnableTestScene + public partial class TestSceneTaikoPlayfield : TaikoSkinnableTestScene { [Cached(typeof(IScrollingInfo))] private ScrollingTestContainer.TestScrollingInfo info = new ScrollingTestContainer.TestScrollingInfo @@ -23,11 +26,10 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning TimeRange = { Value = 5000 }, }; - public TestSceneTaikoPlayfield() + [SetUpSteps] + public void SetUpSteps() { TaikoBeatmap beatmap; - bool kiai = false; - AddStep("set beatmap", () => { Beatmap.Value = CreateWorkingBeatmap(beatmap = new TaikoBeatmap()); @@ -39,12 +41,28 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning AddStep("Load playfield", () => SetContents(_ => new TaikoPlayfield { + Height = 0.2f, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Height = 0.6f, })); + } + [Test] + public void TestBasic() + { + AddStep("do nothing", () => { }); + } + + [Test] + public void TestHeightChanges() + { AddRepeatStep("change height", () => this.ChildrenOfType().ForEach(p => p.Height = Math.Max(0.2f, (p.Height + 0.2f) % 1f)), 50); + } + + [Test] + public void TestKiai() + { + bool kiai = false; AddStep("Toggle kiai", () => { diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoScroller.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoScroller.cs index 9882c7bc90..826cf2acab 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoScroller.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoScroller.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Testing; using osu.Framework.Timing; @@ -12,7 +14,7 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.Tests.Skinning { - public class TestSceneTaikoScroller : TaikoSkinnableTestScene + public partial class TestSceneTaikoScroller : TaikoSkinnableTestScene { private readonly ManualClock clock = new ManualClock(); @@ -21,7 +23,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning public TestSceneTaikoScroller() { AddStep("Load scroller", () => SetContents(_ => - new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.Scroller), _ => Empty()) + new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.Scroller), _ => Empty()) { Clock = new FramedClock(clock), Height = 0.4f, diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoBeatmapConversionTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoBeatmapConversionTest.cs index b3f6a733d3..781a686700 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TaikoBeatmapConversionTest.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TaikoBeatmapConversionTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using NUnit.Framework; @@ -23,6 +25,7 @@ namespace osu.Game.Rulesets.Taiko.Tests [TestCase("slider-conversion-v6")] [TestCase("slider-conversion-v14")] [TestCase("slider-generating-drumroll-2")] + [TestCase("file-hitsamples")] public void Test(string name) => base.Test(name); protected override IEnumerable CreateConvertValue(HitObject hitObject) diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs index 51332a1ece..5685ac0f60 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; @@ -14,13 +16,13 @@ namespace osu.Game.Rulesets.Taiko.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko"; - [TestCase(1.9971301024093662d, 200, "diffcalc-test")] - [TestCase(1.9971301024093662d, 200, "diffcalc-test-strong")] + [TestCase(3.0920212594351191d, 200, "diffcalc-test")] + [TestCase(3.0920212594351191d, 200, "diffcalc-test-strong")] public void Test(double expectedStarRating, int expectedMaxCombo, string name) => base.Test(expectedStarRating, expectedMaxCombo, name); - [TestCase(3.1645810961313674d, 200, "diffcalc-test")] - [TestCase(3.1645810961313674d, 200, "diffcalc-test-strong")] + [TestCase(4.0789820318081444d, 200, "diffcalc-test")] + [TestCase(4.0789820318081444d, 200, "diffcalc-test-strong")] public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new TaikoModDoubleTime()); diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoLegacyModConversionTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoLegacyModConversionTest.cs index a039e84106..c86f8cb8d2 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TaikoLegacyModConversionTest.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TaikoLegacyModConversionTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Game.Beatmaps.Legacy; @@ -22,22 +24,25 @@ namespace osu.Game.Rulesets.Taiko.Tests new object[] { LegacyMods.DoubleTime, new[] { typeof(TaikoModDoubleTime) } }, new object[] { LegacyMods.Relax, new[] { typeof(TaikoModRelax) } }, new object[] { LegacyMods.HalfTime, new[] { typeof(TaikoModHalfTime) } }, - new object[] { LegacyMods.Nightcore, new[] { typeof(TaikoModNightcore) } }, new object[] { LegacyMods.Flashlight, new[] { typeof(TaikoModFlashlight) } }, new object[] { LegacyMods.Autoplay, new[] { typeof(TaikoModAutoplay) } }, - new object[] { LegacyMods.Perfect, new[] { typeof(TaikoModPerfect) } }, new object[] { LegacyMods.Random, new[] { typeof(TaikoModRandom) } }, - new object[] { LegacyMods.Cinema, new[] { typeof(TaikoModCinema) } }, new object[] { LegacyMods.HardRock | LegacyMods.DoubleTime, new[] { typeof(TaikoModHardRock), typeof(TaikoModDoubleTime) } } }; + [TestCaseSource(nameof(taiko_mod_mapping))] + [TestCase(LegacyMods.Cinema, new[] { typeof(TaikoModCinema) })] + [TestCase(LegacyMods.Cinema | LegacyMods.Autoplay, new[] { typeof(TaikoModCinema) })] + [TestCase(LegacyMods.Nightcore, new[] { typeof(TaikoModNightcore) })] + [TestCase(LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(TaikoModNightcore) })] + [TestCase(LegacyMods.Perfect, new[] { typeof(TaikoModPerfect) })] + [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath, new[] { typeof(TaikoModPerfect) })] + public new void TestFromLegacy(LegacyMods legacyMods, Type[] expectedMods) => base.TestFromLegacy(legacyMods, expectedMods); + [TestCaseSource(nameof(taiko_mod_mapping))] [TestCase(LegacyMods.Cinema | LegacyMods.Autoplay, new[] { typeof(TaikoModCinema) })] [TestCase(LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(TaikoModNightcore) })] [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath, new[] { typeof(TaikoModPerfect) })] - public new void TestFromLegacy(LegacyMods legacyMods, Type[] expectedMods) => base.TestFromLegacy(legacyMods, expectedMods); - - [TestCaseSource(nameof(taiko_mod_mapping))] public new void TestToLegacy(LegacyMods legacyMods, Type[] givenMods) => base.TestToLegacy(legacyMods, givenMods); protected override Ruleset CreateRuleset() => new TaikoRuleset(); diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineApplication.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineApplication.cs index f33c738b04..00292d5473 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineApplication.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineApplication.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Objects.Drawables; namespace osu.Game.Rulesets.Taiko.Tests { - public class TestSceneBarLineApplication : HitObjectApplicationTestScene + public partial class TestSceneBarLineApplication : HitObjectApplicationTestScene { [Test] public void TestApplyNewBarLine() diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineGeneration.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineGeneration.cs new file mode 100644 index 0000000000..8511ad9f00 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineGeneration.cs @@ -0,0 +1,122 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + public partial class TestSceneBarLineGeneration : OsuTestScene + { + [Test] + public void TestCloseBarLineGeneration() + { + const double start_time = 1000; + + var beatmap = new Beatmap + { + HitObjects = + { + new Hit + { + Type = HitType.Centre, + StartTime = start_time + } + }, + BeatmapInfo = + { + Difficulty = new BeatmapDifficulty { SliderTickRate = 4 }, + Ruleset = new TaikoRuleset().RulesetInfo + }, + }; + + beatmap.ControlPointInfo.Add(start_time, new TimingControlPoint()); + beatmap.ControlPointInfo.Add(start_time + 1, new TimingControlPoint()); + + var barlines = new BarLineGenerator(beatmap).BarLines; + + AddAssert("first barline generated", () => barlines.Any(b => b.StartTime == start_time)); + AddAssert("second barline generated", () => barlines.Any(b => b.StartTime == start_time + 1)); + } + + [Test] + public void TestOmitBarLineEffectPoint() + { + const double start_time = 1000; + const double beat_length = 500; + + const int time_signature_numerator = 4; + + var beatmap = new Beatmap + { + HitObjects = + { + new Hit + { + Type = HitType.Centre, + StartTime = start_time + } + }, + BeatmapInfo = + { + Difficulty = new BeatmapDifficulty { SliderTickRate = 4 }, + Ruleset = new TaikoRuleset().RulesetInfo + }, + }; + + beatmap.ControlPointInfo.Add(start_time, new TimingControlPoint + { + BeatLength = beat_length, + TimeSignature = new TimeSignature(time_signature_numerator), + OmitFirstBarLine = true + }); + + var barlines = new BarLineGenerator(beatmap).BarLines; + + AddAssert("first barline ommited", () => barlines.All(b => b.StartTime != start_time)); + AddAssert("second barline generated", () => barlines.Any(b => b.StartTime == start_time + (beat_length * time_signature_numerator))); + } + + [Test] + public void TestNegativeStartTimeTimingPoint() + { + const double beat_length = 250; + + const int time_signature_numerator = 4; + + var beatmap = new Beatmap + { + HitObjects = + { + new Hit + { + Type = HitType.Centre, + StartTime = 1000 + } + }, + BeatmapInfo = + { + Difficulty = new BeatmapDifficulty { SliderTickRate = 4 }, + Ruleset = new TaikoRuleset().RulesetInfo + }, + }; + + beatmap.ControlPointInfo.Add(-100, new TimingControlPoint + { + BeatLength = beat_length, + TimeSignature = new TimeSignature(time_signature_numerator) + }); + + var barlines = new BarLineGenerator(beatmap).BarLines; + + AddAssert("bar line generated at t=900", () => barlines.Any(line => line.StartTime == 900)); + AddAssert("bar line generated at t=1900", () => barlines.Any(line => line.StartTime == 1900)); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumRollApplication.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumRollApplication.cs index c389a05566..b01bd11149 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumRollApplication.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumRollApplication.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Objects.Drawables; namespace osu.Game.Rulesets.Taiko.Tests { - public class TestSceneDrumRollApplication : HitObjectApplicationTestScene + public partial class TestSceneDrumRollApplication : HitObjectApplicationTestScene { [Test] public void TestApplyNewDrumRoll() diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumRollJudgements.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumRollJudgements.cs deleted file mode 100644 index 060c3c9443..0000000000 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumRollJudgements.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Linq; -using NUnit.Framework; -using osu.Game.Beatmaps; -using osu.Game.Rulesets.Taiko.Objects; - -namespace osu.Game.Rulesets.Taiko.Tests -{ - public class TestSceneDrumRollJudgements : TestSceneTaikoPlayer - { - [Test] - public void TestStrongDrumRollFullyJudgedOnKilled() - { - AddUntilStep("gameplay finished", () => Player.ScoreProcessor.HasCompleted.Value); - AddAssert("all judgements are misses", () => Player.Results.All(r => r.Type == r.Judgement.MinResult)); - } - - protected override bool Autoplay => false; - - protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new Beatmap - { - BeatmapInfo = { Ruleset = new TaikoRuleset().RulesetInfo }, - HitObjects = - { - new DrumRoll - { - StartTime = 1000, - Duration = 1000, - IsStrong = true - } - } - }; - } -} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumTouchInputArea.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumTouchInputArea.cs new file mode 100644 index 0000000000..5fb85df82b --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumTouchInputArea.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Taiko.Configuration; +using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + [TestFixture] + public partial class TestSceneDrumTouchInputArea : OsuTestScene + { + private DrumTouchInputArea drumTouchInputArea = null!; + + private readonly Bindable controlScheme = new Bindable(); + + [BackgroundDependencyLoader] + private void load() + { + var config = (TaikoRulesetConfigManager)RulesetConfigs.GetConfigFor(Ruleset.Value.CreateInstance()).AsNonNull(); + config.BindWith(TaikoRulesetSetting.TouchControlScheme, controlScheme); + } + + private void createDrum() + { + Child = new TaikoInputManager(new TaikoRuleset().RulesetInfo) + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new InputDrum + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Height = 0.2f, + }, + drumTouchInputArea = new DrumTouchInputArea + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + } + } + }; + } + + [Test] + public void TestDrum() + { + AddStep("create drum", createDrum); + AddStep("show drum", () => drumTouchInputArea.Show()); + + AddStep("change scheme (kddk)", () => controlScheme.Value = TaikoTouchControlScheme.KDDK); + AddStep("change scheme (kkdd)", () => controlScheme.Value = TaikoTouchControlScheme.KKDD); + AddStep("change scheme (ddkk)", () => controlScheme.Value = TaikoTouchControlScheme.DDKK); + } + + protected override Ruleset CreateRuleset() => new TaikoRuleset(); + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneFlyingHits.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneFlyingHits.cs index 5c7e3954e8..e0ff617b59 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneFlyingHits.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneFlyingHits.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Testing; @@ -14,7 +16,7 @@ using osu.Game.Rulesets.Taiko.UI; namespace osu.Game.Rulesets.Taiko.Tests { [TestFixture] - public class TestSceneFlyingHits : DrawableTaikoRulesetTestScene + public partial class TestSceneFlyingHits : DrawableTaikoRulesetTestScene { [TestCase(HitType.Centre)] [TestCase(HitType.Rim)] diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneHitApplication.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneHitApplication.cs index c2f251fcb6..301620edc9 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneHitApplication.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneHitApplication.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Objects.Drawables; namespace osu.Game.Rulesets.Taiko.Tests { - public class TestSceneHitApplication : HitObjectApplicationTestScene + public partial class TestSceneHitApplication : HitObjectApplicationTestScene { [Test] public void TestApplyNewHit() diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs index 87c936d386..91209e5ec5 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -21,7 +23,7 @@ using osuTK; namespace osu.Game.Rulesets.Taiko.Tests { [TestFixture] - public class TestSceneHits : DrawableTaikoRulesetTestScene + public partial class TestSceneHits : DrawableTaikoRulesetTestScene { private const double default_duration = 3000; private const float scroll_time = 1000; diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneSampleOutput.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneSampleOutput.cs index e5c9358c26..dcdda6014c 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneSampleOutput.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneSampleOutput.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Testing; @@ -14,7 +16,7 @@ namespace osu.Game.Rulesets.Taiko.Tests /// Taiko has some interesting rules for legacy mappings. /// [HeadlessTest] - public class TestSceneSampleOutput : TestSceneTaikoPlayer + public partial class TestSceneSampleOutput : TestSceneTaikoPlayer { public override void SetUpSteps() { diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneSwellJudgements.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneSwellJudgements.cs deleted file mode 100644 index 75049b7467..0000000000 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneSwellJudgements.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Linq; -using NUnit.Framework; -using osu.Game.Beatmaps; -using osu.Game.Rulesets.Taiko.Objects; - -namespace osu.Game.Rulesets.Taiko.Tests -{ - public class TestSceneSwellJudgements : TestSceneTaikoPlayer - { - [Test] - public void TestZeroTickTimeOffsets() - { - AddUntilStep("gameplay finished", () => Player.ScoreProcessor.HasCompleted.Value); - AddAssert("all tick offsets are 0", () => Player.Results.Where(r => r.HitObject is SwellTick).All(r => r.TimeOffset == 0)); - } - - protected override bool Autoplay => true; - - protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) - { - var beatmap = new Beatmap - { - BeatmapInfo = { Ruleset = new TaikoRuleset().RulesetInfo }, - HitObjects = - { - new Swell - { - StartTime = 1000, - Duration = 1000, - } - } - }; - - return beatmap; - } - } -} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs index 221d715a35..1d1e82fb07 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs @@ -8,14 +8,13 @@ using osu.Game.Tests.Beatmaps; namespace osu.Game.Rulesets.Taiko.Tests { - public class TestSceneTaikoHitObjectSamples : HitObjectSampleTest + public partial class TestSceneTaikoHitObjectSamples : HitObjectSampleTest { protected override Ruleset CreatePlayerRuleset() => new TaikoRuleset(); protected override IResourceStore RulesetResources => new DllResourceStore(Assembly.GetAssembly(typeof(TestSceneTaikoHitObjectSamples))); [TestCase("taiko-normal-hitnormal")] - [TestCase("normal-hitnormal")] [TestCase("hitnormal")] public void TestDefaultCustomSampleFromBeatmap(string expectedSample) { @@ -27,7 +26,6 @@ namespace osu.Game.Rulesets.Taiko.Tests } [TestCase("taiko-normal-hitnormal")] - [TestCase("normal-hitnormal")] [TestCase("hitnormal")] public void TestDefaultCustomSampleFromUserSkinFallback(string expectedSample) { @@ -39,7 +37,6 @@ namespace osu.Game.Rulesets.Taiko.Tests } [TestCase("taiko-normal-hitnormal2")] - [TestCase("normal-hitnormal2")] public void TestUserSkinLookupIgnoresSampleBank(string unwantedSample) { SetupSkins(string.Empty, unwantedSample); diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayer.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayer.cs index cd7511241a..8c903f748c 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayer.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayer.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Taiko.Tests { - public class TestSceneTaikoPlayer : PlayerTestScene + public partial class TestSceneTaikoPlayer : PlayerTestScene { protected override Ruleset CreatePlayerRuleset() => new TaikoRuleset(); } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuFlashlight.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayerLegacySkin.cs similarity index 52% rename from osu.Game.Rulesets.Osu.Tests/TestSceneOsuFlashlight.cs rename to osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayerLegacySkin.cs index 19736a7709..39776229fa 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuFlashlight.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayerLegacySkin.cs @@ -1,18 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Tests.Visual; -namespace osu.Game.Rulesets.Osu.Tests +namespace osu.Game.Rulesets.Taiko.Tests { - public class TestSceneOsuFlashlight : TestSceneOsuPlayer + public partial class TestSceneTaikoPlayerLegacySkin : LegacySkinPlayerTestScene { + protected override Ruleset CreatePlayerRuleset() => new TaikoRuleset(); + protected override TestPlayer CreatePlayer(Ruleset ruleset) { - SelectedMods.Value = new Mod[] { new OsuModAutoplay(), new OsuModFlashlight(), }; - + SelectedMods.Value = new[] { new TaikoModClassic() }; return base.CreatePlayer(ruleset); } } diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoSuddenDeath.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoSuddenDeath.cs index eec88d7bf8..08c06b08f2 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoSuddenDeath.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoSuddenDeath.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Game.Beatmaps; @@ -11,7 +13,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Taiko.Tests { - public class TestSceneTaikoSuddenDeath : TestSceneTaikoPlayer + public partial class TestSceneTaikoSuddenDeath : TestSceneTaikoPlayer { protected override bool AllowFail => true; @@ -36,16 +38,16 @@ namespace osu.Game.Rulesets.Taiko.Tests }; [Test] - public void TestSpinnerDoesFail() + public void TestSwellDoesNotFail() { bool judged = false; AddStep("Setup judgements", () => { judged = false; - Player.ScoreProcessor.NewJudgement += b => judged = true; + Player.ScoreProcessor.NewJudgement += _ => judged = true; }); AddUntilStep("swell judged", () => judged); - AddAssert("failed", () => Player.GameplayState.HasFailed); + AddAssert("not failed", () => !Player.GameplayState.HasFailed); } } } diff --git a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj index ce468d399b..0c39ad988b 100644 --- a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj +++ b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj @@ -1,11 +1,9 @@  - - + - - + WinExe diff --git a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmap.cs b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmap.cs index 41fe63a553..6ff5cdf7e5 100644 --- a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmap.cs +++ b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmap.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; diff --git a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs index b1d8575de4..6a35e9376b 100644 --- a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs +++ b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; @@ -55,6 +57,27 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps Beatmap converted = base.ConvertBeatmap(original, cancellationToken); + if (original.BeatmapInfo.Ruleset.OnlineID == 0) + { + // Post processing step to transform standard slider velocity changes into scroll speed changes + double lastScrollSpeed = 1; + + foreach (HitObject hitObject in original.HitObjects) + { + double nextScrollSpeed = hitObject.DifficultyControlPoint.SliderVelocity; + EffectControlPoint currentEffectPoint = converted.ControlPointInfo.EffectPointAt(hitObject.StartTime); + + if (!Precision.AlmostEquals(lastScrollSpeed, nextScrollSpeed, acceptableDifference: currentEffectPoint.ScrollSpeedBindable.Precision)) + { + converted.ControlPointInfo.Add(hitObject.StartTime, new EffectControlPoint + { + KiaiMode = currentEffectPoint.KiaiMode, + ScrollSpeed = lastScrollSpeed = nextScrollSpeed, + }); + } + } + } + if (original.BeatmapInfo.Ruleset.OnlineID == 3) { // Post processing step to transform mania hit objects with the same start time into strong hits diff --git a/osu.Game.Rulesets.Taiko/Configuration/TaikoRulesetConfigManager.cs b/osu.Game.Rulesets.Taiko/Configuration/TaikoRulesetConfigManager.cs new file mode 100644 index 0000000000..c3bc7f6439 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Configuration/TaikoRulesetConfigManager.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Configuration; +using osu.Game.Rulesets.Configuration; + +namespace osu.Game.Rulesets.Taiko.Configuration +{ + public class TaikoRulesetConfigManager : RulesetConfigManager + { + public TaikoRulesetConfigManager(SettingsStore? settings, RulesetInfo ruleset, int? variant = null) + : base(settings, ruleset, variant) + { + } + + protected override void InitialiseDefaults() + { + base.InitialiseDefaults(); + + SetDefault(TaikoRulesetSetting.TouchControlScheme, TaikoTouchControlScheme.KDDK); + } + } + + public enum TaikoRulesetSetting + { + TouchControlScheme + } +} diff --git a/osu.Game.Rulesets.Taiko/Configuration/TaikoTouchControlScheme.cs b/osu.Game.Rulesets.Taiko/Configuration/TaikoTouchControlScheme.cs new file mode 100644 index 0000000000..74e4a53746 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Configuration/TaikoTouchControlScheme.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Rulesets.Taiko.Configuration +{ + public enum TaikoTouchControlScheme + { + KDDK, + DDKK, + KKDD + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs new file mode 100644 index 0000000000..9f63e84867 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs @@ -0,0 +1,67 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators +{ + public class ColourEvaluator + { + /// + /// A sigmoid function. It gives a value between (middle - height/2) and (middle + height/2). + /// + /// The input value. + /// The center of the sigmoid, where the largest gradient occurs and value is equal to middle. + /// The radius of the sigmoid, outside of which values are near the minimum/maximum. + /// The middle of the sigmoid output. + /// The height of the sigmoid output. This will be equal to max value - min value. + private static double sigmoid(double val, double center, double width, double middle, double height) + { + double sigmoid = Math.Tanh(Math.E * -(val - center) / width); + return sigmoid * (height / 2) + middle; + } + + /// + /// Evaluate the difficulty of the first note of a . + /// + public static double EvaluateDifficultyOf(MonoStreak monoStreak) + { + return sigmoid(monoStreak.Index, 2, 2, 0.5, 1) * EvaluateDifficultyOf(monoStreak.Parent) * 0.5; + } + + /// + /// Evaluate the difficulty of the first note of a . + /// + public static double EvaluateDifficultyOf(AlternatingMonoPattern alternatingMonoPattern) + { + return sigmoid(alternatingMonoPattern.Index, 2, 2, 0.5, 1) * EvaluateDifficultyOf(alternatingMonoPattern.Parent); + } + + /// + /// Evaluate the difficulty of the first note of a . + /// + public static double EvaluateDifficultyOf(RepeatingHitPatterns repeatingHitPattern) + { + return 2 * (1 - sigmoid(repeatingHitPattern.RepetitionInterval, 2, 2, 0.5, 1)); + } + + public static double EvaluateDifficultyOf(DifficultyHitObject hitObject) + { + TaikoDifficultyHitObjectColour colour = ((TaikoDifficultyHitObject)hitObject).Colour; + double difficulty = 0.0d; + + if (colour.MonoStreak?.FirstHitObject == hitObject) // Difficulty for MonoStreak + difficulty += EvaluateDifficultyOf(colour.MonoStreak); + if (colour.AlternatingMonoPattern?.FirstHitObject == hitObject) // Difficulty for AlternatingMonoPattern + difficulty += EvaluateDifficultyOf(colour.AlternatingMonoPattern); + if (colour.RepeatingHitPattern?.FirstHitObject == hitObject) // Difficulty for RepeatingHitPattern + difficulty += EvaluateDifficultyOf(colour.RepeatingHitPattern); + + return difficulty; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/StaminaEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/StaminaEvaluator.cs new file mode 100644 index 0000000000..84d5de4c63 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/StaminaEvaluator.cs @@ -0,0 +1,75 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators +{ + public class StaminaEvaluator + { + /// + /// Applies a speed bonus dependent on the time since the last hit performed using this finger. + /// + /// The interval between the current and previous note hit using the same finger. + private static double speedBonus(double interval) + { + // Interval is capped at a very small value to prevent infinite values. + interval = Math.Max(interval, 1); + + return 30 / interval; + } + + /// + /// Determines the number of fingers available to hit the current . + /// Any mono notes that is more than 300ms apart from a colour change will be considered to have more than 2 + /// fingers available, since players can hit the same key with multiple fingers. + /// + private static int availableFingersFor(TaikoDifficultyHitObject hitObject) + { + DifficultyHitObject? previousColourChange = hitObject.Colour.PreviousColourChange; + DifficultyHitObject? nextColourChange = hitObject.Colour.NextColourChange; + + if (previousColourChange != null && hitObject.StartTime - previousColourChange.StartTime < 300) + { + return 2; + } + + if (nextColourChange != null && nextColourChange.StartTime - hitObject.StartTime < 300) + { + return 2; + } + + return 4; + } + + /// + /// Evaluates the minimum mechanical stamina required to play the current object. This is calculated using the + /// maximum possible interval between two hits using the same key, by alternating available fingers for each colour. + /// + public static double EvaluateDifficultyOf(DifficultyHitObject current) + { + if (current.BaseObject is not Hit) + { + return 0.0; + } + + // Find the previous hit object hit by the current finger, which is n notes prior, n being the number of + // available fingers. + TaikoDifficultyHitObject taikoCurrent = (TaikoDifficultyHitObject)current; + TaikoDifficultyHitObject? keyPrevious = taikoCurrent.PreviousMono(availableFingersFor(taikoCurrent) - 1); + + if (keyPrevious == null) + { + // There is no previous hit object hit by the current finger + return 0.0; + } + + double objectStrain = 0.5; // Add a base strain to all objects + objectStrain += speedBonus(taikoCurrent.StartTime - keyPrevious.StartTime); + return objectStrain; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/Data/AlternatingMonoPattern.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/Data/AlternatingMonoPattern.cs new file mode 100644 index 0000000000..7910a8262b --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/Data/AlternatingMonoPattern.cs @@ -0,0 +1,53 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data +{ + /// + /// Encodes a list of s. + /// s with the same are grouped together. + /// + public class AlternatingMonoPattern + { + /// + /// s that are grouped together within this . + /// + public readonly List MonoStreaks = new List(); + + /// + /// The parent that contains this + /// + public RepeatingHitPatterns Parent = null!; + + /// + /// Index of this within it's parent + /// + public int Index; + + /// + /// The first in this . + /// + public TaikoDifficultyHitObject FirstHitObject => MonoStreaks[0].FirstHitObject; + + /// + /// Determine if this is a repetition of another . This + /// is a strict comparison and is true if and only if the colour sequence is exactly the same. + /// + public bool IsRepetitionOf(AlternatingMonoPattern other) + { + return HasIdenticalMonoLength(other) && + other.MonoStreaks.Count == MonoStreaks.Count && + other.MonoStreaks[0].HitType == MonoStreaks[0].HitType; + } + + /// + /// Determine if this has the same mono length of another . + /// + public bool HasIdenticalMonoLength(AlternatingMonoPattern other) + { + return other.MonoStreaks[0].RunLength == MonoStreaks[0].RunLength; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/Data/MonoStreak.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/Data/MonoStreak.cs new file mode 100644 index 0000000000..c01a0f6686 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/Data/MonoStreak.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Objects; +using System.Collections.Generic; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data +{ + /// + /// Encode colour information for a sequence of s. Consecutive s + /// of the same are encoded within the same . + /// + public class MonoStreak + { + /// + /// List of s that are encoded within this . + /// + public List HitObjects { get; private set; } = new List(); + + /// + /// The parent that contains this + /// + public AlternatingMonoPattern Parent = null!; + + /// + /// Index of this within it's parent + /// + public int Index; + + /// + /// The first in this . + /// + public TaikoDifficultyHitObject FirstHitObject => HitObjects[0]; + + /// + /// The last in this . + /// + public TaikoDifficultyHitObject LastHitObject => HitObjects[^1]; + + /// + /// The hit type of all objects encoded within this + /// + public HitType? HitType => (HitObjects[0].BaseObject as Hit)?.Type; + + /// + /// How long the mono pattern encoded within is + /// + public int RunLength => HitObjects.Count; + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/Data/RepeatingHitPatterns.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/Data/RepeatingHitPatterns.cs new file mode 100644 index 0000000000..468a9ab876 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/Data/RepeatingHitPatterns.cs @@ -0,0 +1,94 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data +{ + /// + /// Encodes a list of s, grouped together by back and forth repetition of the same + /// . Also stores the repetition interval between this and the previous . + /// + public class RepeatingHitPatterns + { + /// + /// Maximum amount of s to look back to find a repetition. + /// + private const int max_repetition_interval = 16; + + /// + /// The s that are grouped together within this . + /// + public readonly List AlternatingMonoPatterns = new List(); + + /// + /// The first in this + /// + public TaikoDifficultyHitObject FirstHitObject => AlternatingMonoPatterns[0].FirstHitObject; + + /// + /// The previous . This is used to determine the repetition interval. + /// + public readonly RepeatingHitPatterns? Previous; + + /// + /// How many between the current and previous identical . + /// If no repetition is found this will have a value of + 1. + /// + public int RepetitionInterval { get; private set; } = max_repetition_interval + 1; + + public RepeatingHitPatterns(RepeatingHitPatterns? previous) + { + Previous = previous; + } + + /// + /// Returns true if other is considered a repetition of this pattern. This is true if other's first two payloads + /// have identical mono lengths. + /// + private bool isRepetitionOf(RepeatingHitPatterns other) + { + if (AlternatingMonoPatterns.Count != other.AlternatingMonoPatterns.Count) return false; + + for (int i = 0; i < Math.Min(AlternatingMonoPatterns.Count, 2); i++) + { + if (!AlternatingMonoPatterns[i].HasIdenticalMonoLength(other.AlternatingMonoPatterns[i])) return false; + } + + return true; + } + + /// + /// Finds the closest previous that has the identical . + /// Interval is defined as the amount of chunks between the current and repeated patterns. + /// + public void FindRepetitionInterval() + { + if (Previous == null) + { + RepetitionInterval = max_repetition_interval + 1; + return; + } + + RepeatingHitPatterns? other = Previous; + int interval = 1; + + while (interval < max_repetition_interval) + { + if (isRepetitionOf(other)) + { + RepetitionInterval = Math.Min(interval, max_repetition_interval); + return; + } + + other = other.Previous; + if (other == null) break; + + ++interval; + } + + RepetitionInterval = max_repetition_interval + 1; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/TaikoColourDifficultyPreprocessor.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/TaikoColourDifficultyPreprocessor.cs new file mode 100644 index 0000000000..18a299ae92 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/TaikoColourDifficultyPreprocessor.cs @@ -0,0 +1,168 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour +{ + /// + /// Utility class to perform various encodings. + /// + public static class TaikoColourDifficultyPreprocessor + { + /// + /// Processes and encodes a list of s into a list of s, + /// assigning the appropriate s to each . + /// + public static void ProcessAndAssign(List hitObjects) + { + List hitPatterns = encode(hitObjects); + + // Assign indexing and encoding data to all relevant objects. + foreach (var repeatingHitPattern in hitPatterns) + { + // The outermost loop is kept a ForEach loop since it doesn't need index information, and we want to + // keep i and j for AlternatingMonoPattern's and MonoStreak's index respectively, to keep it in line with + // documentation. + for (int i = 0; i < repeatingHitPattern.AlternatingMonoPatterns.Count; ++i) + { + AlternatingMonoPattern monoPattern = repeatingHitPattern.AlternatingMonoPatterns[i]; + monoPattern.Parent = repeatingHitPattern; + monoPattern.Index = i; + + for (int j = 0; j < monoPattern.MonoStreaks.Count; ++j) + { + MonoStreak monoStreak = monoPattern.MonoStreaks[j]; + monoStreak.Parent = monoPattern; + monoStreak.Index = j; + + foreach (var hitObject in monoStreak.HitObjects) + { + hitObject.Colour.RepeatingHitPattern = repeatingHitPattern; + hitObject.Colour.AlternatingMonoPattern = monoPattern; + hitObject.Colour.MonoStreak = monoStreak; + } + } + } + } + } + + /// + /// Encodes a list of s into a list of s. + /// + private static List encode(List data) + { + List monoStreaks = encodeMonoStreak(data); + List alternatingMonoPatterns = encodeAlternatingMonoPattern(monoStreaks); + List repeatingHitPatterns = encodeRepeatingHitPattern(alternatingMonoPatterns); + + return repeatingHitPatterns; + } + + /// + /// Encodes a list of s into a list of s. + /// + private static List encodeMonoStreak(List data) + { + List monoStreaks = new List(); + MonoStreak? currentMonoStreak = null; + + for (int i = 0; i < data.Count; i++) + { + TaikoDifficultyHitObject taikoObject = (TaikoDifficultyHitObject)data[i]; + + // This ignores all non-note objects, which may or may not be the desired behaviour + TaikoDifficultyHitObject? previousObject = taikoObject.PreviousNote(0); + + // If this is the first object in the list or the colour changed, create a new mono streak + if (currentMonoStreak == null || previousObject == null || (taikoObject.BaseObject as Hit)?.Type != (previousObject.BaseObject as Hit)?.Type) + { + currentMonoStreak = new MonoStreak(); + monoStreaks.Add(currentMonoStreak); + } + + // Add the current object to the encoded payload. + currentMonoStreak.HitObjects.Add(taikoObject); + } + + return monoStreaks; + } + + /// + /// Encodes a list of s into a list of s. + /// + private static List encodeAlternatingMonoPattern(List data) + { + List monoPatterns = new List(); + AlternatingMonoPattern? currentMonoPattern = null; + + for (int i = 0; i < data.Count; i++) + { + // Start a new AlternatingMonoPattern if the previous MonoStreak has a different mono length, or if this is the first MonoStreak in the list. + if (currentMonoPattern == null || data[i].RunLength != data[i - 1].RunLength) + { + currentMonoPattern = new AlternatingMonoPattern(); + monoPatterns.Add(currentMonoPattern); + } + + // Add the current MonoStreak to the encoded payload. + currentMonoPattern.MonoStreaks.Add(data[i]); + } + + return monoPatterns; + } + + /// + /// Encodes a list of s into a list of s. + /// + private static List encodeRepeatingHitPattern(List data) + { + List hitPatterns = new List(); + RepeatingHitPatterns? currentHitPattern = null; + + for (int i = 0; i < data.Count; i++) + { + // Start a new RepeatingHitPattern. AlternatingMonoPatterns that should be grouped together will be handled later within this loop. + currentHitPattern = new RepeatingHitPatterns(currentHitPattern); + + // Determine if future AlternatingMonoPatterns should be grouped. + bool isCoupled = i < data.Count - 2 && data[i].IsRepetitionOf(data[i + 2]); + + if (!isCoupled) + { + // If not, add the current AlternatingMonoPattern to the encoded payload and continue. + currentHitPattern.AlternatingMonoPatterns.Add(data[i]); + } + else + { + // If so, add the current AlternatingMonoPattern to the encoded payload and start repeatedly checking if the + // subsequent AlternatingMonoPatterns should be grouped by increasing i and doing the appropriate isCoupled check. + while (isCoupled) + { + currentHitPattern.AlternatingMonoPatterns.Add(data[i]); + i++; + isCoupled = i < data.Count - 2 && data[i].IsRepetitionOf(data[i + 2]); + } + + // Skip over viewed data and add the rest to the payload + currentHitPattern.AlternatingMonoPatterns.Add(data[i]); + currentHitPattern.AlternatingMonoPatterns.Add(data[i + 1]); + i++; + } + + hitPatterns.Add(currentHitPattern); + } + + // Final pass to find repetition intervals + for (int i = 0; i < hitPatterns.Count; i++) + { + hitPatterns[i].FindRepetitionInterval(); + } + + return hitPatterns; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/TaikoDifficultyHitObjectColour.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/TaikoDifficultyHitObjectColour.cs new file mode 100644 index 0000000000..abf6fb3672 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/TaikoDifficultyHitObjectColour.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour +{ + /// + /// Stores colour compression information for a . + /// + public class TaikoDifficultyHitObjectColour + { + /// + /// The that encodes this note. + /// + public MonoStreak? MonoStreak; + + /// + /// The that encodes this note. + /// + public AlternatingMonoPattern? AlternatingMonoPattern; + + /// + /// The that encodes this note. + /// + public RepeatingHitPatterns? RepeatingHitPattern; + + /// + /// The closest past that's not the same colour. + /// + public TaikoDifficultyHitObject? PreviousColourChange => MonoStreak?.FirstHitObject.PreviousNote(0); + + /// + /// The closest future that's not the same colour. + /// + public TaikoDifficultyHitObject? NextColourChange => MonoStreak?.LastHitObject.NextNote(0); + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObjectRhythm.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/TaikoDifficultyHitObjectRhythm.cs similarity index 95% rename from osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObjectRhythm.cs rename to osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/TaikoDifficultyHitObjectRhythm.cs index ea6a224094..a273d7e2ea 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObjectRhythm.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/TaikoDifficultyHitObjectRhythm.cs @@ -1,7 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing +namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm { /// /// Represents a rhythm change in a taiko map. diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs index ae33c184d0..4aaee50c18 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs @@ -2,10 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm; namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing { @@ -14,26 +17,36 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing /// public class TaikoDifficultyHitObject : DifficultyHitObject { + /// + /// The list of all of the same colour as this in the beatmap. + /// + private readonly IReadOnlyList? monoDifficultyHitObjects; + + /// + /// The index of this in . + /// + public readonly int MonoIndex; + + /// + /// The list of all that is either a regular note or finisher in the beatmap + /// + private readonly IReadOnlyList noteDifficultyHitObjects; + + /// + /// The index of this in . + /// + public readonly int NoteIndex; + /// /// The rhythm required to hit this hit object. /// public readonly TaikoDifficultyHitObjectRhythm Rhythm; /// - /// The hit type of this hit object. + /// Colour data for this hit object. This is used by colour evaluator to calculate colour difficulty, but can be used + /// by other skills in the future. /// - public readonly HitType? HitType; - - /// - /// The index of the object in the beatmap. - /// - public readonly int ObjectIndex; - - /// - /// Whether the object should carry a penalty due to being hittable using special techniques - /// making it easier to do so. - /// - public bool StaminaCheese; + public readonly TaikoDifficultyHitObjectColour Colour; /// /// Creates a new difficulty hit object. @@ -42,16 +55,44 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing /// The gameplay preceding . /// The gameplay preceding . /// The rate of the gameplay clock. Modified by speed-changing mods. - /// The index of the object in the beatmap. - public TaikoDifficultyHitObject(HitObject hitObject, HitObject lastObject, HitObject lastLastObject, double clockRate, int objectIndex) - : base(hitObject, lastObject, clockRate) + /// The list of all s in the current beatmap. + /// The list of centre (don) s in the current beatmap. + /// The list of rim (kat) s in the current beatmap. + /// The list of s that is a hit (i.e. not a drumroll or swell) in the current beatmap. + /// The position of this in the list. + public TaikoDifficultyHitObject(HitObject hitObject, HitObject lastObject, HitObject lastLastObject, double clockRate, + List objects, + List centreHitObjects, + List rimHitObjects, + List noteObjects, int index) + : base(hitObject, lastObject, clockRate, objects, index) { - var currentHit = hitObject as Hit; + noteDifficultyHitObjects = noteObjects; + // Create the Colour object, its properties should be filled in by TaikoDifficultyPreprocessor + Colour = new TaikoDifficultyHitObjectColour(); Rhythm = getClosestRhythm(lastObject, lastLastObject, clockRate); - HitType = currentHit?.Type; - ObjectIndex = objectIndex; + switch ((hitObject as Hit)?.Type) + { + case HitType.Centre: + MonoIndex = centreHitObjects.Count; + centreHitObjects.Add(this); + monoDifficultyHitObjects = centreHitObjects; + break; + + case HitType.Rim: + MonoIndex = rimHitObjects.Count; + rimHitObjects.Add(this); + monoDifficultyHitObjects = rimHitObjects; + break; + } + + if (hitObject is Hit) + { + NoteIndex = noteObjects.Count; + noteObjects.Add(this); + } } /// @@ -90,5 +131,13 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing return common_rhythms.OrderBy(x => Math.Abs(x.Ratio - ratio)).First(); } + + public TaikoDifficultyHitObject? PreviousMono(int backwardsIndex) => monoDifficultyHitObjects?.ElementAtOrDefault(MonoIndex - (backwardsIndex + 1)); + + public TaikoDifficultyHitObject? NextMono(int forwardsIndex) => monoDifficultyHitObjects?.ElementAtOrDefault(MonoIndex + (forwardsIndex + 1)); + + public TaikoDifficultyHitObject? PreviousNote(int backwardsIndex) => noteDifficultyHitObjects.ElementAtOrDefault(NoteIndex - (backwardsIndex + 1)); + + public TaikoDifficultyHitObject? NextNote(int forwardsIndex) => noteDifficultyHitObjects.ElementAtOrDefault(NoteIndex + (forwardsIndex + 1)); } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs index 0c17ca66b9..2d45b5eed0 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs @@ -1,13 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; -using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; -using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Difficulty.Evaluators; namespace osu.Game.Rulesets.Taiko.Difficulty.Skills { @@ -16,29 +13,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills /// public class Colour : StrainDecaySkill { - protected override double SkillMultiplier => 1; - protected override double StrainDecayBase => 0.4; + protected override double SkillMultiplier => 0.12; - /// - /// Maximum number of entries to keep in . - /// - private const int mono_history_max_length = 5; - - /// - /// Queue with the lengths of the last most recent mono (single-colour) patterns, - /// with the most recent value at the end of the queue. - /// - private readonly LimitedCapacityQueue monoHistory = new LimitedCapacityQueue(mono_history_max_length); - - /// - /// The of the last object hit before the one being considered. - /// - private HitType? previousHitType; - - /// - /// Length of the current mono pattern. - /// - private int currentMonoLength; + // This is set to decay slower than other skills, due to the fact that only the first note of each encoding class + // having any difficulty values, and we want to allow colour difficulty to be able to build up even on + // slower maps. + protected override double StrainDecayBase => 0.8; public Colour(Mod[] mods) : base(mods) @@ -47,95 +27,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills protected override double StrainValueOf(DifficultyHitObject current) { - // changing from/to a drum roll or a swell does not constitute a colour change. - // hits spaced more than a second apart are also exempt from colour strain. - if (!(current.LastObject is Hit && current.BaseObject is Hit && current.DeltaTime < 1000)) - { - monoHistory.Clear(); - - var currentHit = current.BaseObject as Hit; - currentMonoLength = currentHit != null ? 1 : 0; - previousHitType = currentHit?.Type; - - return 0.0; - } - - var taikoCurrent = (TaikoDifficultyHitObject)current; - - double objectStrain = 0.0; - - if (previousHitType != null && taikoCurrent.HitType != previousHitType) - { - // The colour has changed. - objectStrain = 1.0; - - if (monoHistory.Count < 2) - { - // There needs to be at least two streaks to determine a strain. - objectStrain = 0.0; - } - else if ((monoHistory[^1] + currentMonoLength) % 2 == 0) - { - // The last streak in the history is guaranteed to be a different type to the current streak. - // If the total number of notes in the two streaks is even, nullify this object's strain. - objectStrain = 0.0; - } - - objectStrain *= repetitionPenalties(); - currentMonoLength = 1; - } - else - { - currentMonoLength += 1; - } - - previousHitType = taikoCurrent.HitType; - return objectStrain; + return ColourEvaluator.EvaluateDifficultyOf(current); } - - /// - /// The penalty to apply due to the length of repetition in colour streaks. - /// - private double repetitionPenalties() - { - const int most_recent_patterns_to_compare = 2; - double penalty = 1.0; - - monoHistory.Enqueue(currentMonoLength); - - for (int start = monoHistory.Count - most_recent_patterns_to_compare - 1; start >= 0; start--) - { - if (!isSamePattern(start, most_recent_patterns_to_compare)) - continue; - - int notesSince = 0; - for (int i = start; i < monoHistory.Count; i++) notesSince += monoHistory[i]; - penalty *= repetitionPenalty(notesSince); - break; - } - - return penalty; - } - - /// - /// Determines whether the last patterns have repeated in the history - /// of single-colour note sequences, starting from . - /// - private bool isSamePattern(int start, int mostRecentPatternsToCompare) - { - for (int i = 0; i < mostRecentPatternsToCompare; i++) - { - if (monoHistory[start + i] != monoHistory[monoHistory.Count - mostRecentPatternsToCompare + i]) - return false; - } - - return true; - } - - /// - /// Calculates the strain penalty for a colour pattern repetition. - /// - /// The number of notes since the last repetition of the pattern. - private double repetitionPenalty(int notesSince) => Math.Min(1.0, 0.032 * notesSince); } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Peaks.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Peaks.cs new file mode 100644 index 0000000000..ec8e754c5c --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Peaks.cs @@ -0,0 +1,93 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Skills +{ + public class Peaks : Skill + { + private const double rhythm_skill_multiplier = 0.2 * final_multiplier; + private const double colour_skill_multiplier = 0.375 * final_multiplier; + private const double stamina_skill_multiplier = 0.375 * final_multiplier; + + private const double final_multiplier = 0.0625; + + private readonly Rhythm rhythm; + private readonly Colour colour; + private readonly Stamina stamina; + + public double ColourDifficultyValue => colour.DifficultyValue() * colour_skill_multiplier; + public double RhythmDifficultyValue => rhythm.DifficultyValue() * rhythm_skill_multiplier; + public double StaminaDifficultyValue => stamina.DifficultyValue() * stamina_skill_multiplier; + + public Peaks(Mod[] mods) + : base(mods) + { + rhythm = new Rhythm(mods); + colour = new Colour(mods); + stamina = new Stamina(mods); + } + + /// + /// Returns the p-norm of an n-dimensional vector. + /// + /// The value of p to calculate the norm for. + /// The coefficients of the vector. + private double norm(double p, params double[] values) => Math.Pow(values.Sum(x => Math.Pow(x, p)), 1 / p); + + public override void Process(DifficultyHitObject current) + { + rhythm.Process(current); + colour.Process(current); + stamina.Process(current); + } + + /// + /// Returns the combined star rating of the beatmap, calculated using peak strains from all sections of the map. + /// + /// + /// For each section, the peak strains of all separate skills are combined into a single peak strain for the section. + /// The resulting partial rating of the beatmap is a weighted sum of the combined peaks (higher peaks are weighted more). + /// + public override double DifficultyValue() + { + List peaks = new List(); + + var colourPeaks = colour.GetCurrentStrainPeaks().ToList(); + var rhythmPeaks = rhythm.GetCurrentStrainPeaks().ToList(); + var staminaPeaks = stamina.GetCurrentStrainPeaks().ToList(); + + for (int i = 0; i < colourPeaks.Count; i++) + { + double colourPeak = colourPeaks[i] * colour_skill_multiplier; + double rhythmPeak = rhythmPeaks[i] * rhythm_skill_multiplier; + double staminaPeak = staminaPeaks[i] * stamina_skill_multiplier; + + double peak = norm(1.5, colourPeak, staminaPeak); + peak = norm(2, peak, rhythmPeak); + + // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). + // These sections will not contribute to the difficulty. + if (peak > 0) + peaks.Add(peak); + } + + double difficulty = 0; + double weight = 1; + + foreach (double strain in peaks.OrderByDescending(d => d)) + { + difficulty += strain * weight; + weight *= 0.9; + } + + return difficulty; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs index 973e55f4b4..ff187a133a 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; -using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Utils; namespace osu.Game.Rulesets.Taiko.Difficulty.Skills { @@ -106,7 +108,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills if (!samePattern(start, mostRecentPatternsToCompare)) continue; - int notesSince = hitObject.ObjectIndex - rhythmHistory[start].ObjectIndex; + int notesSince = hitObject.Index - rhythmHistory[start].Index; penalty *= repetitionPenalty(notesSince); break; } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/SingleKeyStamina.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/SingleKeyStamina.cs deleted file mode 100644 index cabfd231d8..0000000000 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/SingleKeyStamina.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Difficulty.Preprocessing; -using osu.Game.Rulesets.Difficulty.Skills; - -namespace osu.Game.Rulesets.Taiko.Difficulty.Skills -{ - /// - /// Stamina of a single key, calculated based on repetition speed. - /// - public class SingleKeyStamina - { - private double? previousHitTime; - - /// - /// Similar to - /// - public double StrainValueOf(DifficultyHitObject current) - { - if (previousHitTime == null) - { - previousHitTime = current.StartTime; - return 0; - } - - double objectStrain = 0.5; - objectStrain += speedBonus(current.StartTime - previousHitTime.Value); - previousHitTime = current.StartTime; - return objectStrain; - } - - /// - /// Applies a speed bonus dependent on the time since the last hit performed using this key. - /// - /// The duration between the current and previous note hit using the same key. - private double speedBonus(double notePairDuration) - { - return 175 / (notePairDuration + 100); - } - } -} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs index 61bcbfa59d..d04c028fec 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs @@ -1,47 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; -using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Difficulty.Evaluators; namespace osu.Game.Rulesets.Taiko.Difficulty.Skills { /// /// Calculates the stamina coefficient of taiko difficulty. /// - /// - /// The reference play style chosen uses two hands, with full alternating (the hand changes after every hit). - /// public class Stamina : StrainDecaySkill { - protected override double SkillMultiplier => 1; + protected override double SkillMultiplier => 1.1; protected override double StrainDecayBase => 0.4; - private readonly SingleKeyStamina[] centreKeyStamina = - { - new SingleKeyStamina(), - new SingleKeyStamina() - }; - - private readonly SingleKeyStamina[] rimKeyStamina = - { - new SingleKeyStamina(), - new SingleKeyStamina() - }; - - /// - /// Current index into for a centre hit. - /// - private int centreKeyIndex; - - /// - /// Current index into for a rim hit. - /// - private int rimKeyIndex; - /// /// Creates a skill. /// @@ -51,32 +27,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills { } - /// - /// Get the next to use for the given . - /// - /// The current . - private SingleKeyStamina getNextSingleKeyStamina(TaikoDifficultyHitObject current) - { - // Alternate key for the same color. - if (current.HitType == HitType.Centre) - { - centreKeyIndex = (centreKeyIndex + 1) % 2; - return centreKeyStamina[centreKeyIndex]; - } - - rimKeyIndex = (rimKeyIndex + 1) % 2; - return rimKeyStamina[rimKeyIndex]; - } - protected override double StrainValueOf(DifficultyHitObject current) { - if (!(current.BaseObject is Hit)) - { - return 0.0; - } - - TaikoDifficultyHitObject hitObject = (TaikoDifficultyHitObject)current; - return getNextSingleKeyStamina(hitObject).StrainValueOf(hitObject); + return StaminaEvaluator.EvaluateDifficultyOf(current); } } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs index 3dc5438072..72452e27b3 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using Newtonsoft.Json; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; namespace osu.Game.Rulesets.Taiko.Difficulty @@ -28,13 +29,10 @@ namespace osu.Game.Rulesets.Taiko.Difficulty public double ColourDifficulty { get; set; } /// - /// The perceived approach rate inclusive of rate-adjusting mods (DT/HT/etc). + /// The difficulty corresponding to the hardest parts of the map. /// - /// - /// Rate-adjusting mods don't directly affect the approach rate difficulty value, but have a perceived effect as a result of adjusting audio timing. - /// - [JsonProperty("approach_rate")] - public double ApproachRate { get; set; } + [JsonProperty("peak_difficulty")] + public double PeakDifficulty { get; set; } /// /// The perceived hit window for a GREAT hit inclusive of rate-adjusting mods (DT/HT/etc). @@ -55,9 +53,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow); } - public override void FromDatabaseAttributes(IReadOnlyDictionary values) + public override void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo) { - base.FromDatabaseAttributes(values); + base.FromDatabaseAttributes(values, onlineInfo); MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO]; StarRating = values[ATTRIB_ID_DIFFICULTY]; diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index 1aa31c6fe4..24b5f5939a 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -11,6 +13,7 @@ using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour; using osu.Game.Rulesets.Taiko.Difficulty.Skills; using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Rulesets.Taiko.Objects; @@ -20,21 +23,22 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { public class TaikoDifficultyCalculator : DifficultyCalculator { - private const double rhythm_skill_multiplier = 0.014; - private const double colour_skill_multiplier = 0.01; - private const double stamina_skill_multiplier = 0.021; + private const double difficulty_multiplier = 1.35; + + public override int Version => 20220902; public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) { } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[] + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) { - new Colour(mods), - new Rhythm(mods), - new Stamina(mods) - }; + return new Skill[] + { + new Peaks(mods) + }; + } protected override Mod[] DifficultyAdjustmentMods => new Mod[] { @@ -46,18 +50,23 @@ namespace osu.Game.Rulesets.Taiko.Difficulty protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) { - List taikoDifficultyHitObjects = new List(); + List difficultyHitObjects = new List(); + List centreObjects = new List(); + List rimObjects = new List(); + List noteObjects = new List(); for (int i = 2; i < beatmap.HitObjects.Count; i++) { - taikoDifficultyHitObjects.Add( + difficultyHitObjects.Add( new TaikoDifficultyHitObject( - beatmap.HitObjects[i], beatmap.HitObjects[i - 1], beatmap.HitObjects[i - 2], clockRate, i - ) + beatmap.HitObjects[i], beatmap.HitObjects[i - 1], beatmap.HitObjects[i - 2], clockRate, difficultyHitObjects, + centreObjects, rimObjects, noteObjects, difficultyHitObjects.Count) ); } - return taikoDifficultyHitObjects; + TaikoColourDifficultyPreprocessor.ProcessAndAssign(difficultyHitObjects); + + return difficultyHitObjects; } protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) @@ -65,27 +74,14 @@ namespace osu.Game.Rulesets.Taiko.Difficulty if (beatmap.HitObjects.Count == 0) return new TaikoDifficultyAttributes { Mods = mods }; - var colour = (Colour)skills[0]; - var rhythm = (Rhythm)skills[1]; - var stamina = (Stamina)skills[2]; + var combined = (Peaks)skills[0]; - double colourRating = colour.DifficultyValue() * colour_skill_multiplier; - double rhythmRating = rhythm.DifficultyValue() * rhythm_skill_multiplier; - double staminaRating = stamina.DifficultyValue() * stamina_skill_multiplier; + double colourRating = combined.ColourDifficultyValue * difficulty_multiplier; + double rhythmRating = combined.RhythmDifficultyValue * difficulty_multiplier; + double staminaRating = combined.StaminaDifficultyValue * difficulty_multiplier; - double staminaPenalty = simpleColourPenalty(staminaRating, colourRating); - staminaRating *= staminaPenalty; - - //TODO : This is a temporary fix for the stamina rating of converts, due to their low colour variance. - if (beatmap.BeatmapInfo.Ruleset.OnlineID == 0 && colourRating < 0.05) - { - staminaPenalty *= 0.25; - } - - double combinedRating = locallyCombinedDifficulty(colour, rhythm, stamina, staminaPenalty); - double separatedRating = norm(1.5, colourRating, rhythmRating, staminaRating); - double starRating = 1.4 * separatedRating + 0.5 * combinedRating; - starRating = rescale(starRating); + double combinedRating = combined.DifficultyValue() * difficulty_multiplier; + double starRating = rescale(combinedRating * 1.4); HitWindows hitWindows = new TaikoHitWindows(); hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); @@ -97,75 +93,14 @@ namespace osu.Game.Rulesets.Taiko.Difficulty StaminaDifficulty = staminaRating, RhythmDifficulty = rhythmRating, ColourDifficulty = colourRating, + PeakDifficulty = combinedRating, GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate, MaxCombo = beatmap.HitObjects.Count(h => h is Hit), }; } /// - /// Calculates the penalty for the stamina skill for maps with low colour difficulty. - /// - /// - /// Some maps (especially converts) can be easy to read despite a high note density. - /// This penalty aims to reduce the star rating of such maps by factoring in colour difficulty to the stamina skill. - /// - private double simpleColourPenalty(double staminaDifficulty, double colorDifficulty) - { - if (colorDifficulty <= 0) return 0.79 - 0.25; - - return 0.79 - Math.Atan(staminaDifficulty / colorDifficulty - 12) / Math.PI / 2; - } - - /// - /// Returns the p-norm of an n-dimensional vector. - /// - /// The value of p to calculate the norm for. - /// The coefficients of the vector. - private double norm(double p, params double[] values) => Math.Pow(values.Sum(x => Math.Pow(x, p)), 1 / p); - - /// - /// Returns the partial star rating of the beatmap, calculated using peak strains from all sections of the map. - /// - /// - /// For each section, the peak strains of all separate skills are combined into a single peak strain for the section. - /// The resulting partial rating of the beatmap is a weighted sum of the combined peaks (higher peaks are weighted more). - /// - private double locallyCombinedDifficulty(Colour colour, Rhythm rhythm, Stamina stamina, double staminaPenalty) - { - List peaks = new List(); - - var colourPeaks = colour.GetCurrentStrainPeaks().ToList(); - var rhythmPeaks = rhythm.GetCurrentStrainPeaks().ToList(); - var staminaPeaks = stamina.GetCurrentStrainPeaks().ToList(); - - for (int i = 0; i < colourPeaks.Count; i++) - { - double colourPeak = colourPeaks[i] * colour_skill_multiplier; - double rhythmPeak = rhythmPeaks[i] * rhythm_skill_multiplier; - double staminaPeak = staminaPeaks[i] * stamina_skill_multiplier * staminaPenalty; - - double peak = norm(2, colourPeak, rhythmPeak, staminaPeak); - - // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). - // These sections will not contribute to the difficulty. - if (peak > 0) - peaks.Add(peak); - } - - double difficulty = 0; - double weight = 1; - - foreach (double strain in peaks.OrderByDescending(d => d)) - { - difficulty += strain * weight; - weight *= 0.9; - } - - return difficulty; - } - - /// - /// Applies a final re-scaling of the star rating to bring maps with recorded full combos below 9.5 stars. + /// Applies a final re-scaling of the star rating. /// /// The raw star rating value before re-scaling. private double rescale(double sr) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs index fa5c0202dd..b61c13a2df 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using Newtonsoft.Json; using osu.Game.Rulesets.Difficulty; @@ -15,6 +17,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty [JsonProperty("accuracy")] public double Accuracy { get; set; } + [JsonProperty("effective_miss_count")] + public double EffectiveMissCount { get; set; } + public override IEnumerable GetAttributesForDisplay() { foreach (var attribute in base.GetAttributesForDisplay()) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index 8d99fd3b87..2d1b2903c9 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -18,6 +20,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty private int countOk; private int countMeh; private int countMiss; + private double accuracy; + + private double effectiveMissCount; public TaikoPerformanceCalculator() : base(new TaikoRuleset()) @@ -32,17 +37,25 @@ namespace osu.Game.Rulesets.Taiko.Difficulty countOk = score.Statistics.GetValueOrDefault(HitResult.Ok); countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); + accuracy = customAccuracy; - double multiplier = 1.1; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things + // The effectiveMissCount is calculated by gaining a ratio for totalSuccessfulHits and increasing the miss penalty for shorter object counts lower than 1000. + if (totalSuccessfulHits > 0) + effectiveMissCount = Math.Max(1.0, 1000.0 / totalSuccessfulHits) * countMiss; - if (score.Mods.Any(m => m is ModNoFail)) - multiplier *= 0.90; + // TODO: The detection of rulesets is temporary until the leftover old skills have been reworked. + bool isConvert = score.BeatmapInfo.Ruleset.OnlineID != 1; + + double multiplier = 1.13; if (score.Mods.Any(m => m is ModHidden)) - multiplier *= 1.10; + multiplier *= 1.075; - double difficultyValue = computeDifficultyValue(score, taikoAttributes); - double accuracyValue = computeAccuracyValue(score, taikoAttributes); + if (score.Mods.Any(m => m is ModEasy)) + multiplier *= 0.975; + + double difficultyValue = computeDifficultyValue(score, taikoAttributes, isConvert); + double accuracyValue = computeAccuracyValue(score, taikoAttributes, isConvert); double totalValue = Math.Pow( Math.Pow(difficultyValue, 1.1) + @@ -53,39 +66,56 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { Difficulty = difficultyValue, Accuracy = accuracyValue, + EffectiveMissCount = effectiveMissCount, Total = totalValue }; } - private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes) + private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert) { - double difficultyValue = Math.Pow(5 * Math.Max(1.0, attributes.StarRating / 0.175) - 4.0, 2.25) / 450.0; + double difficultyValue = Math.Pow(5 * Math.Max(1.0, attributes.StarRating / 0.115) - 4.0, 2.25) / 1150.0; double lengthBonus = 1 + 0.1 * Math.Min(1.0, totalHits / 1500.0); difficultyValue *= lengthBonus; - difficultyValue *= Math.Pow(0.985, countMiss); + difficultyValue *= Math.Pow(0.986, effectiveMissCount); - if (score.Mods.Any(m => m is ModHidden)) + if (score.Mods.Any(m => m is ModEasy)) + difficultyValue *= 0.985; + + if (score.Mods.Any(m => m is ModHidden) && !isConvert) difficultyValue *= 1.025; - if (score.Mods.Any(m => m is ModFlashlight)) - difficultyValue *= 1.05 * lengthBonus; + if (score.Mods.Any(m => m is ModHardRock)) + difficultyValue *= 1.050; - return difficultyValue * score.Accuracy; + if (score.Mods.Any(m => m is ModFlashlight)) + difficultyValue *= 1.050 * lengthBonus; + + return difficultyValue * Math.Pow(accuracy, 2.0); } - private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes) + private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert) { if (attributes.GreatHitWindow <= 0) return 0; - double accValue = Math.Pow(150.0 / attributes.GreatHitWindow, 1.1) * Math.Pow(score.Accuracy, 15) * 22.0; + double accuracyValue = Math.Pow(60.0 / attributes.GreatHitWindow, 1.1) * Math.Pow(accuracy, 8.0) * Math.Pow(attributes.StarRating, 0.4) * 27.0; - // Bonus for many objects - it's harder to keep good accuracy up for longer - return accValue * Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3)); + double lengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3)); + accuracyValue *= lengthBonus; + + // Slight HDFL Bonus for accuracy. A clamp is used to prevent against negative values. + if (score.Mods.Any(m => m is ModFlashlight) && score.Mods.Any(m => m is ModHidden) && !isConvert) + accuracyValue *= Math.Max(1.0, 1.1 * lengthBonus); + + return accuracyValue; } private int totalHits => countGreat + countOk + countMeh + countMiss; + + private int totalSuccessfulHits => countGreat + countOk + countMeh; + + private double customAccuracy => totalHits > 0 ? (countGreat * 300 + countOk * 150) / (totalHits * 300.0) : 0; } } diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/DrumRollPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/DrumRollPlacementBlueprint.cs index eb07ce7635..4b4e2b5847 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/DrumRollPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/DrumRollPlacementBlueprint.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Taiko.Objects; namespace osu.Game.Rulesets.Taiko.Edit.Blueprints { - public class DrumRollPlacementBlueprint : TaikoSpanPlacementBlueprint + public partial class DrumRollPlacementBlueprint : TaikoSpanPlacementBlueprint { public DrumRollPlacementBlueprint() : base(new DrumRoll()) diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPiece.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPiece.cs index b02e3aa9ba..84bc547372 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPiece.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPiece.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -8,7 +10,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Taiko.Edit.Blueprints { - public class HitPiece : CompositeDrawable + public partial class HitPiece : CompositeDrawable { public HitPiece() { diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs index 0d0fd136a7..8b1a4f688c 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs @@ -10,7 +10,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Taiko.Edit.Blueprints { - public class HitPlacementBlueprint : PlacementBlueprint + public partial class HitPlacementBlueprint : PlacementBlueprint { private readonly HitPiece piece; @@ -25,22 +25,19 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints }; } + protected override void LoadComplete() + { + base.LoadComplete(); + BeginPlacement(); + } + 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/LengthPiece.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/LengthPiece.cs index 6b651fd739..af02522a05 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/LengthPiece.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/LengthPiece.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -8,7 +10,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Taiko.Edit.Blueprints { - public class LengthPiece : CompositeDrawable + public partial class LengthPiece : CompositeDrawable { public LengthPiece() { diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/SwellPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/SwellPlacementBlueprint.cs index 95fa82a0f2..2080293428 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/SwellPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/SwellPlacementBlueprint.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Taiko.Objects; namespace osu.Game.Rulesets.Taiko.Edit.Blueprints { - public class SwellPlacementBlueprint : TaikoSpanPlacementBlueprint + public partial class SwellPlacementBlueprint : TaikoSpanPlacementBlueprint { public SwellPlacementBlueprint() : base(new Swell()) diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSelectionBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSelectionBlueprint.cs index 01b90c4bca..34695cbdd6 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSelectionBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; @@ -8,7 +10,7 @@ using osuTK; namespace osu.Game.Rulesets.Taiko.Edit.Blueprints { - public class TaikoSelectionBlueprint : HitObjectSelectionBlueprint + public partial class TaikoSelectionBlueprint : HitObjectSelectionBlueprint { public TaikoSelectionBlueprint(HitObject hitObject) : base(hitObject) diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs index 59249e6bf4..fcf2573d64 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics; using osu.Framework.Input.Events; @@ -14,7 +16,7 @@ using osuTK.Input; namespace osu.Game.Rulesets.Taiko.Edit.Blueprints { - public class TaikoSpanPlacementBlueprint : PlacementBlueprint + public partial class TaikoSpanPlacementBlueprint : PlacementBlueprint { private readonly HitPiece headPiece; private readonly HitPiece tailPiece; @@ -50,6 +52,12 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints private double originalStartTime; private Vector2 originalPosition; + protected override void LoadComplete() + { + base.LoadComplete(); + BeginPlacement(); + } + protected override bool OnMouseDown(MouseDownEvent e) { if (e.Button != MouseButton.Left) @@ -65,7 +73,7 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints return; base.OnMouseUp(e); - EndPlacement(true); + EndPlacement(spanPlacementObject.Duration > 0); } public override void UpdateTimeAndPosition(SnapResult result) diff --git a/osu.Game.Rulesets.Taiko/Edit/DrumRollCompositionTool.cs b/osu.Game.Rulesets.Taiko/Edit/DrumRollCompositionTool.cs index 587a4efecb..acb17fc455 100644 --- a/osu.Game.Rulesets.Taiko/Edit/DrumRollCompositionTool.cs +++ b/osu.Game.Rulesets.Taiko/Edit/DrumRollCompositionTool.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; diff --git a/osu.Game.Rulesets.Taiko/Edit/HitCompositionTool.cs b/osu.Game.Rulesets.Taiko/Edit/HitCompositionTool.cs index 3e97b4e322..e52dae4b0c 100644 --- a/osu.Game.Rulesets.Taiko/Edit/HitCompositionTool.cs +++ b/osu.Game.Rulesets.Taiko/Edit/HitCompositionTool.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; diff --git a/osu.Game.Rulesets.Taiko/Edit/SwellCompositionTool.cs b/osu.Game.Rulesets.Taiko/Edit/SwellCompositionTool.cs index 918afde1dd..dd0ff61c10 100644 --- a/osu.Game.Rulesets.Taiko/Edit/SwellCompositionTool.cs +++ b/osu.Game.Rulesets.Taiko/Edit/SwellCompositionTool.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoBlueprintContainer.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoBlueprintContainer.cs index a465638779..6be22f3af0 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoBlueprintContainer.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoBlueprintContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Taiko.Edit.Blueprints; @@ -8,7 +10,7 @@ using osu.Game.Screens.Edit.Compose.Components; namespace osu.Game.Rulesets.Taiko.Edit { - public class TaikoBlueprintContainer : ComposeBlueprintContainer + public partial class TaikoBlueprintContainer : ComposeBlueprintContainer { public TaikoBlueprintContainer(HitObjectComposer composer) : base(composer) diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs index 161799c980..cff5731181 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs @@ -9,7 +9,7 @@ using osu.Game.Screens.Edit.Compose.Components; namespace osu.Game.Rulesets.Taiko.Edit { - public class TaikoHitObjectComposer : HitObjectComposer + public partial class TaikoHitObjectComposer : HitObjectComposer { public TaikoHitObjectComposer(TaikoRuleset ruleset) : base(ruleset) diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs index ab3b729307..b727c0a61b 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -14,7 +16,7 @@ using osu.Game.Screens.Edit.Compose.Components; namespace osu.Game.Rulesets.Taiko.Edit { - public class TaikoSelectionHandler : EditorSelectionHandler + public partial class TaikoSelectionHandler : EditorSelectionHandler { private readonly Bindable selectionRimState = new Bindable(); private readonly Bindable selectionStrongState = new Bindable(); diff --git a/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollJudgement.cs b/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollJudgement.cs deleted file mode 100644 index 0d91002f4b..0000000000 --- a/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollJudgement.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Scoring; - -namespace osu.Game.Rulesets.Taiko.Judgements -{ - public class TaikoDrumRollJudgement : TaikoJudgement - { - protected override double HealthIncreaseFor(HitResult result) - { - // Drum rolls can be ignored with no health penalty - switch (result) - { - case HitResult.Miss: - return 0; - - default: - return base.HealthIncreaseFor(result); - } - } - } -} diff --git a/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollTickJudgement.cs b/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollTickJudgement.cs index 647ad7853d..de56c76f56 100644 --- a/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollTickJudgement.cs +++ b/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollTickJudgement.cs @@ -1,24 +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.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Taiko.Judgements { public class TaikoDrumRollTickJudgement : TaikoJudgement { - public override HitResult MaxResult => HitResult.SmallTickHit; + public override HitResult MaxResult => HitResult.SmallBonus; - protected override double HealthIncreaseFor(HitResult result) - { - switch (result) - { - case HitResult.SmallTickHit: - return 0.15; - - default: - return 0; - } - } + protected override double HealthIncreaseFor(HitResult result) => 0; } } diff --git a/osu.Game.Rulesets.Taiko/Judgements/TaikoJudgement.cs b/osu.Game.Rulesets.Taiko/Judgements/TaikoJudgement.cs index e272c1a4ef..f8e3303752 100644 --- a/osu.Game.Rulesets.Taiko/Judgements/TaikoJudgement.cs +++ b/osu.Game.Rulesets.Taiko/Judgements/TaikoJudgement.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; diff --git a/osu.Game.Rulesets.Taiko/Judgements/TaikoStrongJudgement.cs b/osu.Game.Rulesets.Taiko/Judgements/TaikoStrongJudgement.cs index 06495ad9f4..bafe7dfbaf 100644 --- a/osu.Game.Rulesets.Taiko/Judgements/TaikoStrongJudgement.cs +++ b/osu.Game.Rulesets.Taiko/Judgements/TaikoStrongJudgement.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Taiko.Judgements { public class TaikoStrongJudgement : TaikoJudgement { - public override HitResult MaxResult => HitResult.SmallBonus; + public override HitResult MaxResult => HitResult.LargeBonus; // MainObject already changes the HP protected override double HealthIncreaseFor(HitResult result) => 0; diff --git a/osu.Game.Rulesets.Taiko/Judgements/TaikoSwellJudgement.cs b/osu.Game.Rulesets.Taiko/Judgements/TaikoSwellJudgement.cs index 4d61efd3ee..146621997d 100644 --- a/osu.Game.Rulesets.Taiko/Judgements/TaikoSwellJudgement.cs +++ b/osu.Game.Rulesets.Taiko/Judgements/TaikoSwellJudgement.cs @@ -1,17 +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 osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Taiko.Judgements { public class TaikoSwellJudgement : TaikoJudgement { + public override HitResult MaxResult => HitResult.LargeBonus; + protected override double HealthIncreaseFor(HitResult result) { switch (result) { - case HitResult.Miss: + case HitResult.IgnoreMiss: return -0.65; default: diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs index 4b74b4991e..1e09061859 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; +using System.Linq; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Replays; @@ -12,5 +14,7 @@ namespace osu.Game.Rulesets.Taiko.Mods { public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList mods) => new ModReplayData(new TaikoAutoGenerator(beatmap).Generate(), new ModCreatedUser { Username = "mekkadosu!" }); + + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(TaikoModSingleTap) }).ToArray(); } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModCinema.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModCinema.cs index fee0cb2744..c268087f0a 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModCinema.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModCinema.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; +using System.Linq; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Objects; @@ -13,5 +15,7 @@ namespace osu.Game.Rulesets.Taiko.Mods { public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList mods) => new ModReplayData(new TaikoAutoGenerator(beatmap).Generate(), new ModCreatedUser { Username = "mekkadosu!" }); + + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(TaikoModSingleTap) }).ToArray(); } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs index 5a6f57bc36..d0361b1c8d 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs @@ -2,31 +2,29 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; using osu.Game.Rulesets.Taiko.UI; using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Taiko.Mods { - public class TaikoModClassic : ModClassic, IApplicableToDrawableRuleset, IUpdatableByPlayfield + public class TaikoModClassic : ModClassic, IApplicableToDrawableRuleset, IApplicableToDrawableHitObject { - private DrawableTaikoRuleset drawableTaikoRuleset; - public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { - drawableTaikoRuleset = (DrawableTaikoRuleset)drawableRuleset; - drawableTaikoRuleset.LockPlayfieldAspect.Value = false; + var drawableTaikoRuleset = (DrawableTaikoRuleset)drawableRuleset; + drawableTaikoRuleset.LockPlayfieldMaxAspect.Value = false; + + var playfield = (TaikoPlayfield)drawableRuleset.Playfield; + playfield.ClassicHitTargetPosition.Value = true; } - public void Update(Playfield playfield) + public void ApplyToDrawableHitObject(DrawableHitObject drawable) { - // Classic taiko scrolls at a constant 100px per 1000ms. More notes become visible as the playfield is lengthened. - const float scroll_rate = 10; - - // Since the time range will depend on a positional value, it is referenced to the x480 pixel space. - float ratio = drawableTaikoRuleset.DrawHeight / 480; - - drawableTaikoRuleset.TimeRange.Value = (playfield.HitObjectContainer.DrawWidth / ratio) * scroll_rate; + if (drawable is DrawableTaikoHitObject hit) + hit.SnapJudgementLocation = 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 bc38213acc..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 => 1.12; } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs index ad6fdf59e2..009f2854f8 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; @@ -8,7 +9,7 @@ namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModEasy : ModEasy { - public override string Description => @"Beats move slower, and less accuracy required!"; + public override LocalisableString Description => @"Beats move slower, and less accuracy required!"; /// /// Multiplier factor added to the scrolling speed. diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs index beec785fe8..733772e21f 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs @@ -4,7 +4,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Layout; -using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.UI; @@ -13,62 +12,63 @@ using osuTK; namespace osu.Game.Rulesets.Taiko.Mods { - public class TaikoModFlashlight : ModFlashlight + public partial class TaikoModFlashlight : ModFlashlight { - public override double ScoreMultiplier => 1.12; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.12 : 1; - [SettingSource("Flashlight size", "Multiplier applied to the default flashlight size.")] - public override BindableFloat SizeMultiplier { get; } = new BindableFloat + public override BindableFloat SizeMultiplier { get; } = new BindableFloat(1) { MinValue = 0.5f, MaxValue = 1.5f, - Default = 1f, - Value = 1f, Precision = 0.1f }; - [SettingSource("Change size based on combo", "Decrease the flashlight size as combo increases.")] - public override BindableBool ComboBasedSize { get; } = new BindableBool - { - Default = true, - Value = true - }; + public override BindableBool ComboBasedSize { get; } = new BindableBool(true); - public override float DefaultFlashlightSize => 250; + public override float DefaultFlashlightSize => 200; - protected override Flashlight CreateFlashlight() => new TaikoFlashlight(this, playfield); + protected override Flashlight CreateFlashlight() => new TaikoFlashlight(this, Playfield); - private TaikoPlayfield playfield; + protected TaikoPlayfield Playfield { get; private set; } = null!; public override void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { - playfield = (TaikoPlayfield)drawableRuleset.Playfield; + Playfield = (TaikoPlayfield)drawableRuleset.Playfield; base.ApplyToDrawableRuleset(drawableRuleset); } - private class TaikoFlashlight : Flashlight + public partial class TaikoFlashlight : Flashlight { - private readonly LayoutValue flashlightProperties = new LayoutValue(Invalidation.DrawSize); + private readonly LayoutValue flashlightProperties = new LayoutValue(Invalidation.RequiredParentSizeToFit | Invalidation.DrawInfo); private readonly TaikoPlayfield taikoPlayfield; public TaikoFlashlight(TaikoModFlashlight modFlashlight, TaikoPlayfield taikoPlayfield) : base(modFlashlight) { this.taikoPlayfield = taikoPlayfield; - FlashlightSize = getSizeFor(0); + + FlashlightSize = adjustSizeForPlayfieldAspectRatio(GetSize()); + FlashlightSmoothness = 1.4f; AddLayout(flashlightProperties); } - private Vector2 getSizeFor(int combo) + /// + /// Returns the aspect ratio-adjusted size of the flashlight. + /// This ensures that the size of the flashlight remains independent of taiko-specific aspect ratio adjustments. + /// + /// + /// The size of the flashlight. + /// The value provided here should always come from . + /// + private Vector2 adjustSizeForPlayfieldAspectRatio(float size) { - // Preserve flashlight size through the playfield's aspect adjustment. - return new Vector2(0, GetSizeFor(combo) * taikoPlayfield.DrawHeight / TaikoPlayfield.DEFAULT_HEIGHT); + return new Vector2(0, size * taikoPlayfield.DrawHeight / TaikoPlayfield.DEFAULT_HEIGHT); } - protected override void OnComboChange(ValueChangedEvent e) + protected override void UpdateFlashlightSize(float size) { - this.TransformTo(nameof(FlashlightSize), getSizeFor(e.NewValue), FLASHLIGHT_FADE_DURATION); + this.TransformTo(nameof(FlashlightSize), adjustSizeForPlayfieldAspectRatio(size), FLASHLIGHT_FADE_DURATION); } protected override string FragmentShader => "CircularFlashlight"; @@ -82,7 +82,7 @@ namespace osu.Game.Rulesets.Taiko.Mods FlashlightPosition = ToLocalSpace(taikoPlayfield.HitTarget.ScreenSpaceDrawQuad.Centre); ClearTransforms(targetMember: nameof(FlashlightSize)); - FlashlightSize = getSizeFor(Combo.Value); + FlashlightSize = adjustSizeForPlayfieldAspectRatio(GetSize()); flashlightProperties.Validate(); } 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/TaikoModHardRock.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModHardRock.cs index 8437dfe52e..ba41175461 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModHardRock.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModHardRock.cs @@ -8,7 +8,7 @@ namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModHardRock : ModHardRock { - public override double ScoreMultiplier => 1.06; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1; /// /// Multiplier factor added to the scrolling speed. diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs index 7f565cb82d..4708ef9bf0 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -15,8 +16,8 @@ namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModHidden : ModHidden, IApplicableToDrawableRuleset { - public override string Description => @"Beats fade out before you hit them!"; - public override double ScoreMultiplier => 1.06; + public override LocalisableString Description => @"Beats fade out before you hit them!"; + public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1; /// /// How far away from the hit target should hitobjects start to fade out. @@ -30,7 +31,7 @@ namespace osu.Game.Rulesets.Taiko.Mods /// private const float fade_out_duration = 0.375f; - private DrawableTaikoRuleset drawableRuleset; + private DrawableTaikoRuleset drawableRuleset = null!; public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { @@ -46,8 +47,8 @@ namespace osu.Game.Rulesets.Taiko.Mods { switch (hitObject) { - case DrawableDrumRollTick _: - case DrawableHit _: + case DrawableDrumRollTick: + case DrawableHit: double preempt = drawableRuleset.TimeRange.Value / drawableRuleset.ControlPointAt(hitObject.HitObject.StartTime).Multiplier; double start = hitObject.HitObject.StartTime - preempt * fade_out_start_time; double duration = preempt * fade_out_duration; diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModNightcore.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModNightcore.cs index 5377eb1072..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 => 1.12; } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs index 307a37bf2e..c0be0290e6 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; @@ -13,7 +14,7 @@ namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModRandom : ModRandom, IApplicableToBeatmap { - public override string Description => @"Shuffle around the colours!"; + public override LocalisableString Description => @"Shuffle around the colours!"; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(TaikoModSwap)).ToArray(); public void ApplyToBeatmap(IBeatmap beatmap) diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModRelax.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModRelax.cs index 7be70d9ac3..e90ab589fc 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModRelax.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModRelax.cs @@ -1,12 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Linq; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModRelax : ModRelax { - public override string Description => @"No ninja-like spinners, demanding drumrolls or unexpected katu's."; + public override LocalisableString Description => @"No ninja-like spinners, demanding drumrolls or unexpected katus."; + + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(TaikoModSingleTap) }).ToArray(); } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModSingleTap.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModSingleTap.cs new file mode 100644 index 0000000000..a5cffca06f --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModSingleTap.cs @@ -0,0 +1,127 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.Play; +using osu.Game.Utils; +using osu.Game.Rulesets.Taiko.UI; + +namespace osu.Game.Rulesets.Taiko.Mods +{ + public partial class TaikoModSingleTap : Mod, IApplicableToDrawableRuleset, IUpdatableByPlayfield + { + public override string Name => @"Single Tap"; + public override string Acronym => @"SG"; + public override LocalisableString Description => @"One key for dons, one key for kats."; + + public override double ScoreMultiplier => 1.0; + public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModRelax), typeof(TaikoModCinema) }; + public override ModType Type => ModType.Conversion; + + private DrawableTaikoRuleset ruleset = null!; + + private TaikoPlayfield playfield { get; set; } = null!; + + private TaikoAction? lastAcceptedCentreAction { get; set; } + private TaikoAction? lastAcceptedRimAction { get; set; } + + /// + /// A tracker for periods where single tap should not be enforced (i.e. non-gameplay periods). + /// + /// + /// This is different from in that the periods here end strictly at the first object after the break, rather than the break's end time. + /// + private PeriodTracker nonGameplayPeriods = null!; + + private IFrameStableClock gameplayClock = null!; + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + ruleset = (DrawableTaikoRuleset)drawableRuleset; + ruleset.KeyBindingInputManager.Add(new InputInterceptor(this)); + playfield = (TaikoPlayfield)ruleset.Playfield; + + var periods = new List(); + + if (drawableRuleset.Objects.Any()) + { + periods.Add(new Period(int.MinValue, getValidJudgementTime(ruleset.Objects.First()) - 1)); + + foreach (BreakPeriod b in drawableRuleset.Beatmap.Breaks) + periods.Add(new Period(b.StartTime, getValidJudgementTime(ruleset.Objects.First(h => h.StartTime >= b.EndTime)) - 1)); + + static double getValidJudgementTime(HitObject hitObject) => hitObject.StartTime - hitObject.HitWindows.WindowFor(HitResult.Meh); + } + + nonGameplayPeriods = new PeriodTracker(periods); + + gameplayClock = drawableRuleset.FrameStableClock; + } + + public void Update(Playfield playfield) + { + if (!nonGameplayPeriods.IsInAny(gameplayClock.CurrentTime)) return; + + lastAcceptedCentreAction = null; + lastAcceptedRimAction = null; + } + + private bool checkCorrectAction(TaikoAction action) + { + if (nonGameplayPeriods.IsInAny(gameplayClock.CurrentTime)) + return true; + + // If next hit object is strong, allow usage of all actions. Strong drumrolls are ignored in this check. + if (playfield.HitObjectContainer.AliveObjects.FirstOrDefault(h => h.Result?.HasResult != true)?.HitObject is TaikoStrongableHitObject hitObject + && hitObject.IsStrong + && hitObject is not DrumRoll) + return true; + + if ((action == TaikoAction.LeftCentre || action == TaikoAction.RightCentre) + && (lastAcceptedCentreAction == null || lastAcceptedCentreAction == action)) + { + lastAcceptedCentreAction = action; + return true; + } + + if ((action == TaikoAction.LeftRim || action == TaikoAction.RightRim) + && (lastAcceptedRimAction == null || lastAcceptedRimAction == action)) + { + lastAcceptedRimAction = action; + return true; + } + + return false; + } + + private partial class InputInterceptor : Component, IKeyBindingHandler + { + private readonly TaikoModSingleTap mod; + + public InputInterceptor(TaikoModSingleTap mod) + { + this.mod = mod; + } + + public bool OnPressed(KeyBindingPressEvent e) + // if the pressed action is incorrect, block it from reaching gameplay. + => !mod.checkCorrectAction(e.Action); + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs index 3cb337c41d..fc3913f56d 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Beatmaps; @@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Taiko.Mods { public override string Name => "Swap"; public override string Acronym => "SW"; - public override string Description => @"Dons become kats, kats become dons"; + public override LocalisableString Description => @"Dons become kats, kats become dons"; public override ModType Type => ModType.Conversion; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModRandom)).ToArray(); diff --git a/osu.Game.Rulesets.Taiko/Objects/BarLine.cs b/osu.Game.Rulesets.Taiko/Objects/BarLine.cs index bbfc02f975..d2eba0eb54 100644 --- a/osu.Game.Rulesets.Taiko/Objects/BarLine.cs +++ b/osu.Game.Rulesets.Taiko/Objects/BarLine.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; @@ -9,14 +11,16 @@ namespace osu.Game.Rulesets.Taiko.Objects { public class BarLine : TaikoHitObject, IBarLine { + private HitObjectProperty major; + + public Bindable MajorBindable => major.Bindable; + public bool Major { - get => MajorBindable.Value; - set => MajorBindable.Value = value; + get => major.Value; + set => major.Value = value; } - public readonly Bindable MajorBindable = new BindableBool(); - public override Judgement CreateJudgement() => new IgnoreJudgement(); } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableBarLine.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableBarLine.cs index d653f01db6..65a6fd4499 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableBarLine.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableBarLine.cs @@ -1,15 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Game.Rulesets.Objects; -using osuTK; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Skinning.Default; using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.Objects.Drawables @@ -17,7 +14,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables /// /// A line that scrolls alongside hit objects in the playfield and visualises control points. /// - public class DrawableBarLine : DrawableHitObject + public partial class DrawableBarLine : DrawableHitObject { public new BarLine HitObject => (BarLine)base.HitObject; @@ -26,35 +23,15 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables /// private const float tracker_width = 2f; - /// - /// The vertical offset of the triangles from the line tracker. - /// - private const float triangle_offset = 10f; - - /// - /// The size of the triangles. - /// - private const float triangle_size = 20f; - - /// - /// The visual line tracker. - /// - private SkinnableDrawable line; - - /// - /// Container with triangles. Only visible for major lines. - /// - private Container triangleContainer; - - private readonly Bindable major = new Bindable(); + public readonly Bindable Major = new Bindable(); public DrawableBarLine() : this(null) { } - public DrawableBarLine([CanBeNull] BarLine barLine) - : base(barLine) + public DrawableBarLine(BarLine? barLine) + : base(barLine!) { } @@ -67,69 +44,23 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables RelativeSizeAxes = Axes.Y; Width = tracker_width; - AddRangeInternal(new Drawable[] + AddInternal(new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.BarLine), _ => new DefaultBarLine()) { - line = new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.BarLine), _ => new Box - { - RelativeSizeAxes = Axes.Both, - EdgeSmoothness = new Vector2(0.5f, 0), - }) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - triangleContainer = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Children = new[] - { - new EquilateralTriangle - { - Name = "Top", - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Position = new Vector2(0, -triangle_offset), - Size = new Vector2(-triangle_size), - EdgeSmoothness = new Vector2(1), - }, - new EquilateralTriangle - { - Name = "Bottom", - Anchor = Anchor.BottomCentre, - Origin = Anchor.TopCentre, - Position = new Vector2(0, triangle_offset), - Size = new Vector2(triangle_size), - EdgeSmoothness = new Vector2(1), - } - } - } + Anchor = Anchor.Centre, + Origin = Anchor.Centre, }); } - protected override void LoadComplete() - { - base.LoadComplete(); - major.BindValueChanged(updateMajor, true); - } - - private void updateMajor(ValueChangedEvent major) - { - line.Alpha = major.NewValue ? 1f : 0.75f; - triangleContainer.Alpha = major.NewValue ? 1 : 0; - } - protected override void OnApply() { base.OnApply(); - major.BindTo(HitObject.MajorBindable); + Major.BindTo(HitObject.MajorBindable); } protected override void OnFree() { base.OnFree(); - major.UnbindFrom(HitObject.MajorBindable); + Major.UnbindFrom(HitObject.MajorBindable); } protected override void UpdateHitStateTransforms(ArmedState state) diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs index b84db513f7..005d2ab1ac 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs @@ -1,8 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; -using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Utils; @@ -11,23 +12,25 @@ using osu.Game.Rulesets.Objects.Drawables; using osuTK.Graphics; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; using osu.Framework.Input.Events; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Skinning.Default; using osu.Game.Skinning; using osuTK; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { - public class DrawableDrumRoll : DrawableTaikoStrongableHitObject + public partial class DrawableDrumRoll : DrawableTaikoStrongableHitObject { /// /// Number of rolling hits required to reach the dark/final colour. /// private const int rolling_hits_for_engaged_colour = 5; + public override Quad ScreenSpaceDrawQuad => MainPiece.Drawable.ScreenSpaceDrawQuad; + /// /// Rolling number of tick hits. This increases for hits and decreases for misses. /// @@ -38,6 +41,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables private Color4 colourIdle; private Color4 colourEngaged; + public override bool DisplayResult => false; + public DrawableDrumRoll() : this(null) { @@ -110,7 +115,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables return base.CreateNestedHitObject(hitObject); } - protected override SkinnableDrawable CreateMainPiece() => new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.DrumRollBody), + protected override SkinnableDrawable CreateMainPiece() => new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.DrumRollBody), _ => new ElongatedCirclePiece()); public override bool OnPressed(KeyBindingPressEvent e) => false; @@ -138,14 +143,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables if (timeOffset < 0) return; - int countHit = NestedHitObjects.Count(o => o.IsHit); - - if (countHit >= HitObject.RequiredGoodHits) - { - ApplyResult(r => r.Type = countHit >= HitObject.RequiredGreatHits ? HitResult.Great : HitResult.Ok); - } - else - ApplyResult(r => r.Type = r.Judgement.MinResult); + ApplyResult(r => r.Type = r.Judgement.MaxResult); } protected override void UpdateHitStateTransforms(ArmedState state) @@ -175,7 +173,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables (MainPiece.Drawable as IHasAccentColour)?.FadeAccent(newColour, fadeDuration); } - public class StrongNestedHit : DrawableStrongNestedHit + public partial class StrongNestedHit : DrawableStrongNestedHit { public new DrawableDrumRoll ParentHitObject => (DrawableDrumRoll)base.ParentHitObject; diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs index e24923e482..abecd19545 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using JetBrains.Annotations; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input.Events; using osu.Game.Rulesets.Objects; @@ -12,8 +15,10 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { - public class DrawableDrumRollTick : DrawableTaikoStrongableHitObject + public partial class DrawableDrumRollTick : DrawableTaikoStrongableHitObject { + public BindableBool IsFirstTick = new BindableBool(); + /// /// The hit type corresponding to the that the user pressed to hit this . /// @@ -30,13 +35,14 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables FillMode = FillMode.Fit; } - protected override SkinnableDrawable CreateMainPiece() => new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.DrumRollTick), - _ => new TickPiece - { - Filled = HitObject.FirstTick - }); + protected override SkinnableDrawable CreateMainPiece() => new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.DrumRollTick), _ => new TickPiece()); - protected override double MaximumJudgementOffset => HitObject.HitWindow; + protected override void OnApply() + { + base.OnApply(); + + IsFirstTick.Value = HitObject.FirstTick; + } protected override void CheckForResult(bool userTriggered, double timeOffset) { @@ -66,7 +72,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables switch (state) { case ArmedState.Hit: - this.ScaleTo(0, 100, Easing.OutQuint); + this.ScaleTo(1.4f, 200, Easing.OutQuint); + this.FadeOut(200, Easing.OutQuint); break; } } @@ -79,7 +86,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables protected override DrawableStrongNestedHit CreateStrongNestedHit(DrumRollTick.StrongNestedHit hitObject) => new StrongNestedHit(hitObject); - public class StrongNestedHit : DrawableStrongNestedHit + public partial class StrongNestedHit : DrawableStrongNestedHit { public new DrawableDrumRollTick ParentHitObject => (DrawableDrumRollTick)base.ParentHitObject; diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableFlyingHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableFlyingHit.cs index 3253c1ce5a..0cd265ecab 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableFlyingHit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableFlyingHit.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -9,7 +11,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables /// /// A hit used specifically for drum rolls, where spawning flying hits is required. /// - public class DrawableFlyingHit : DrawableHit + public partial class DrawableFlyingHit : DrawableHit { public DrawableFlyingHit(DrawableDrumRollTick drumRollTick) : base(new IgnoreHit diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs index 863672b3fa..62c8457c58 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; @@ -17,7 +19,7 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { - public class DrawableHit : DrawableTaikoStrongableHitObject + public partial class DrawableHit : DrawableTaikoStrongableHitObject { /// /// A list of keys which can result in hits for this HitObject. @@ -88,8 +90,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables } protected override SkinnableDrawable CreateMainPiece() => HitObject.Type == HitType.Centre - ? new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.CentreHit), _ => new CentreHitCirclePiece(), confineMode: ConfineMode.ScaleToFit) - : new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.RimHit), _ => new RimHitCirclePiece(), confineMode: ConfineMode.ScaleToFit); + ? new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.CentreHit), _ => new CentreHitCirclePiece(), confineMode: ConfineMode.ScaleToFit) + : new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.RimHit), _ => new RimHitCirclePiece(), confineMode: ConfineMode.ScaleToFit); public override IEnumerable GetSamples() { @@ -199,15 +201,15 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables break; case ArmedState.Hit: - // If we're far enough away from the left stage, we should bring outselves in front of it + // If we're far enough away from the left stage, we should bring ourselves in front of it ProxyContent(); - var flash = (MainPiece.Drawable as CirclePiece)?.FlashBox; - flash?.FadeTo(0.9f).FadeOut(300); - const float gravity_time = 300; const float gravity_travel_height = 200; + if (SnapJudgementLocation) + MainPiece.MoveToX(-X); + this.ScaleTo(0.8f, gravity_time * 2, Easing.OutQuad); this.MoveToY(-gravity_travel_height, gravity_time, Easing.Out) @@ -221,7 +223,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables protected override DrawableStrongNestedHit CreateStrongNestedHit(Hit.StrongNestedHit hitObject) => new StrongNestedHit(hitObject); - public class StrongNestedHit : DrawableStrongNestedHit + public partial class StrongNestedHit : DrawableStrongNestedHit { public new DrawableHit ParentHitObject => (DrawableHit)base.ParentHitObject; diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableStrongNestedHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableStrongNestedHit.cs index 9c22e34387..4ea30453d1 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableStrongNestedHit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableStrongNestedHit.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using osu.Game.Rulesets.Taiko.Judgements; @@ -9,7 +11,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables /// /// Used as a nested hitobject to provide s for s. /// - public abstract class DrawableStrongNestedHit : DrawableTaikoHitObject + public abstract partial class DrawableStrongNestedHit : DrawableTaikoHitObject { public new DrawableTaikoHitObject ParentHitObject => (DrawableTaikoHitObject)base.ParentHitObject; diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs index 77243218ce..8441e3a749 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using JetBrains.Annotations; @@ -14,13 +16,12 @@ using osuTK.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Skinning.Default; using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { - public class DrawableSwell : DrawableTaikoHitObject + public partial class DrawableSwell : DrawableTaikoHitObject { private const float target_ring_thick_border = 1.4f; private const float target_ring_thin_border = 1f; @@ -37,6 +38,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables private readonly CircularContainer targetRing; private readonly CircularContainer expandingRing; + public override bool DisplayResult => false; + public DrawableSwell() : this(null) { @@ -122,7 +125,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables targetRing.BorderColour = colours.YellowDark.Opacity(0.25f); } - protected override SkinnableDrawable CreateMainPiece() => new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.Swell), + protected override SkinnableDrawable CreateMainPiece() => new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.Swell), _ => new SwellCirclePiece { // to allow for rotation transform @@ -199,7 +202,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables expandingRing.ScaleTo(1f + Math.Min(target_ring_scale - 1f, (target_ring_scale - 1f) * completion * 1.3f), 260, Easing.OutQuint); if (numHits == HitObject.RequiredHits) - ApplyResult(r => r.Type = HitResult.Great); + ApplyResult(r => r.Type = r.Judgement.MaxResult); } else { @@ -220,7 +223,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables tick.TriggerResult(false); } - ApplyResult(r => r.Type = numHits > HitObject.RequiredHits / 2 ? HitResult.Ok : r.Judgement.MinResult); + ApplyResult(r => r.Type = numHits == HitObject.RequiredHits ? r.Judgement.MaxResult : r.Judgement.MinResult); } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.cs index d4ea9ed29f..3a5c006962 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using osu.Framework.Graphics; using osu.Framework.Input.Events; @@ -9,7 +11,7 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { - public class DrawableSwellTick : DrawableTaikoHitObject + public partial class DrawableSwellTick : DrawableTaikoHitObject { public override bool DisplayResult => false; @@ -37,7 +39,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables public override bool OnPressed(KeyBindingPressEvent e) => false; - protected override SkinnableDrawable CreateMainPiece() => new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.DrumRollTick), + protected override SkinnableDrawable CreateMainPiece() => new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.DrumRollTick), _ => new TickPiece()); } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs index eb64ba72f2..f695c505a4 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; @@ -16,13 +18,22 @@ using osuTK; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { - public abstract class DrawableTaikoHitObject : DrawableHitObject, IKeyBindingHandler + public abstract partial class DrawableTaikoHitObject : DrawableHitObject, IKeyBindingHandler { protected readonly Container Content; private readonly Container proxiedContent; private readonly Container nonProxiedContent; + /// + /// Whether the location of the hit should be snapped to the hit target before animating. + /// + /// + /// This is how osu-stable worked, but notably is not how TnT works. + /// Not snapping results in less visual feedback on hit accuracy. + /// + public bool SnapJudgementLocation { get; set; } + protected DrawableTaikoHitObject([CanBeNull] TaikoHitObject hitObject) : base(hitObject) { @@ -54,7 +65,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables isProxied = true; - nonProxiedContent.Remove(Content); + nonProxiedContent.Remove(Content, false); proxiedContent.Add(Content); } @@ -68,7 +79,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables isProxied = false; - proxiedContent.Remove(Content); + proxiedContent.Remove(Content, false); nonProxiedContent.Add(Content); } @@ -103,13 +114,13 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables } } - private class ProxiedContentContainer : Container + private partial class ProxiedContentContainer : Container { public override bool RemoveWhenNotAlive => false; } } - public abstract class DrawableTaikoHitObject : DrawableTaikoHitObject + public abstract partial class DrawableTaikoHitObject : DrawableTaikoHitObject where TObject : TaikoHitObject { public override Vector2 OriginPosition => new Vector2(DrawHeight / 2); @@ -131,6 +142,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables protected override void OnApply() { base.OnApply(); + + // TODO: THIS CANNOT BE HERE, it makes pooling pointless (see https://github.com/ppy/osu/issues/21072). RecreatePieces(); } @@ -139,7 +152,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables Size = BaseSize = new Vector2(TaikoHitObject.DEFAULT_SIZE); if (MainPiece != null) - Content.Remove(MainPiece); + Content.Remove(MainPiece, true); Content.Add(MainPiece = CreateMainPiece()); } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs index 70d4371e99..4d7cdf3243 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; @@ -10,7 +12,7 @@ using osuTK; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { - public abstract class DrawableTaikoStrongableHitObject : DrawableTaikoHitObject + public abstract partial class DrawableTaikoStrongableHitObject : DrawableTaikoHitObject where TObject : TaikoStrongableHitObject where TStrongNestedObject : StrongNestedHitObject { diff --git a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs index 0e93ad7e73..3325eda7cf 100644 --- a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs @@ -1,8 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Objects.Types; -using System; using System.Threading; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -10,7 +11,6 @@ using osu.Game.Beatmaps.Formats; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; -using osu.Game.Rulesets.Taiko.Judgements; using osuTK; namespace osu.Game.Rulesets.Taiko.Objects @@ -40,24 +40,12 @@ namespace osu.Game.Rulesets.Taiko.Objects /// public int TickRate = 1; - /// - /// Number of drum roll ticks required for a "Good" hit. - /// - public double RequiredGoodHits { get; protected set; } - - /// - /// Number of drum roll ticks required for a "Great" hit. - /// - public double RequiredGreatHits { get; protected set; } - /// /// The length (in milliseconds) between ticks of this drumroll. /// Half of this value is the hit window of the ticks. /// private double tickSpacing = 100; - private float overallDifficulty = BeatmapDifficulty.DEFAULT_DIFFICULTY; - protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); @@ -68,16 +56,12 @@ namespace osu.Game.Rulesets.Taiko.Objects Velocity = scoringDistance / timingPoint.BeatLength; tickSpacing = timingPoint.BeatLength / TickRate; - overallDifficulty = difficulty.OverallDifficulty; } protected override void CreateNestedHitObjects(CancellationToken cancellationToken) { createTicks(cancellationToken); - RequiredGoodHits = NestedHitObjects.Count * Math.Min(0.15, 0.05 + 0.10 / 6 * overallDifficulty); - RequiredGreatHits = NestedHitObjects.Count * Math.Min(0.30, 0.10 + 0.20 / 6 * overallDifficulty); - base.CreateNestedHitObjects(cancellationToken); } @@ -104,7 +88,7 @@ namespace osu.Game.Rulesets.Taiko.Objects } } - public override Judgement CreateJudgement() => new TaikoDrumRollJudgement(); + public override Judgement CreateJudgement() => new IgnoreJudgement(); protected override HitWindows CreateHitWindows() => HitWindows.Empty; @@ -112,6 +96,8 @@ namespace osu.Game.Rulesets.Taiko.Objects public class StrongNestedHit : StrongNestedHitObject { + // The strong hit of the drum roll doesn't actually provide any score. + public override Judgement CreateJudgement() => new IgnoreJudgement(); } #region LegacyBeatmapEncoder diff --git a/osu.Game.Rulesets.Taiko/Objects/DrumRollTick.cs b/osu.Game.Rulesets.Taiko/Objects/DrumRollTick.cs index 9d0336441e..6bcb8674e6 100644 --- a/osu.Game.Rulesets.Taiko/Objects/DrumRollTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/DrumRollTick.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Judgements; @@ -29,6 +31,8 @@ namespace osu.Game.Rulesets.Taiko.Objects protected override HitWindows CreateHitWindows() => HitWindows.Empty; + public override double MaximumJudgementOffset => HitWindow; + protected override StrongNestedHitObject CreateStrongNestedHit(double startTime) => new StrongNestedHit { StartTime = startTime }; public class StrongNestedHit : StrongNestedHitObject diff --git a/osu.Game.Rulesets.Taiko/Objects/Hit.cs b/osu.Game.Rulesets.Taiko/Objects/Hit.cs index 2038da9344..787079bfee 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Hit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Hit.cs @@ -1,10 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Game.Audio; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osuTK.Graphics; @@ -12,19 +15,21 @@ namespace osu.Game.Rulesets.Taiko.Objects { public class Hit : TaikoStrongableHitObject, IHasDisplayColour { - public readonly Bindable TypeBindable = new Bindable(); + private HitObjectProperty type; - public Bindable DisplayColour { get; } = new Bindable(COLOUR_CENTRE); + public Bindable TypeBindable => type.Bindable; /// /// The that actuates this . /// public HitType Type { - get => TypeBindable.Value; - set => TypeBindable.Value = value; + get => type.Value; + set => type.Value = value; } + public Bindable DisplayColour { get; } = new Bindable(COLOUR_CENTRE); + public static readonly Color4 COLOUR_CENTRE = Color4Extensions.FromHex(@"bb1177"); public static readonly Color4 COLOUR_RIM = Color4Extensions.FromHex(@"2299bb"); @@ -36,7 +41,7 @@ namespace osu.Game.Rulesets.Taiko.Objects DisplayColour.Value = Type == HitType.Centre ? COLOUR_CENTRE : COLOUR_RIM; }); - SamplesBindable.BindCollectionChanged((_, __) => updateTypeFromSamples()); + SamplesBindable.BindCollectionChanged((_, _) => updateTypeFromSamples()); } private void updateTypeFromSamples() diff --git a/osu.Game.Rulesets.Taiko/Objects/HitType.cs b/osu.Game.Rulesets.Taiko/Objects/HitType.cs index 17b3fdbd04..eae7fa683a 100644 --- a/osu.Game.Rulesets.Taiko/Objects/HitType.cs +++ b/osu.Game.Rulesets.Taiko/Objects/HitType.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Taiko.Objects { /// diff --git a/osu.Game.Rulesets.Taiko/Objects/IgnoreHit.cs b/osu.Game.Rulesets.Taiko/Objects/IgnoreHit.cs index 302f940ef4..18f47b7cff 100644 --- a/osu.Game.Rulesets.Taiko/Objects/IgnoreHit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/IgnoreHit.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; namespace osu.Game.Rulesets.Taiko.Objects diff --git a/osu.Game.Rulesets.Taiko/Objects/StrongNestedHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/StrongNestedHitObject.cs index 3b427e48c5..628c41d878 100644 --- a/osu.Game.Rulesets.Taiko/Objects/StrongNestedHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/StrongNestedHitObject.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Judgements; diff --git a/osu.Game.Rulesets.Taiko/Objects/Swell.cs b/osu.Game.Rulesets.Taiko/Objects/Swell.cs index eeae6e79f8..cb91c46b4d 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Swell.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Swell.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Threading; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Judgements; diff --git a/osu.Game.Rulesets.Taiko/Objects/SwellTick.cs b/osu.Game.Rulesets.Taiko/Objects/SwellTick.cs index bdc0478195..43830cb528 100644 --- a/osu.Game.Rulesets.Taiko/Objects/SwellTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/SwellTick.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; diff --git a/osu.Game.Rulesets.Taiko/Objects/TaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/TaikoHitObject.cs index 1a1fde1990..3aba5c571b 100644 --- a/osu.Game.Rulesets.Taiko/Objects/TaikoHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/TaikoHitObject.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; diff --git a/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs index 6e0f6a3109..d4d59d5d44 100644 --- a/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using System.Threading; using osu.Framework.Bindables; @@ -39,7 +41,7 @@ namespace osu.Game.Rulesets.Taiko.Objects protected TaikoStrongableHitObject() { IsStrongBindable.BindValueChanged(_ => updateSamplesFromType()); - SamplesBindable.BindCollectionChanged((_, __) => updateTypeFromSamples()); + SamplesBindable.BindCollectionChanged((_, _) => updateTypeFromSamples()); } private void updateTypeFromSamples() diff --git a/osu.Game.Rulesets.Taiko/Properties/AssemblyInfo.cs b/osu.Game.Rulesets.Taiko/Properties/AssemblyInfo.cs index ca7d04876e..5b66e18a6d 100644 --- a/osu.Game.Rulesets.Taiko/Properties/AssemblyInfo.cs +++ b/osu.Game.Rulesets.Taiko/Properties/AssemblyInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Runtime.CompilerServices; // We publish our internal attributes to other sub-projects of the framework. diff --git a/osu.Game.Rulesets.Taiko/Replays/TaikoAutoGenerator.cs b/osu.Game.Rulesets.Taiko/Replays/TaikoAutoGenerator.cs index 5fd281f9fa..11136ad695 100644 --- a/osu.Game.Rulesets.Taiko/Replays/TaikoAutoGenerator.cs +++ b/osu.Game.Rulesets.Taiko/Replays/TaikoAutoGenerator.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using osu.Framework.Extensions.ObjectExtensions; using osu.Game.Beatmaps; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Replays; @@ -117,7 +118,7 @@ namespace osu.Game.Rulesets.Taiko.Replays var nextHitObject = GetNextObject(i); // Get the next object that requires pressing the same button bool canDelayKeyUp = nextHitObject == null || nextHitObject.StartTime > endTime + KEY_UP_DELAY; - double calculatedDelay = canDelayKeyUp ? KEY_UP_DELAY : (nextHitObject.StartTime - endTime) * 0.9; + double calculatedDelay = canDelayKeyUp ? KEY_UP_DELAY : (nextHitObject.AsNonNull().StartTime - endTime) * 0.9; Frames.Add(new TaikoReplayFrame(endTime + calculatedDelay)); hitButton = !hitButton; diff --git a/osu.Game.Rulesets.Taiko/Replays/TaikoReplayFrame.cs b/osu.Game.Rulesets.Taiko/Replays/TaikoReplayFrame.cs index d2a7329a28..a0a687dca6 100644 --- a/osu.Game.Rulesets.Taiko/Replays/TaikoReplayFrame.cs +++ b/osu.Game.Rulesets.Taiko/Replays/TaikoReplayFrame.cs @@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Taiko.Replays Actions.AddRange(actions); } - public void FromLegacy(LegacyReplayFrame currentFrame, IBeatmap beatmap, ReplayFrame lastFrame = null) + public void FromLegacy(LegacyReplayFrame currentFrame, IBeatmap beatmap, ReplayFrame? lastFrame = null) { if (currentFrame.MouseRight1) Actions.Add(TaikoAction.LeftRim); if (currentFrame.MouseRight2) Actions.Add(TaikoAction.RightRim); diff --git a/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/file-hitsamples-expected-conversion.json b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/file-hitsamples-expected-conversion.json new file mode 100644 index 0000000000..70348a3871 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/file-hitsamples-expected-conversion.json @@ -0,0 +1 @@ +{"Mappings":[{"StartTime":500.0,"Objects":[{"StartTime":500.0,"EndTime":500.0,"IsRim":false,"IsCentre":true,"IsDrumRoll":false,"IsSwell":false,"IsStrong":false}]},{"StartTime":1000.0,"Objects":[{"StartTime":1000.0,"EndTime":1000.0,"IsRim":true,"IsCentre":false,"IsDrumRoll":false,"IsSwell":false,"IsStrong":false}]},{"StartTime":1500.0,"Objects":[{"StartTime":1500.0,"EndTime":1500.0,"IsRim":true,"IsCentre":false,"IsDrumRoll":false,"IsSwell":false,"IsStrong":false}]},{"StartTime":2000.0,"Objects":[{"StartTime":2000.0,"EndTime":2000.0,"IsRim":true,"IsCentre":false,"IsDrumRoll":false,"IsSwell":false,"IsStrong":false}]},{"StartTime":2500.0,"Objects":[{"StartTime":2500.0,"EndTime":2500.0,"IsRim":false,"IsCentre":true,"IsDrumRoll":false,"IsSwell":false,"IsStrong":true}]},{"StartTime":3000.0,"Objects":[{"StartTime":3000.0,"EndTime":3000.0,"IsRim":true,"IsCentre":false,"IsDrumRoll":false,"IsSwell":false,"IsStrong":true}]},{"StartTime":3500.0,"Objects":[{"StartTime":3500.0,"EndTime":3500.0,"IsRim":true,"IsCentre":false,"IsDrumRoll":false,"IsSwell":false,"IsStrong":true}]},{"StartTime":4000.0,"Objects":[{"StartTime":4000.0,"EndTime":4000.0,"IsRim":true,"IsCentre":false,"IsDrumRoll":false,"IsSwell":false,"IsStrong":true}]}]} \ No newline at end of file diff --git a/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/file-hitsamples.osu b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/file-hitsamples.osu new file mode 100644 index 0000000000..5d4bcb52a1 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/file-hitsamples.osu @@ -0,0 +1,22 @@ +osu file format v14 + +[Difficulty] +HPDrainRate:5 +CircleSize:7 +OverallDifficulty:6.5 +ApproachRate:10 +SliderMultiplier:1.9 +SliderTickRate:1 + +[TimingPoints] +500,500,4,2,1,50,1,0 + +[HitObjects] +256,192,500,1,0,0:0:0:0:sample.ogg +256,192,1000,1,8,0:0:0:0:sample.ogg +256,192,1500,1,2,0:0:0:0:sample.ogg +256,192,2000,1,10,0:0:0:0:sample.ogg +256,192,2500,1,4,0:0:0:0:sample.ogg +256,192,3000,1,12,0:0:0:0:sample.ogg +256,192,3500,1,6,0:0:0:0:sample.ogg +256,192,4000,1,14,0:0:0:0:sample.ogg diff --git a/osu.Game.Rulesets.Taiko/Scoring/TaikoHealthProcessor.cs b/osu.Game.Rulesets.Taiko/Scoring/TaikoHealthProcessor.cs index 0d6ce44255..7c70beb0a4 100644 --- a/osu.Game.Rulesets.Taiko/Scoring/TaikoHealthProcessor.cs +++ b/osu.Game.Rulesets.Taiko/Scoring/TaikoHealthProcessor.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using osu.Game.Beatmaps; @@ -14,7 +16,7 @@ namespace osu.Game.Rulesets.Taiko.Scoring /// A for the taiko ruleset. /// Taiko fails if the player has not half-filled their health by the end of the map. /// - public class TaikoHealthProcessor : AccumulatingHealthProcessor + public partial class TaikoHealthProcessor : AccumulatingHealthProcessor { /// /// A value used for calculating . diff --git a/osu.Game.Rulesets.Taiko/Scoring/TaikoHitWindows.cs b/osu.Game.Rulesets.Taiko/Scoring/TaikoHitWindows.cs index cf806c0c97..896af24772 100644 --- a/osu.Game.Rulesets.Taiko/Scoring/TaikoHitWindows.cs +++ b/osu.Game.Rulesets.Taiko/Scoring/TaikoHitWindows.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Taiko.Scoring diff --git a/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs index bacc22714e..4b60ee3ccb 100644 --- a/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs +++ b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Taiko.Scoring { - internal class TaikoScoreProcessor : ScoreProcessor + internal partial class TaikoScoreProcessor : ScoreProcessor { public TaikoScoreProcessor() : base(new TaikoRuleset()) diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonBarLine.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonBarLine.cs new file mode 100644 index 0000000000..32afb8e6ac --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonBarLine.cs @@ -0,0 +1,83 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public partial class ArgonBarLine : CompositeDrawable + { + private Container majorEdgeContainer = null!; + + private Bindable major = null!; + + [BackgroundDependencyLoader] + private void load(DrawableHitObject drawableHitObject) + { + RelativeSizeAxes = Axes.Both; + + const float line_offset = 8; + var majorPieceSize = new Vector2(6, 20); + + InternalChildren = new Drawable[] + { + line = new Box + { + RelativeSizeAxes = Axes.Both, + EdgeSmoothness = new Vector2(0.5f, 0), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + majorEdgeContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Children = new[] + { + new Circle + { + Name = "Top line", + Anchor = Anchor.TopCentre, + Origin = Anchor.BottomCentre, + Size = majorPieceSize, + Y = -line_offset, + }, + new Circle + { + Name = "Bottom line", + Anchor = Anchor.BottomCentre, + Origin = Anchor.TopCentre, + Size = majorPieceSize, + Y = line_offset, + }, + } + } + }; + + major = ((DrawableBarLine)drawableHitObject).Major.GetBoundCopy(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + major.BindValueChanged(updateMajor, true); + } + + private Box line = null!; + + private void updateMajor(ValueChangedEvent major) + { + line.Alpha = major.NewValue ? 1f : 0.5f; + line.Width = major.NewValue ? 1 : 0.5f; + majorEdgeContainer.Alpha = major.NewValue ? 1 : 0; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonCentreCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonCentreCirclePiece.cs new file mode 100644 index 0000000000..dedb276d58 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonCentreCirclePiece.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public partial class ArgonCentreCirclePiece : ArgonCirclePiece + { + [BackgroundDependencyLoader] + private void load() + { + AccentColour = ColourInfo.GradientVertical( + new Color4(241, 0, 0, 255), + new Color4(167, 0, 0, 255) + ); + + AddInternal(new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Icon = FontAwesome.Solid.AngleLeft, + Size = new Vector2(ICON_SIZE), + Scale = new Vector2(0.8f, 1) + }); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonCirclePiece.cs new file mode 100644 index 0000000000..d7e37899ce --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonCirclePiece.cs @@ -0,0 +1,116 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Audio.Track; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public abstract partial class ArgonCirclePiece : BeatSyncedContainer + { + public const float ICON_SIZE = 20 / 70f; + + private const double pre_beat_transition_time = 80; + + private const float flash_opacity = 0.3f; + + private ColourInfo accentColour; + + /// + /// The colour of the inner circle and outer glows. + /// + public ColourInfo AccentColour + { + get => accentColour; + set + { + accentColour = value; + + ring.Colour = AccentColour.MultiplyAlpha(0.5f); + ring2.Colour = AccentColour; + } + } + + [Resolved] + private DrawableHitObject drawableHitObject { get; set; } = null!; + + private readonly Drawable flash; + + private readonly RingPiece ring; + private readonly RingPiece ring2; + + protected ArgonCirclePiece() + { + RelativeSizeAxes = Axes.Both; + + EarlyActivationMilliseconds = pre_beat_transition_time; + + AddRangeInternal(new[] + { + new Circle + { + RelativeSizeAxes = Axes.Both, + Colour = new Color4(0, 0, 0, 190) + }, + ring = new RingPiece(20 / 70f), + ring2 = new RingPiece(5 / 70f), + flash = new Circle + { + Name = "Flash layer", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Alpha = 0, + }, + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + drawableHitObject.ApplyCustomUpdateState += updateStateTransforms; + updateStateTransforms(drawableHitObject, drawableHitObject.State.Value); + } + + private void updateStateTransforms(DrawableHitObject h, ArmedState state) + { + if (h.HitObject is not Hit) + return; + + switch (state) + { + case ArmedState.Hit: + using (BeginAbsoluteSequence(h.HitStateUpdateTime)) + { + flash.FadeTo(0.9f).FadeOut(500, Easing.OutQuint); + } + + break; + } + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + if (!effectPoint.KiaiMode) + return; + + if (drawableHitObject.State.Value == ArmedState.Idle) + { + flash + .FadeTo(flash_opacity) + .Then() + .FadeOut(timingPoint.BeatLength * 0.75, Easing.OutSine); + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonElongatedCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonElongatedCirclePiece.cs new file mode 100644 index 0000000000..17386cc659 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonElongatedCirclePiece.cs @@ -0,0 +1,33 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public partial class ArgonElongatedCirclePiece : ArgonCirclePiece + { + public ArgonElongatedCirclePiece() + { + RelativeSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + AccentColour = ColourInfo.GradientVertical( + new Color4(241, 161, 0, 255), + new Color4(167, 111, 0, 255) + ); + } + + protected override void Update() + { + base.Update(); + Width = Parent.DrawSize.X + DrawHeight; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonHitExplosion.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonHitExplosion.cs new file mode 100644 index 0000000000..a47fd7e62e --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonHitExplosion.cs @@ -0,0 +1,87 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.UI; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public partial class ArgonHitExplosion : CompositeDrawable, IAnimatableHitExplosion + { + private readonly TaikoSkinComponents component; + private readonly Circle outer; + + public ArgonHitExplosion(TaikoSkinComponents component) + { + this.component = component; + + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + outer = new Circle + { + Name = "Outer circle", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientVertical( + new Color4(255, 227, 236, 255), + new Color4(255, 198, 211, 255) + ), + Masking = true, + }, + new Circle + { + Name = "Inner circle", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Colour = Color4.White, + Size = new Vector2(0.85f), + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = new Color4(255, 132, 191, 255).Opacity(0.5f), + Radius = 45, + }, + Masking = true, + }, + }; + } + + public void Animate(DrawableHitObject drawableHitObject) + { + this.FadeOut(); + + switch (component) + { + case TaikoSkinComponents.TaikoExplosionGreat: + this.FadeIn(30, Easing.In) + .Then() + .FadeOut(450, Easing.OutQuint); + break; + + case TaikoSkinComponents.TaikoExplosionOk: + this.FadeTo(0.2f, 30, Easing.In) + .Then() + .FadeOut(200, Easing.OutQuint); + break; + } + } + + public void AnimateSecondHit() + { + outer.ResizeTo(new Vector2(TaikoStrongableHitObject.STRONG_SCALE), 500, Easing.OutQuint); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonHitTarget.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonHitTarget.cs new file mode 100644 index 0000000000..0ddcd6feb2 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonHitTarget.cs @@ -0,0 +1,72 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Taiko.Objects; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public partial class ArgonHitTarget : CompositeDrawable + { + /// + /// Thickness of all drawn line pieces. + /// + public ArgonHitTarget() + { + RelativeSizeAxes = Axes.Both; + Masking = true; + + const float border_thickness = 4f; + + InternalChildren = new Drawable[] + { + new Circle + { + Name = "Bar Upper", + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Y = -border_thickness, + RelativeSizeAxes = Axes.Y, + Size = new Vector2(border_thickness, (1 - TaikoStrongableHitObject.DEFAULT_STRONG_SIZE)), + }, + new Circle + { + Name = "Outer circle", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Colour = Color4.White, + Blending = BlendingParameters.Additive, + Alpha = 0.1f, + Size = new Vector2(TaikoHitObject.DEFAULT_SIZE), + Masking = true, + }, + new Circle + { + Name = "Inner circle", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Colour = Color4.White, + Blending = BlendingParameters.Additive, + Alpha = 0.1f, + Size = new Vector2(TaikoHitObject.DEFAULT_SIZE * 0.85f), + Masking = true, + }, + new Circle + { + Name = "Bar Lower", + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.Y, + Y = border_thickness, + Size = new Vector2(border_thickness, (1 - TaikoStrongableHitObject.DEFAULT_STRONG_SIZE)), + }, + }; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonInputDrum.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonInputDrum.cs new file mode 100644 index 0000000000..e7b0a5537a --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonInputDrum.cs @@ -0,0 +1,218 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Screens.Ranking; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public partial class ArgonInputDrum : AspectContainer + { + private const float rim_size = 0.3f; + + public ArgonInputDrum() + { + RelativeSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + const float middle_split = 6; + + InternalChild = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Scale = new Vector2(0.9f), + Children = new Drawable[] + { + new TaikoHalfDrum(false) + { + Name = "Left Half", + Anchor = Anchor.Centre, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Both, + RimAction = TaikoAction.LeftRim, + CentreAction = TaikoAction.LeftCentre + }, + new TaikoHalfDrum(true) + { + Name = "Right Half", + Anchor = Anchor.Centre, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Both, + RimAction = TaikoAction.RightRim, + CentreAction = TaikoAction.RightCentre + }, + new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Masking = true, + Children = new Drawable[] + { + new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = OsuColour.Gray(38 / 255f), + Width = middle_split, + RelativeSizeAxes = Axes.Y, + }, + new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = OsuColour.Gray(48 / 255f), + Width = middle_split, + Height = 1 - rim_size, + RelativeSizeAxes = Axes.Y, + }, + }, + } + } + }; + } + + /// + /// A half-drum. Contains one centre and one rim hit. + /// + private partial class TaikoHalfDrum : CompositeDrawable, IKeyBindingHandler + { + /// + /// The key to be used for the rim of the half-drum. + /// + public TaikoAction RimAction; + + /// + /// The key to be used for the centre of the half-drum. + /// + public TaikoAction CentreAction; + + private readonly Drawable rimHit; + private readonly Drawable centreHit; + + public TaikoHalfDrum(bool flipped) + { + Anchor anchor = flipped ? Anchor.CentreLeft : Anchor.CentreRight; + + Masking = true; + + Anchor = anchor; + Origin = anchor; + + RelativeSizeAxes = Axes.Both; + // Extend maskable region for glow. + Height = 2f; + + InternalChildren = new Drawable[] + { + new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + Children = new[] + { + new Circle + { + Anchor = anchor, + Colour = OsuColour.Gray(51 / 255f), + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both + }, + rimHit = new Circle + { + Anchor = anchor, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal( + new Color4(227, 248, 255, 255), + new Color4(198, 245, 255, 255) + ), + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = new Color4(126, 215, 253, 170), + Radius = 50, + }, + Alpha = 0, + }, + new Circle + { + Anchor = anchor, + Origin = Anchor.Centre, + Colour = OsuColour.Gray(64 / 255f), + RelativeSizeAxes = Axes.Both, + Size = new Vector2(1 - rim_size) + }, + centreHit = new Circle + { + Anchor = anchor, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal( + new Color4(255, 227, 236, 255), + new Color4(255, 198, 211, 255) + ), + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = new Color4(255, 147, 199, 255), + Radius = 50, + }, + Size = new Vector2(1 - rim_size), + Alpha = 0, + } + }, + }, + }; + } + + public bool OnPressed(KeyBindingPressEvent e) + { + Drawable? target = null; + + if (e.Action == CentreAction) + target = centreHit; + else if (e.Action == RimAction) + target = rimHit; + + if (target != null) + { + const float alpha_amount = 0.5f; + + const float down_time = 40; + const float up_time = 750; + + target.Animate( + t => t.FadeTo(Math.Min(target.Alpha + alpha_amount, 1), down_time, Easing.OutQuint) + ).Then( + t => t.FadeOut(up_time, Easing.OutQuint) + ); + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonJudgementPiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonJudgementPiece.cs new file mode 100644 index 0000000000..bbd62ff85b --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonJudgementPiece.cs @@ -0,0 +1,189 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public partial class ArgonJudgementPiece : JudgementPiece, IAnimatableJudgement + { + private RingExplosion? ringExplosion; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public ArgonJudgementPiece(HitResult result) + : base(result) + { + RelativePositionAxes = Axes.Both; + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + if (Result.IsHit()) + { + AddInternal(ringExplosion = new RingExplosion(Result) + { + Colour = colours.ForHitResult(Result), + RelativePositionAxes = Axes.Y, + }); + } + } + + protected override SpriteText CreateJudgementText() => + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Blending = BlendingParameters.Additive, + Spacing = new Vector2(10, 0), + RelativePositionAxes = Axes.Both, + Font = OsuFont.Default.With(size: 20, weight: FontWeight.Regular), + }; + + /// + /// Plays the default animation for this judgement piece. + /// + /// + /// The base implementation only handles fade (for all result types) and misses. + /// Individual rulesets are recommended to implement their appropriate hit animations. + /// + public virtual void PlayAnimation() + { + const double duration = 800; + + switch (Result) + { + default: + JudgementText.MoveToY(-0.6f) + .MoveToY(-1.0f, duration, Easing.OutQuint); + + JudgementText + .ScaleTo(Vector2.One) + .ScaleTo(new Vector2(1.4f), duration, Easing.OutQuint); + break; + + case HitResult.Miss: + this.ScaleTo(1.6f); + this.ScaleTo(1, 100, Easing.In); + + JudgementText.MoveTo(Vector2.Zero); + JudgementText.MoveToOffset(new Vector2(0, 100), duration, Easing.InQuint); + + this.RotateTo(0); + this.RotateTo(40, duration, Easing.InQuint); + break; + } + + this.FadeOutFromOne(duration, Easing.OutQuint); + + ringExplosion?.PlayAnimation(); + } + + public Drawable? GetAboveHitObjectsProxiedContent() => null; + + private partial class RingExplosion : CompositeDrawable + { + private readonly float travel = 58; + + public RingExplosion(HitResult result) + { + const float thickness = 4; + + const float small_size = 9; + const float large_size = 14; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Blending = BlendingParameters.Additive; + + int countSmall = 0; + int countLarge = 0; + + switch (result) + { + case HitResult.Meh: + countSmall = 3; + travel *= 0.3f; + break; + + case HitResult.Ok: + case HitResult.Good: + countSmall = 4; + travel *= 0.6f; + break; + + case HitResult.Great: + case HitResult.Perfect: + countSmall = 4; + countLarge = 4; + break; + } + + for (int i = 0; i < countSmall; i++) + AddInternal(new RingPiece(thickness) { Size = new Vector2(small_size) }); + + for (int i = 0; i < countLarge; i++) + AddInternal(new RingPiece(thickness) { Size = new Vector2(large_size) }); + } + + public void PlayAnimation() + { + foreach (var c in InternalChildren) + { + const float start_position_ratio = 0.6f; + + float direction = RNG.NextSingle(0, 360); + float distance = RNG.NextSingle(travel / 2, travel); + + c.MoveTo(new Vector2( + MathF.Cos(direction) * distance * start_position_ratio, + MathF.Sin(direction) * distance * start_position_ratio + )); + + c.MoveTo(new Vector2( + MathF.Cos(direction) * distance, + MathF.Sin(direction) * distance + ), 600, Easing.OutQuint); + } + + this.FadeOutFromOne(1000, Easing.OutQuint); + } + + public partial class RingPiece : CircularContainer + { + public RingPiece(float thickness = 9) + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Masking = true; + BorderThickness = thickness; + BorderColour = Color4.White; + + Child = new Box + { + AlwaysPresent = true, + Alpha = 0, + RelativeSizeAxes = Axes.Both + }; + } + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonPlayfieldBackgroundLeft.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonPlayfieldBackgroundLeft.cs new file mode 100644 index 0000000000..07ac37b04c --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonPlayfieldBackgroundLeft.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public partial class ArgonPlayfieldBackgroundLeft : CompositeDrawable + { + public ArgonPlayfieldBackgroundLeft() + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + }, + }; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonPlayfieldBackgroundRight.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonPlayfieldBackgroundRight.cs new file mode 100644 index 0000000000..41a3e3bb33 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonPlayfieldBackgroundRight.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public partial class ArgonPlayfieldBackgroundRight : CompositeDrawable + { + public ArgonPlayfieldBackgroundRight() + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = Color4.Black, + Alpha = 0.7f, + RelativeSizeAxes = Axes.Both, + }, + }; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonRimCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonRimCirclePiece.cs new file mode 100644 index 0000000000..b32326a90f --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonRimCirclePiece.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public partial class ArgonRimCirclePiece : ArgonCirclePiece + { + [BackgroundDependencyLoader] + private void load() + { + AccentColour = ColourInfo.GradientVertical( + new Color4(0, 161, 241, 255), + new Color4(0, 111, 167, 255) + ); + + AddInternal(new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Icon = FontAwesome.Solid.AngleLeft, + Size = new Vector2(ICON_SIZE), + Scale = new Vector2(0.8f, 1) + }); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonSwellCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonSwellCirclePiece.cs new file mode 100644 index 0000000000..d93d8598f6 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonSwellCirclePiece.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public partial class ArgonSwellCirclePiece : ArgonCirclePiece + { + [BackgroundDependencyLoader] + private void load() + { + AccentColour = ColourInfo.GradientVertical( + new Color4(240, 201, 0, 255), + new Color4(167, 139, 0, 255) + ); + + AddInternal(new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Icon = FontAwesome.Solid.Asterisk, + Size = new Vector2(ICON_SIZE), + Scale = new Vector2(0.8f, 1) + }); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonTickPiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonTickPiece.cs new file mode 100644 index 0000000000..5baa320201 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonTickPiece.cs @@ -0,0 +1,68 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public partial class ArgonTickPiece : CompositeDrawable + { + private readonly Bindable isFirstTick = new Bindable(); + + public ArgonTickPiece() + { + const float tick_size = 1 / TaikoHitObject.DEFAULT_SIZE * ArgonCirclePiece.ICON_SIZE; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + RelativeSizeAxes = Axes.Both; + FillMode = FillMode.Fit; + Size = new Vector2(tick_size); + } + + [Resolved] + private DrawableHitObject drawableHitObject { get; set; } = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (drawableHitObject is DrawableDrumRollTick drumRollTick) + isFirstTick.BindTo(drumRollTick.IsFirstTick); + + isFirstTick.BindValueChanged(first => + { + if (first.NewValue) + { + InternalChild = new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both + }; + } + else + { + InternalChild = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Icon = FontAwesome.Solid.AngleLeft, + Scale = new Vector2(0.8f, 1) + }; + } + }, true); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/RingPiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/RingPiece.cs new file mode 100644 index 0000000000..719c1c36fa --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/RingPiece.cs @@ -0,0 +1,40 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public partial class RingPiece : CircularContainer + { + private readonly float relativeBorderThickness; + + public RingPiece(float relativeBorderThickness) + { + this.relativeBorderThickness = relativeBorderThickness; + RelativeSizeAxes = Axes.Both; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Masking = true; + BorderColour = Color4.White; + + Child = new Box + { + AlwaysPresent = true, + Alpha = 0, + RelativeSizeAxes = Axes.Both + }; + } + + protected override void Update() + { + base.Update(); + BorderThickness = relativeBorderThickness * DrawSize.Y; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs new file mode 100644 index 0000000000..780018af4e --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/TaikoArgonSkinTransformer.cs @@ -0,0 +1,78 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Taiko.Skinning.Argon +{ + public class TaikoArgonSkinTransformer : SkinTransformer + { + public TaikoArgonSkinTransformer(ISkin skin) + : base(skin) + { + } + + public override Drawable? GetDrawableComponent(ISkinComponentLookup component) + { + switch (component) + { + case GameplaySkinComponentLookup resultComponent: + // This should eventually be moved to a skin setting, when supported. + if (Skin is ArgonProSkin && resultComponent.Component >= HitResult.Great) + return Drawable.Empty(); + + return new ArgonJudgementPiece(resultComponent.Component); + + case TaikoSkinComponentLookup taikoComponent: + // TODO: Once everything is finalised, consider throwing UnsupportedSkinComponentException on missing entries. + switch (taikoComponent.Component) + { + case TaikoSkinComponents.CentreHit: + return new ArgonCentreCirclePiece(); + + case TaikoSkinComponents.RimHit: + return new ArgonRimCirclePiece(); + + case TaikoSkinComponents.PlayfieldBackgroundLeft: + return new ArgonPlayfieldBackgroundLeft(); + + case TaikoSkinComponents.PlayfieldBackgroundRight: + return new ArgonPlayfieldBackgroundRight(); + + case TaikoSkinComponents.InputDrum: + return new ArgonInputDrum(); + + case TaikoSkinComponents.HitTarget: + return new ArgonHitTarget(); + + case TaikoSkinComponents.BarLine: + return new ArgonBarLine(); + + case TaikoSkinComponents.DrumRollBody: + return new ArgonElongatedCirclePiece(); + + case TaikoSkinComponents.DrumRollTick: + return new ArgonTickPiece(); + + case TaikoSkinComponents.TaikoExplosionKiai: + // the drawable needs to expire as soon as possible to avoid accumulating empty drawables on the playfield. + return Drawable.Empty().With(d => d.Expire()); + + case TaikoSkinComponents.TaikoExplosionGreat: + case TaikoSkinComponents.TaikoExplosionMiss: + case TaikoSkinComponents.TaikoExplosionOk: + return new ArgonHitExplosion(taikoComponent.Component); + + case TaikoSkinComponents.Swell: + return new ArgonSwellCirclePiece(); + } + + break; + } + + return base.GetDrawableComponent(component); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/CentreHitCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/CentreHitCirclePiece.cs index 25f895708f..4ed5c3d0df 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Default/CentreHitCirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/CentreHitCirclePiece.cs @@ -10,7 +10,7 @@ using osuTK; namespace osu.Game.Rulesets.Taiko.Skinning.Default { - public class CentreHitCirclePiece : CirclePiece + public partial class CentreHitCirclePiece : CirclePiece { public CentreHitCirclePiece() { @@ -26,7 +26,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default /// /// The symbol used for centre hit pieces. /// - public class CentreHitSymbolPiece : Container + public partial class CentreHitSymbolPiece : Container { public CentreHitSymbolPiece() { @@ -39,12 +39,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default Children = new[] { - new CircularContainer - { - RelativeSizeAxes = Axes.Both, - Masking = true, - Children = new[] { new Box { RelativeSizeAxes = Axes.Both } } - } + new Circle { RelativeSizeAxes = Axes.Both } }; } } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs index f2452ad88c..bde502bbed 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; using osu.Framework.Audio.Track; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -11,6 +12,7 @@ using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Taiko.Objects; using osuTK.Graphics; @@ -23,13 +25,18 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default /// for a usage example. /// /// - public abstract class CirclePiece : BeatSyncedContainer, IHasAccentColour + public abstract partial class CirclePiece : BeatSyncedContainer, IHasAccentColour { public const float SYMBOL_SIZE = TaikoHitObject.DEFAULT_SIZE; public const float SYMBOL_BORDER = 8; private const double pre_beat_transition_time = 80; + private const float flash_opacity = 0.3f; + + [Resolved] + private DrawableHitObject drawableHitObject { get; set; } = null!; + private Color4 accentColour; /// @@ -70,7 +77,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default private readonly Container background; - public Box FlashBox; + private readonly Box flashBox; protected CirclePiece() { @@ -116,7 +123,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default Masking = true, Children = new[] { - FlashBox = new Box + flashBox = new Box { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -138,6 +145,28 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default }); } + protected override void LoadComplete() + { + base.LoadComplete(); + + drawableHitObject.ApplyCustomUpdateState += updateStateTransforms; + updateStateTransforms(drawableHitObject, drawableHitObject.State.Value); + } + + private void updateStateTransforms(DrawableHitObject h, ArmedState state) + { + if (h.HitObject is not Hit) + return; + + switch (state) + { + case ArmedState.Hit: + using (BeginAbsoluteSequence(h.HitStateUpdateTime)) + flashBox.FadeTo(0.9f).FadeOut(300); + break; + } + } + private const float edge_alpha_kiai = 0.5f; private void resetEdgeEffects() @@ -155,6 +184,14 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default if (!effectPoint.KiaiMode) return; + if (drawableHitObject.State.Value == ArmedState.Idle) + { + flashBox + .FadeTo(flash_opacity) + .Then() + .FadeOut(timingPoint.BeatLength * 0.75, Easing.OutSine); + } + if (beatIndex % timingPoint.TimeSignature.Numerator != 0) return; diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/DefaultBarLine.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/DefaultBarLine.cs new file mode 100644 index 0000000000..09965054e0 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/DefaultBarLine.cs @@ -0,0 +1,94 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.Skinning.Default +{ + public partial class DefaultBarLine : CompositeDrawable + { + /// + /// The vertical offset of the triangles from the line tracker. + /// + private const float triangle_offset = 10f; + + /// + /// The size of the triangles. + /// + private const float triangle_size = 20f; + + /// + /// Container with triangles. Only visible for major lines. + /// + private Container triangleContainer = null!; + + private Bindable major = null!; + + [BackgroundDependencyLoader] + private void load(DrawableHitObject drawableHitObject) + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + line = new Box + { + RelativeSizeAxes = Axes.Both, + EdgeSmoothness = new Vector2(0.5f, 0), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + triangleContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Children = new[] + { + new EquilateralTriangle + { + Name = "Top", + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Position = new Vector2(0, -triangle_offset), + Size = new Vector2(-triangle_size), + EdgeSmoothness = new Vector2(1), + }, + new EquilateralTriangle + { + Name = "Bottom", + Anchor = Anchor.BottomCentre, + Origin = Anchor.TopCentre, + Position = new Vector2(0, triangle_offset), + Size = new Vector2(triangle_size), + EdgeSmoothness = new Vector2(1), + } + } + } + }; + + major = ((DrawableBarLine)drawableHitObject).Major.GetBoundCopy(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + major.BindValueChanged(updateMajor, true); + } + + private Box line = null!; + + private void updateMajor(ValueChangedEvent major) + { + line.Alpha = major.NewValue ? 1f : 0.75f; + triangleContainer.Alpha = major.NewValue ? 1 : 0; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/DefaultHitExplosion.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/DefaultHitExplosion.cs similarity index 80% rename from osu.Game.Rulesets.Taiko/UI/DefaultHitExplosion.cs rename to osu.Game.Rulesets.Taiko/Skinning/Default/DefaultHitExplosion.cs index 91e844187a..8a70c4bde8 100644 --- a/osu.Game.Rulesets.Taiko/UI/DefaultHitExplosion.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/DefaultHitExplosion.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 JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,19 +9,20 @@ using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.UI; +using osuTK; using osuTK.Graphics; -namespace osu.Game.Rulesets.Taiko.UI +namespace osu.Game.Rulesets.Taiko.Skinning.Default { - internal class DefaultHitExplosion : CircularContainer, IAnimatableHitExplosion + internal partial class DefaultHitExplosion : CircularContainer, IAnimatableHitExplosion { private readonly HitResult result; - [CanBeNull] - private Box body; + private Box? body; [Resolved] - private OsuColour colours { get; set; } + private OsuColour colours { get; set; } = null!; public DefaultHitExplosion(HitResult result) { @@ -56,7 +56,7 @@ namespace osu.Game.Rulesets.Taiko.UI updateColour(); } - private void updateColour([CanBeNull] DrawableHitObject judgedObject = null) + private void updateColour(DrawableHitObject? judgedObject = null) { if (body == null) return; @@ -75,6 +75,7 @@ namespace osu.Game.Rulesets.Taiko.UI public void AnimateSecondHit() { + this.ResizeTo(new Vector2(TaikoStrongableHitObject.STRONG_SCALE), 50); } } } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/DefaultInputDrum.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/DefaultInputDrum.cs new file mode 100644 index 0000000000..6d19db999c --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/DefaultInputDrum.cs @@ -0,0 +1,180 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Screens.Ranking; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.Skinning.Default +{ + public partial class DefaultInputDrum : AspectContainer + { + public DefaultInputDrum() + { + RelativeSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + const float middle_split = 0.025f; + + InternalChild = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Scale = new Vector2(0.9f), + Children = new[] + { + new TaikoHalfDrum(false) + { + Name = "Left Half", + Anchor = Anchor.Centre, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.X, + X = -middle_split / 2, + RimAction = TaikoAction.LeftRim, + CentreAction = TaikoAction.LeftCentre + }, + new TaikoHalfDrum(true) + { + Name = "Right Half", + Anchor = Anchor.Centre, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.X, + X = middle_split / 2, + RimAction = TaikoAction.RightRim, + CentreAction = TaikoAction.RightCentre + } + } + }; + } + + /// + /// A half-drum. Contains one centre and one rim hit. + /// + private partial class TaikoHalfDrum : Container, IKeyBindingHandler + { + /// + /// The key to be used for the rim of the half-drum. + /// + public TaikoAction RimAction; + + /// + /// The key to be used for the centre of the half-drum. + /// + public TaikoAction CentreAction; + + private readonly Sprite rim; + private readonly Sprite rimHit; + private readonly Sprite centre; + private readonly Sprite centreHit; + + public TaikoHalfDrum(bool flipped) + { + Masking = true; + + Children = new Drawable[] + { + rim = new Sprite + { + Anchor = flipped ? Anchor.CentreLeft : Anchor.CentreRight, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both + }, + rimHit = new Sprite + { + Anchor = flipped ? Anchor.CentreLeft : Anchor.CentreRight, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Blending = BlendingParameters.Additive, + }, + centre = new Sprite + { + Anchor = flipped ? Anchor.CentreLeft : Anchor.CentreRight, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.7f) + }, + centreHit = new Sprite + { + Anchor = flipped ? Anchor.CentreLeft : Anchor.CentreRight, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.7f), + Alpha = 0, + Blending = BlendingParameters.Additive + } + }; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures, OsuColour colours) + { + rim.Texture = textures.Get(@"Gameplay/taiko/taiko-drum-outer"); + rimHit.Texture = textures.Get(@"Gameplay/taiko/taiko-drum-outer-hit"); + centre.Texture = textures.Get(@"Gameplay/taiko/taiko-drum-inner"); + centreHit.Texture = textures.Get(@"Gameplay/taiko/taiko-drum-inner-hit"); + + rimHit.Colour = colours.Blue; + centreHit.Colour = colours.Pink; + } + + public bool OnPressed(KeyBindingPressEvent e) + { + Drawable? target = null; + Drawable? back = null; + + if (e.Action == CentreAction) + { + target = centreHit; + back = centre; + } + else if (e.Action == RimAction) + { + target = rimHit; + back = rim; + } + + if (target != null) + { + const float scale_amount = 0.05f; + const float alpha_amount = 0.5f; + + const float down_time = 40; + const float up_time = 1000; + + back.ScaleTo(target.Scale.X - scale_amount, down_time, Easing.OutQuint) + .Then() + .ScaleTo(1, up_time, Easing.OutQuint); + + target.Animate( + t => t.ScaleTo(target.Scale.X - scale_amount, down_time, Easing.OutQuint), + t => t.FadeTo(Math.Min(target.Alpha + alpha_amount, 1), down_time, Easing.OutQuint) + ).Then( + t => t.ScaleTo(1, up_time, Easing.OutQuint), + t => t.FadeOut(up_time, Easing.OutQuint) + ); + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/DefaultJudgementPiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/DefaultJudgementPiece.cs new file mode 100644 index 0000000000..c78d7719d0 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/DefaultJudgementPiece.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Taiko.Skinning.Default +{ + public partial class DefaultJudgementPiece : Rulesets.Judgements.DefaultJudgementPiece + { + public DefaultJudgementPiece(HitResult result) + : base(result) + { + RelativePositionAxes = Axes.Both; + } + + public override void PlayAnimation() + { + if (Result != HitResult.Miss) + { + this + .MoveToY(-0.6f) + .MoveToY(-1.5f, 500); + + JudgementText + .ScaleTo(0.9f) + .ScaleTo(1, 500, Easing.OutElastic); + + this.FadeOutFromOne(800, Easing.OutQuint); + } + else + base.PlayAnimation(); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/DefaultKiaiHitExplosion.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/DefaultKiaiHitExplosion.cs similarity index 92% rename from osu.Game.Rulesets.Taiko/UI/DefaultKiaiHitExplosion.cs rename to osu.Game.Rulesets.Taiko/Skinning/Default/DefaultKiaiHitExplosion.cs index 7ce8b016d5..750fb505a0 100644 --- a/osu.Game.Rulesets.Taiko/UI/DefaultKiaiHitExplosion.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/DefaultKiaiHitExplosion.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 osuTK; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -9,10 +8,11 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Game.Rulesets.Taiko.Objects; +using osuTK; -namespace osu.Game.Rulesets.Taiko.UI +namespace osu.Game.Rulesets.Taiko.Skinning.Default { - public class DefaultKiaiHitExplosion : CircularContainer + public partial class DefaultKiaiHitExplosion : CircularContainer { public override bool RemoveWhenNotAlive => true; diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/ElongatedCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/ElongatedCirclePiece.cs index 210841bca0..11d82a3714 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Default/ElongatedCirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/ElongatedCirclePiece.cs @@ -7,7 +7,7 @@ using osu.Game.Graphics; namespace osu.Game.Rulesets.Taiko.Skinning.Default { - public class ElongatedCirclePiece : CirclePiece + public partial class ElongatedCirclePiece : CirclePiece { public ElongatedCirclePiece() { diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/RimHitCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/RimHitCirclePiece.cs index c6165495d8..0920d58a34 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Default/RimHitCirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/RimHitCirclePiece.cs @@ -11,7 +11,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Taiko.Skinning.Default { - public class RimHitCirclePiece : CirclePiece + public partial class RimHitCirclePiece : CirclePiece { public RimHitCirclePiece() { @@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default /// /// The symbol used for rim hit pieces. /// - public class RimHitSymbolPiece : CircularContainer + public partial class RimHitSymbolPiece : CircularContainer { public RimHitSymbolPiece() { diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/SwellSymbolPiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/SwellSymbolPiece.cs index 2f59cac3ff..d40b518aa3 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Default/SwellSymbolPiece.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/SwellSymbolPiece.cs @@ -10,7 +10,7 @@ using osuTK; namespace osu.Game.Rulesets.Taiko.Skinning.Default { - public class SwellCirclePiece : CirclePiece + public partial class SwellCirclePiece : CirclePiece { public SwellCirclePiece() { @@ -26,7 +26,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default /// /// The symbol used for swell pieces. /// - public class SwellSymbolPiece : Container + public partial class SwellSymbolPiece : Container { public SwellSymbolPiece() { diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/TickPiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/TickPiece.cs index 09c8243aac..8bd8d5f4b4 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Default/TickPiece.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/TickPiece.cs @@ -1,15 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects.Drawables; using osuTK; using osuTK.Graphics; namespace osu.Game.Rulesets.Taiko.Skinning.Default { - public class TickPiece : CompositeDrawable + public partial class TickPiece : CompositeDrawable { /// /// Any tick that is not the first for a drumroll is not filled, but is instead displayed @@ -22,20 +26,10 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default /// private const float tick_size = 0.35f; - private bool filled; - - public bool Filled - { - get => filled; - set - { - filled = value; - fillBox.Alpha = filled ? 1 : 0; - } - } - private readonly Box fillBox; + private Bindable isFirstTick = null!; + public TickPiece() { Anchor = Anchor.Centre; @@ -62,5 +56,19 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default } }; } + + [Resolved] + private DrawableHitObject drawableHitObject { get; set; } = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (drawableHitObject is DrawableDrumRollTick drumRollTick) + { + isFirstTick = drumRollTick.IsFirstTick.GetBoundCopy(); + isFirstTick.BindValueChanged(first => fillBox.Alpha = first.NewValue ? 1 : 0, true); + } + } } } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyBarLine.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyBarLine.cs index 2b528ae8ce..01100bfd63 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyBarLine.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyBarLine.cs @@ -9,7 +9,7 @@ using osuTK; namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { - public class LegacyBarLine : Sprite + public partial class LegacyBarLine : Sprite { [BackgroundDependencyLoader] private void load(ISkinSource skin) diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyCirclePiece.cs index 2b6c14ca63..37eb95b86f 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyCirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyCirclePiece.cs @@ -2,31 +2,50 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Screens.Play; using osu.Game.Skinning; using osuTK; using osuTK.Graphics; namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { - public class LegacyCirclePiece : CompositeDrawable, IHasAccentColour + public partial class LegacyCirclePiece : CompositeDrawable, IHasAccentColour { - private Drawable backgroundLayer; + private Drawable backgroundLayer = null!; + private Drawable? foregroundLayer; + + private Bindable currentCombo { get; } = new BindableInt(); + + private int animationFrame; + private double beatLength; + + // required for editor blueprints (not sure why these circle pieces are zero size). + public override Quad ScreenSpaceDrawQuad => backgroundLayer.ScreenSpaceDrawQuad; public LegacyCirclePiece() { RelativeSizeAxes = Axes.Both; } + [Resolved(canBeNull: true)] + private GameplayState? gameplayState { get; set; } + + [Resolved(canBeNull: true)] + private IBeatSyncProvider? beatSyncProvider { get; set; } + [BackgroundDependencyLoader] private void load(ISkinSource skin, DrawableHitObject drawableHitObject) { - Drawable getDrawableFor(string lookup) + Drawable? getDrawableFor(string lookup) { const string normal_hit = "taikohit"; const string big_hit = "taikobig"; @@ -39,9 +58,9 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy } // backgroundLayer is guaranteed to exist due to the pre-check in TaikoLegacySkinTransformer. - AddInternal(backgroundLayer = getDrawableFor("circle")); + AddInternal(backgroundLayer = new LegacyKiaiFlashingDrawable(() => getDrawableFor("circle"))); - var foregroundLayer = getDrawableFor("circleoverlay"); + foregroundLayer = getDrawableFor("circleoverlay"); if (foregroundLayer != null) AddInternal(foregroundLayer); @@ -54,6 +73,9 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy c.Anchor = Anchor.Centre; c.Origin = Anchor.Centre; } + + if (gameplayState != null) + currentCombo.BindTo(gameplayState.ScoreProcessor.Combo); } protected override void LoadComplete() @@ -70,6 +92,37 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy // This ensures they are scaled relative to each other but also match the expected DrawableHit size. foreach (var c in InternalChildren) c.Scale = new Vector2(DrawHeight / 128); + + if (foregroundLayer is IFramedAnimation animatableForegroundLayer) + animateForegroundLayer(animatableForegroundLayer); + } + + private void animateForegroundLayer(IFramedAnimation animatableForegroundLayer) + { + int multiplier; + + if (currentCombo.Value >= 150) + { + multiplier = 2; + } + else if (currentCombo.Value >= 50) + { + multiplier = 1; + } + else + { + animatableForegroundLayer.GotoFrame(0); + return; + } + + if (beatSyncProvider?.ControlPoints != null) + { + beatLength = beatSyncProvider.ControlPoints.TimingPointAt(Time.Current).BeatLength; + + animationFrame = Time.Current % ((beatLength * 2) / multiplier) >= beatLength / multiplier ? 0 : 1; + + animatableForegroundLayer.GotoFrame(animationFrame); + } } private Color4 accentColour; diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs index ea6f813be8..5543a31ec9 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs @@ -4,21 +4,33 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; using osu.Game.Graphics; using osu.Game.Skinning; using osuTK.Graphics; namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { - public class LegacyDrumRoll : CompositeDrawable, IHasAccentColour + public partial class LegacyDrumRoll : CompositeDrawable, IHasAccentColour { - private LegacyCirclePiece headCircle; + public override Quad ScreenSpaceDrawQuad + { + get + { + var headDrawQuad = headCircle.ScreenSpaceDrawQuad; + var tailDrawQuad = tailCircle.ScreenSpaceDrawQuad; - private Sprite body; + return new Quad(headDrawQuad.TopLeft, tailDrawQuad.TopRight, headDrawQuad.BottomLeft, tailDrawQuad.BottomRight); + } + } - private Sprite end; + private LegacyCirclePiece headCircle = null!; + + private Sprite body = null!; + + private Sprite tailCircle = null!; public LegacyDrumRoll() { @@ -30,7 +42,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { InternalChildren = new Drawable[] { - end = new Sprite + tailCircle = new Sprite { Anchor = Anchor.CentreRight, Origin = Anchor.CentreLeft, @@ -80,7 +92,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy headCircle.AccentColour = colour; body.Colour = colour; - end.Colour = colour; + tailCircle.Colour = colour; } } } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHit.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHit.cs index d93317f0e2..87c9d72d22 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHit.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHit.cs @@ -7,7 +7,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { - public class LegacyHit : LegacyCirclePiece + public partial class LegacyHit : LegacyCirclePiece { private readonly TaikoSkinComponents component; diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHitExplosion.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHitExplosion.cs index 21bd35ad22..b9a432f3bd 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHitExplosion.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHitExplosion.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 JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; @@ -11,12 +10,11 @@ using osu.Game.Rulesets.Taiko.UI; namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { - public class LegacyHitExplosion : CompositeDrawable, IAnimatableHitExplosion + public partial class LegacyHitExplosion : CompositeDrawable, IAnimatableHitExplosion { private readonly Drawable sprite; - [CanBeNull] - private readonly Drawable strongSprite; + private readonly Drawable? strongSprite; /// /// Creates a new legacy hit explosion. @@ -27,7 +25,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy /// /// The normal legacy explosion sprite. /// The strong legacy explosion sprite. - public LegacyHitExplosion(Drawable sprite, [CanBeNull] Drawable strongSprite = null) + public LegacyHitExplosion(Drawable sprite, Drawable? strongSprite = null) { this.sprite = sprite; this.strongSprite = strongSprite; diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyInputDrum.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyInputDrum.cs index 43c5c07f80..8ad419f8bd 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyInputDrum.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyInputDrum.cs @@ -8,8 +8,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; -using osu.Game.Rulesets.Taiko.Objects; -using osu.Game.Rulesets.Taiko.UI; using osu.Game.Skinning; using osuTK; @@ -18,15 +16,16 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy /// /// A component of the playfield that captures input and displays input as a drum. /// - internal class LegacyInputDrum : Container + internal partial class LegacyInputDrum : Container { - private LegacyHalfDrum left; - private LegacyHalfDrum right; - private Container content; + private Container content = null!; + private LegacyHalfDrum left = null!; + private LegacyHalfDrum right = null!; public LegacyInputDrum() { - RelativeSizeAxes = Axes.Both; + RelativeSizeAxes = Axes.Y; + AutoSizeAxes = Axes.X; } [BackgroundDependencyLoader] @@ -97,7 +96,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy /// /// A half-drum. Contains one centre and one rim hit. /// - private class LegacyHalfDrum : Container, IKeyBindingHandler + private partial class LegacyHalfDrum : Container, IKeyBindingHandler { /// /// The key to be used for the rim of the half-drum. @@ -112,9 +111,6 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy public readonly Sprite Rim; public readonly Sprite Centre; - [Resolved] - private DrumSampleTriggerSource sampleTriggerSource { get; set; } - public LegacyHalfDrum(bool flipped) { Masking = true; @@ -144,17 +140,15 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy public bool OnPressed(KeyBindingPressEvent e) { - Drawable target = null; + Drawable? target = null; if (e.Action == CentreAction) { target = Centre; - sampleTriggerSource.Play(HitType.Centre); } else if (e.Action == RimAction) { target = Rim; - sampleTriggerSource.Play(HitType.Rim); } if (target != null) diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyKiaiGlow.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyKiaiGlow.cs new file mode 100644 index 0000000000..623243e9e1 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyKiaiGlow.cs @@ -0,0 +1,65 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Audio.Track; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.Skinning.Legacy +{ + internal partial class LegacyKiaiGlow : BeatSyncedContainer + { + private bool isKiaiActive; + + private Sprite sprite = null!; + + [BackgroundDependencyLoader(true)] + private void load(ISkinSource skin, HealthProcessor? healthProcessor) + { + Child = sprite = new Sprite + { + Texture = skin.GetTexture("taiko-glow"), + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Alpha = 0, + Scale = new Vector2(0.7f), + Colour = new Colour4(255, 228, 0, 255), + }; + + if (healthProcessor != null) + healthProcessor.NewJudgement += onNewJudgement; + } + + protected override void Update() + { + base.Update(); + + if (isKiaiActive) + sprite.Alpha = (float)Math.Min(1, sprite.Alpha + Math.Abs(Clock.ElapsedFrameTime) / 100f); + else + sprite.Alpha = (float)Math.Max(0, sprite.Alpha - Math.Abs(Clock.ElapsedFrameTime) / 600f); + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + isKiaiActive = effectPoint.KiaiMode; + } + + private void onNewJudgement(JudgementResult result) + { + if (!result.IsHit || !isKiaiActive) + return; + + sprite.ScaleTo(0.85f).Then() + .ScaleTo(0.7f, 80, Easing.OutQuad); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyTaikoScroller.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyTaikoScroller.cs index fa49242675..2964473f89 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyTaikoScroller.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyTaikoScroller.cs @@ -15,7 +15,7 @@ using osuTK; namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { - public class LegacyTaikoScroller : CompositeDrawable + public partial class LegacyTaikoScroller : CompositeDrawable { public Bindable LastResult = new Bindable(); @@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy } [BackgroundDependencyLoader(true)] - private void load(GameplayState gameplayState) + private void load(GameplayState? gameplayState) { if (gameplayState != null) ((IBindable)LastResult).BindTo(gameplayState.LastJudgementResult); @@ -87,10 +87,10 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy } } - private class ScrollerSprite : CompositeDrawable + private partial class ScrollerSprite : CompositeDrawable { - private Sprite passingSprite; - private Sprite failingSprite; + private Sprite passingSprite = null!; + private Sprite failingSprite = null!; private bool passing = true; diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacyHitTarget.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacyHitTarget.cs index c4657fcc49..492782f0d1 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacyHitTarget.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacyHitTarget.cs @@ -11,9 +11,9 @@ using osuTK; namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { - public class TaikoLegacyHitTarget : CompositeDrawable + public partial class TaikoLegacyHitTarget : CompositeDrawable { - private Container content; + private Container content = null!; [BackgroundDependencyLoader] private void load(ISkinSource skin) diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacyPlayfieldBackgroundRight.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacyPlayfieldBackgroundRight.cs index 02756d57a4..85870d0fd6 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacyPlayfieldBackgroundRight.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacyPlayfieldBackgroundRight.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Audio.Track; using osu.Framework.Graphics; @@ -12,11 +13,11 @@ using osuTK; namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { - public class TaikoLegacyPlayfieldBackgroundRight : BeatSyncedContainer + public partial class TaikoLegacyPlayfieldBackgroundRight : BeatSyncedContainer { - private Sprite kiai; + private Sprite kiai = null!; - private bool kiaiDisplayed; + private bool isKiaiActive; [BackgroundDependencyLoader] private void load(ISkinSource skin) @@ -41,17 +42,19 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy }; } + protected override void Update() + { + base.Update(); + + if (isKiaiActive) + kiai.Alpha = (float)Math.Min(1, kiai.Alpha + Math.Abs(Clock.ElapsedFrameTime) / 200f); + else + kiai.Alpha = (float)Math.Max(0, kiai.Alpha - Math.Abs(Clock.ElapsedFrameTime) / 200f); + } + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { - base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); - - if (effectPoint.KiaiMode != kiaiDisplayed) - { - kiaiDisplayed = effectPoint.KiaiMode; - - kiai.ClearTransforms(); - kiai.FadeTo(kiaiDisplayed ? 1 : 0, 200); - } + isKiaiActive = effectPoint.KiaiMode; } } } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs index af5921b0fb..d61f9ac35d 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs @@ -14,24 +14,29 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { public class TaikoLegacySkinTransformer : LegacySkinTransformer { + public override bool IsProvidingLegacyResources => base.IsProvidingLegacyResources || hasHitCircle || hasBarLeft; + private readonly Lazy hasExplosion; + private bool hasHitCircle => GetTexture("taikohitcircle") != null; + private bool hasBarLeft => GetTexture("taiko-bar-left") != null; + public TaikoLegacySkinTransformer(ISkin skin) : base(skin) { hasExplosion = new Lazy(() => GetTexture(getHitName(TaikoSkinComponents.TaikoExplosionGreat)) != null); } - public override Drawable GetDrawableComponent(ISkinComponent component) + public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) { - if (component is GameplaySkinComponent) + if (lookup is GameplaySkinComponentLookup) { // if a taiko skin is providing explosion sprites, hide the judgements completely if (hasExplosion.Value) return Drawable.Empty().With(d => d.Expire()); } - if (component is TaikoSkinComponent taikoComponent) + if (lookup is TaikoSkinComponentLookup taikoComponent) { switch (taikoComponent.Component) { @@ -42,14 +47,14 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy return null; case TaikoSkinComponents.InputDrum: - if (GetTexture("taiko-bar-left") != null) + if (hasBarLeft) return new LegacyInputDrum(); return null; case TaikoSkinComponents.CentreHit: case TaikoSkinComponents.RimHit: - if (GetTexture("taikohitcircle") != null) + if (hasHitCircle) return new LegacyHit(taikoComponent.Component); return null; @@ -124,12 +129,18 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy case TaikoSkinComponents.Mascot: return new DrawableTaikoMascot(); + case TaikoSkinComponents.KiaiGlow: + if (GetTexture("taiko-glow") != null) + return new LegacyKiaiGlow(); + + return null; + default: - throw new UnsupportedSkinComponentException(component); + throw new UnsupportedSkinComponentException(lookup); } } - return base.GetDrawableComponent(component); + return base.GetDrawableComponent(lookup); } private string getHitName(TaikoSkinComponents component) @@ -149,7 +160,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy throw new ArgumentOutOfRangeException(nameof(component), $"Invalid component type: {component}"); } - public override ISample GetSample(ISampleInfo sampleInfo) + public override ISample? GetSample(ISampleInfo sampleInfo) { if (sampleInfo is HitSampleInfo hitSampleInfo) return base.GetSample(new LegacyTaikoSampleInfo(hitSampleInfo)); @@ -171,9 +182,6 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { foreach (string name in base.LookupNames) yield return name.Insert(name.LastIndexOf('/') + 1, "taiko-"); - - foreach (string name in base.LookupNames) - yield return name; } } } diff --git a/osu.Game.Rulesets.Taiko/TaikoInputManager.cs b/osu.Game.Rulesets.Taiko/TaikoInputManager.cs index 058bec5111..ca06a0a77e 100644 --- a/osu.Game.Rulesets.Taiko/TaikoInputManager.cs +++ b/osu.Game.Rulesets.Taiko/TaikoInputManager.cs @@ -1,13 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; +using osu.Framework.Allocation; using osu.Framework.Input.Bindings; using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Taiko { - public class TaikoInputManager : RulesetInputManager + [Cached] // Used for touch input, see DrumTouchInputArea. + public partial class TaikoInputManager : RulesetInputManager { public TaikoInputManager(RulesetInfo ruleset) : base(ruleset, 0, SimultaneousBindingMode.Unique) diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index 615fbf093f..a35fdb890d 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -1,40 +1,46 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Taiko.Mods; -using osu.Game.Rulesets.Taiko.UI; -using osu.Game.Rulesets.UI; +using System; using System.Collections.Generic; +using System.Linq; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; -using osu.Game.Rulesets.Replays.Types; -using osu.Game.Rulesets.Taiko.Replays; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; using osu.Game.Beatmaps.Legacy; +using osu.Game.Graphics; using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Replays.Types; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Beatmaps; using osu.Game.Rulesets.Taiko.Difficulty; -using osu.Game.Rulesets.Taiko.Scoring; -using osu.Game.Scoring; -using System; -using System.Linq; -using osu.Framework.Extensions.EnumExtensions; -using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Taiko.Edit; +using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Replays; +using osu.Game.Rulesets.Taiko.Scoring; +using osu.Game.Rulesets.Taiko.Skinning.Argon; using osu.Game.Rulesets.Taiko.Skinning.Legacy; +using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Rulesets.UI; +using osu.Game.Overlays.Settings; +using osu.Game.Scoring; using osu.Game.Screens.Ranking.Statistics; using osu.Game.Skinning; +using osu.Game.Rulesets.Configuration; +using osu.Game.Configuration; +using osu.Game.Rulesets.Taiko.Configuration; namespace osu.Game.Rulesets.Taiko { public class TaikoRuleset : Ruleset, ILegacyRuleset { - public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableTaikoRuleset(this, beatmap, mods); + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList? mods = null) => new DrawableTaikoRuleset(this, beatmap, mods); public override ScoreProcessor CreateScoreProcessor() => new TaikoScoreProcessor(); @@ -42,10 +48,24 @@ namespace osu.Game.Rulesets.Taiko public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new TaikoBeatmapConverter(beatmap, this); - public override ISkin CreateLegacySkinProvider(ISkin skin, IBeatmap beatmap) => new TaikoLegacySkinTransformer(skin); + public override ISkin? CreateSkinTransformer(ISkin skin, IBeatmap beatmap) + { + switch (skin) + { + case ArgonSkin: + return new TaikoArgonSkinTransformer(skin); + + case LegacySkin: + return new TaikoLegacySkinTransformer(skin); + } + + return null; + } public const string SHORT_NAME = "taiko"; + public override string RulesetAPIVersionSupported => CURRENT_RULESET_API_VERSION; + public override IEnumerable GetDefaultKeyBindings(int variant = 0) => new[] { new KeyBinding(InputKey.MouseLeft, TaikoAction.LeftCentre), @@ -128,6 +148,7 @@ namespace osu.Game.Rulesets.Taiko new MultiMod(new TaikoModDoubleTime(), new TaikoModNightcore()), new TaikoModHidden(), new TaikoModFlashlight(), + new ModAccuracyChallenge(), }; case ModType.Conversion: @@ -137,6 +158,7 @@ namespace osu.Game.Rulesets.Taiko new TaikoModDifficultyAdjust(), new TaikoModClassic(), new TaikoModSwap(), + new TaikoModSingleTap(), }; case ModType.Automation: @@ -177,6 +199,10 @@ namespace osu.Game.Rulesets.Taiko public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new TaikoReplayFrame(); + public override IRulesetConfigManager CreateConfig(SettingsStore? settings) => new TaikoRulesetConfigManager(settings, RulesetInfo); + + public override RulesetSettingsSubsection CreateSettings() => new TaikoSettingsSubsection(this); + protected override IEnumerable GetValidHitResults() { return new[] @@ -184,21 +210,20 @@ namespace osu.Game.Rulesets.Taiko HitResult.Great, HitResult.Ok, - HitResult.SmallTickHit, - HitResult.SmallBonus, + HitResult.LargeBonus, }; } - public override string GetDisplayNameForHitResult(HitResult result) + public override LocalisableString GetDisplayNameForHitResult(HitResult result) { switch (result) { - case HitResult.SmallTickHit: + case HitResult.SmallBonus: return "drum tick"; - case HitResult.SmallBonus: - return "strong bonus"; + case HitResult.LargeBonus: + return "bonus"; } return base.GetDisplayNameForHitResult(result); diff --git a/osu.Game.Rulesets.Taiko/TaikoSettingsSubsection.cs b/osu.Game.Rulesets.Taiko/TaikoSettingsSubsection.cs new file mode 100644 index 0000000000..9fe861c08c --- /dev/null +++ b/osu.Game.Rulesets.Taiko/TaikoSettingsSubsection.cs @@ -0,0 +1,36 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Taiko.Configuration; + +namespace osu.Game.Rulesets.Taiko +{ + public partial class TaikoSettingsSubsection : RulesetSettingsSubsection + { + protected override LocalisableString Header => "osu!taiko"; + + public TaikoSettingsSubsection(TaikoRuleset ruleset) + : base(ruleset) + { + } + + [BackgroundDependencyLoader] + private void load() + { + var config = (TaikoRulesetConfigManager)Config; + + Children = new Drawable[] + { + new SettingsEnumDropdown + { + LabelText = "Touch control scheme", + Current = config.GetBindable(TaikoRulesetSetting.TouchControlScheme) + } + }; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/TaikoSkinComponent.cs b/osu.Game.Rulesets.Taiko/TaikoSkinComponentLookup.cs similarity index 68% rename from osu.Game.Rulesets.Taiko/TaikoSkinComponent.cs rename to osu.Game.Rulesets.Taiko/TaikoSkinComponentLookup.cs index e6e4bc0dd7..c35971e9fd 100644 --- a/osu.Game.Rulesets.Taiko/TaikoSkinComponent.cs +++ b/osu.Game.Rulesets.Taiko/TaikoSkinComponentLookup.cs @@ -5,15 +5,15 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko { - public class TaikoSkinComponent : GameplaySkinComponent + public class TaikoSkinComponentLookup : GameplaySkinComponentLookup { - public TaikoSkinComponent(TaikoSkinComponents component) + public TaikoSkinComponentLookup(TaikoSkinComponents component) : base(component) { } protected override string RulesetPrefix => TaikoRuleset.SHORT_NAME; - protected override string ComponentName => Component.ToString().ToLower(); + protected override string ComponentName => Component.ToString().ToLowerInvariant(); } } diff --git a/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs b/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs index bf48898dd2..b8e3313e1b 100644 --- a/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs +++ b/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs @@ -21,5 +21,6 @@ namespace osu.Game.Rulesets.Taiko TaikoExplosionKiai, Scroller, Mascot, + KiaiGlow } } diff --git a/osu.Game.Rulesets.Taiko/UI/BarLinePlayfield.cs b/osu.Game.Rulesets.Taiko/UI/BarLinePlayfield.cs index cb878e8ea0..ebdf9ef191 100644 --- a/osu.Game.Rulesets.Taiko/UI/BarLinePlayfield.cs +++ b/osu.Game.Rulesets.Taiko/UI/BarLinePlayfield.cs @@ -8,7 +8,7 @@ using osu.Game.Rulesets.UI.Scrolling; namespace osu.Game.Rulesets.Taiko.UI { - public class BarLinePlayfield : ScrollingPlayfield + public partial class BarLinePlayfield : ScrollingPlayfield { [BackgroundDependencyLoader] private void load() diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs index 876fa207bf..f91fe586f2 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs @@ -4,39 +4,25 @@ using osu.Framework.Graphics; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; +using osuTK; +using DefaultJudgementPiece = osu.Game.Rulesets.Taiko.Skinning.Default.DefaultJudgementPiece; namespace osu.Game.Rulesets.Taiko.UI { /// /// Text that is shown as judgement when a hit object is hit or missed. /// - public class DrawableTaikoJudgement : DrawableJudgement + public partial class DrawableTaikoJudgement : DrawableJudgement { - protected override void ApplyHitAnimations() + public DrawableTaikoJudgement() { - this.MoveToY(-100, 500); - base.ApplyHitAnimations(); + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + RelativeSizeAxes = Axes.Both; + Size = Vector2.One; } - protected override Drawable CreateDefaultJudgement(HitResult result) => new TaikoJudgementPiece(result); - - private class TaikoJudgementPiece : DefaultJudgementPiece - { - public TaikoJudgementPiece(HitResult result) - : base(result) - { - } - - public override void PlayAnimation() - { - if (Result != HitResult.Miss) - { - JudgementText.ScaleTo(0.9f); - JudgementText.ScaleTo(1, 500, Easing.OutElastic); - } - - base.PlayAnimation(); - } - } + protected override Drawable CreateDefaultJudgement(HitResult result) => new DefaultJudgementPiece(result); } } diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoMascot.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoMascot.cs index 7ba2618a63..0dfb796c74 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoMascot.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoMascot.cs @@ -16,13 +16,14 @@ using osu.Game.Screens.Play; namespace osu.Game.Rulesets.Taiko.UI { - public class DrawableTaikoMascot : BeatSyncedContainer + public partial class DrawableTaikoMascot : BeatSyncedContainer { public readonly Bindable State; - public readonly Bindable LastResult; + public readonly Bindable LastResult; private readonly Dictionary animations; - private TaikoMascotAnimation currentAnimation; + + private TaikoMascotAnimation? currentAnimation; private bool lastObjectHit = true; private bool kiaiMode; @@ -32,13 +33,13 @@ namespace osu.Game.Rulesets.Taiko.UI Origin = Anchor = Anchor.BottomLeft; State = new Bindable(startingState); - LastResult = new Bindable(); + LastResult = new Bindable(); animations = new Dictionary(); } [BackgroundDependencyLoader(true)] - private void load(GameplayState gameplayState) + private void load(GameplayState? gameplayState) { InternalChildren = new[] { @@ -62,7 +63,7 @@ namespace osu.Game.Rulesets.Taiko.UI LastResult.BindValueChanged(onNewResult); } - private void onNewResult(ValueChangedEvent resultChangedEvent) + private void onNewResult(ValueChangedEvent resultChangedEvent) { var result = resultChangedEvent.NewValue; if (result == null) diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs index 2efc4176f5..a08877e2dd 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs @@ -1,23 +1,25 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Game.Beatmaps; -using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Taiko.Objects; -using osu.Game.Rulesets.UI; -using osu.Game.Rulesets.Taiko.Replays; using osu.Framework.Input; +using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Input.Handlers; using osu.Game.Replays; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Replays; using osu.Game.Rulesets.Timing; +using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Scoring; using osu.Game.Skinning; @@ -25,11 +27,13 @@ using osuTK; namespace osu.Game.Rulesets.Taiko.UI { - public class DrawableTaikoRuleset : DrawableScrollingRuleset + public partial class DrawableTaikoRuleset : DrawableScrollingRuleset { public new BindableDouble TimeRange => base.TimeRange; - public readonly BindableBool LockPlayfieldAspect = new BindableBool(true); + public readonly BindableBool LockPlayfieldMaxAspect = new BindableBool(true); + + public new TaikoInputManager KeyBindingInputManager => (TaikoInputManager)base.KeyBindingInputManager; protected override ScrollVisualisationMethod VisualisationMethod => ScrollVisualisationMethod.Overlapping; @@ -41,7 +45,6 @@ namespace osu.Game.Rulesets.Taiko.UI : base(ruleset, beatmap, mods) { Direction.Value = ScrollingDirection.Left; - TimeRange.Value = 7000; } [BackgroundDependencyLoader] @@ -49,11 +52,26 @@ namespace osu.Game.Rulesets.Taiko.UI { new BarLineGenerator(Beatmap).BarLines.ForEach(bar => Playfield.Add(bar)); - FrameStableComponents.Add(scroller = new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.Scroller), _ => Empty()) + FrameStableComponents.Add(scroller = new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.Scroller), _ => Empty()) { RelativeSizeAxes = Axes.X, Depth = float.MaxValue }); + + KeyBindingInputManager.Add(new DrumTouchInputArea()); + } + + protected override void Update() + { + base.Update(); + + // Taiko scrolls at a constant 100px per 1000ms. More notes become visible as the playfield is lengthened. + const float scroll_rate = 10; + + // Since the time range will depend on a positional value, it is referenced to the x480 pixel space. + float ratio = DrawHeight / 480; + + TimeRange.Value = (Playfield.HitObjectContainer.DrawWidth / ratio) * scroll_rate; } protected override void UpdateAfterChildren() @@ -74,7 +92,7 @@ namespace osu.Game.Rulesets.Taiko.UI public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new TaikoPlayfieldAdjustmentContainer { - LockPlayfieldAspect = { BindTarget = LockPlayfieldAspect } + LockPlayfieldMaxAspect = { BindTarget = LockPlayfieldMaxAspect } }; protected override PassThroughInputManager CreateInputManager() => new TaikoInputManager(Ruleset.RulesetInfo); diff --git a/osu.Game.Rulesets.Taiko/UI/DrumRollHitContainer.cs b/osu.Game.Rulesets.Taiko/UI/DrumRollHitContainer.cs index ae37840825..87ee852171 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrumRollHitContainer.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrumRollHitContainer.cs @@ -6,7 +6,7 @@ using osu.Game.Rulesets.UI.Scrolling; namespace osu.Game.Rulesets.Taiko.UI { - internal class DrumRollHitContainer : ScrollingHitObjectContainer + internal partial class DrumRollHitContainer : ScrollingHitObjectContainer { // TODO: this usage is buggy. // Because `LifetimeStart` is set based on scrolling, lifetime is not same as the time when the object is created. diff --git a/osu.Game.Rulesets.Taiko/UI/DrumSamplePlayer.cs b/osu.Game.Rulesets.Taiko/UI/DrumSamplePlayer.cs new file mode 100644 index 0000000000..6454fb5afa --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/DrumSamplePlayer.cs @@ -0,0 +1,59 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Taiko.UI +{ + internal partial class DrumSamplePlayer : CompositeDrawable, IKeyBindingHandler + { + private readonly DrumSampleTriggerSource leftRimSampleTriggerSource; + private readonly DrumSampleTriggerSource leftCentreSampleTriggerSource; + private readonly DrumSampleTriggerSource rightCentreSampleTriggerSource; + private readonly DrumSampleTriggerSource rightRimSampleTriggerSource; + + public DrumSamplePlayer(HitObjectContainer hitObjectContainer) + { + InternalChildren = new Drawable[] + { + leftRimSampleTriggerSource = new DrumSampleTriggerSource(hitObjectContainer), + leftCentreSampleTriggerSource = new DrumSampleTriggerSource(hitObjectContainer), + rightCentreSampleTriggerSource = new DrumSampleTriggerSource(hitObjectContainer), + rightRimSampleTriggerSource = new DrumSampleTriggerSource(hitObjectContainer), + }; + } + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case TaikoAction.LeftRim: + leftRimSampleTriggerSource.Play(HitType.Rim); + break; + + case TaikoAction.LeftCentre: + leftCentreSampleTriggerSource.Play(HitType.Centre); + break; + + case TaikoAction.RightCentre: + rightCentreSampleTriggerSource.Play(HitType.Centre); + break; + + case TaikoAction.RightRim: + rightRimSampleTriggerSource.Play(HitType.Rim); + break; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs b/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs index 3279d128d3..4809791af8 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs @@ -8,7 +8,7 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Taiko.UI { - public class DrumSampleTriggerSource : GameplaySampleTriggerSource + public partial class DrumSampleTriggerSource : GameplaySampleTriggerSource { public DrumSampleTriggerSource(HitObjectContainer hitObjectContainer) : base(hitObjectContainer) diff --git a/osu.Game.Rulesets.Taiko/UI/DrumTouchInputArea.cs b/osu.Game.Rulesets.Taiko/UI/DrumTouchInputArea.cs new file mode 100644 index 0000000000..29ccd96675 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/DrumTouchInputArea.cs @@ -0,0 +1,326 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Rulesets.Taiko.Configuration; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.UI +{ + /// + /// An overlay that captures and displays osu!taiko mouse and touch input. + /// + public partial class DrumTouchInputArea : VisibilityContainer + { + // visibility state affects our child. we always want to handle input. + public override bool PropagatePositionalInputSubTree => true; + public override bool PropagateNonPositionalInputSubTree => true; + + private KeyBindingContainer keyBindingContainer = null!; + + private readonly Dictionary trackedActions = new Dictionary(); + + private Container mainContent = null!; + + private DrumSegment leftCentre = null!; + private DrumSegment rightCentre = null!; + private DrumSegment leftRim = null!; + private DrumSegment rightRim = null!; + + private readonly Bindable configTouchControlScheme = new Bindable(); + + [BackgroundDependencyLoader] + private void load(TaikoInputManager taikoInputManager, TaikoRulesetConfigManager config) + { + Debug.Assert(taikoInputManager.KeyBindingContainer != null); + + keyBindingContainer = taikoInputManager.KeyBindingContainer; + + // Container should handle input everywhere. + RelativeSizeAxes = Axes.Both; + + const float centre_region = 0.80f; + + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.X, + Height = 350, + Y = 20, + Masking = true, + FillMode = FillMode.Fit, + Children = new Drawable[] + { + mainContent = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + leftRim = new DrumSegment + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomRight, + X = -2, + }, + rightRim = new DrumSegment + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomRight, + X = 2, + Rotation = 90, + }, + leftCentre = new DrumSegment + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomRight, + X = -2, + Scale = new Vector2(centre_region), + }, + rightCentre = new DrumSegment + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomRight, + X = 2, + Scale = new Vector2(centre_region), + Rotation = 90, + } + } + }, + } + }, + }; + + config.BindWith(TaikoRulesetSetting.TouchControlScheme, configTouchControlScheme); + configTouchControlScheme.BindValueChanged(scheme => + { + var actions = getOrderedActionsForScheme(scheme.NewValue); + + leftRim.Action = actions[0]; + leftCentre.Action = actions[1]; + rightCentre.Action = actions[2]; + rightRim.Action = actions[3]; + }, true); + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + // Hide whenever the keyboard is used. + Hide(); + return false; + } + + protected override bool OnTouchDown(TouchDownEvent e) + { + handleDown(e.Touch.Source, e.ScreenSpaceTouchDownPosition); + return true; + } + + protected override void OnTouchUp(TouchUpEvent e) + { + handleUp(e.Touch.Source); + base.OnTouchUp(e); + } + + private static TaikoAction[] getOrderedActionsForScheme(TaikoTouchControlScheme scheme) + { + switch (scheme) + { + case TaikoTouchControlScheme.KDDK: + return new[] + { + TaikoAction.LeftRim, + TaikoAction.LeftCentre, + TaikoAction.RightCentre, + TaikoAction.RightRim + }; + + case TaikoTouchControlScheme.DDKK: + return new[] + { + TaikoAction.LeftCentre, + TaikoAction.RightCentre, + TaikoAction.LeftRim, + TaikoAction.RightRim + }; + + case TaikoTouchControlScheme.KKDD: + return new[] + { + TaikoAction.LeftRim, + TaikoAction.RightRim, + TaikoAction.LeftCentre, + TaikoAction.RightCentre + }; + + default: + throw new ArgumentOutOfRangeException(nameof(scheme), scheme, null); + } + } + + private void handleDown(object source, Vector2 position) + { + Show(); + + TaikoAction taikoAction = getTaikoActionFromPosition(position); + + // Not too sure how this can happen, but let's avoid throwing. + if (trackedActions.ContainsKey(source)) + return; + + trackedActions.Add(source, taikoAction); + keyBindingContainer.TriggerPressed(taikoAction); + } + + private void handleUp(object source) + { + keyBindingContainer.TriggerReleased(trackedActions[source]); + trackedActions.Remove(source); + } + + private TaikoAction getTaikoActionFromPosition(Vector2 inputPosition) + { + bool centreHit = leftCentre.Contains(inputPosition) || rightCentre.Contains(inputPosition); + bool leftSide = ToLocalSpace(inputPosition).X < DrawWidth / 2; + + if (leftSide) + return centreHit ? leftCentre.Action : leftRim.Action; + + return centreHit ? rightCentre.Action : rightRim.Action; + } + + protected override void PopIn() + { + mainContent.FadeIn(500, Easing.OutQuint); + } + + protected override void PopOut() + { + mainContent.FadeOut(300); + } + + private partial class DrumSegment : CompositeDrawable, IKeyBindingHandler + { + private TaikoAction action; + + public TaikoAction Action + { + get => action; + set + { + if (action == value) + return; + + action = value; + updateColoursFromAction(); + } + } + + private Circle overlay = null!; + + private Circle circle = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public override bool Contains(Vector2 screenSpacePos) => circle.Contains(screenSpacePos); + + public DrumSegment() + { + RelativeSizeAxes = Axes.Both; + + FillMode = FillMode.Fit; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + new Container + { + Masking = true, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + circle = new Circle + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.8f, + Scale = new Vector2(2), + }, + overlay = new Circle + { + Alpha = 0, + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Scale = new Vector2(2), + } + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + updateColoursFromAction(); + } + + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == Action) + overlay.FadeTo(1f, 80, Easing.OutQuint); + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + if (e.Action == Action) + overlay.FadeOut(1000, Easing.OutQuint); + } + + private void updateColoursFromAction() + { + if (!IsLoaded) + return; + + var colour = getColourFromTaikoAction(Action); + + circle.Colour = colour.Multiply(1.4f).Darken(2.8f); + overlay.Colour = colour; + } + + private Color4 getColourFromTaikoAction(TaikoAction handledAction) + { + switch (handledAction) + { + case TaikoAction.LeftRim: + case TaikoAction.RightRim: + return colours.Blue; + + case TaikoAction.LeftCentre: + case TaikoAction.RightCentre: + return colours.Pink; + } + + throw new ArgumentOutOfRangeException(); + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs b/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs index 8f5e9e54ab..1a2652acf9 100644 --- a/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs +++ b/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using JetBrains.Annotations; using osuTK; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -11,6 +10,7 @@ using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Skinning.Default; using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.UI @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Taiko.UI /// /// A circle explodes from the hit target to indicate a hitobject has been hit. /// - internal class HitExplosion : PoolableDrawable + internal partial class HitExplosion : PoolableDrawable { public override bool RemoveWhenNotAlive => true; public override bool RemoveCompletedTransforms => false; @@ -27,10 +27,9 @@ namespace osu.Game.Rulesets.Taiko.UI private double? secondHitTime; - [CanBeNull] - public DrawableHitObject JudgedObject; + public DrawableHitObject? JudgedObject; - private SkinnableDrawable skinnable; + private SkinnableDrawable skinnable = null!; /// /// This constructor only exists to meet the new() type constraint of . @@ -56,11 +55,11 @@ namespace osu.Game.Rulesets.Taiko.UI [BackgroundDependencyLoader] private void load() { - InternalChild = skinnable = new SkinnableDrawable(new TaikoSkinComponent(getComponentName(result)), _ => new DefaultHitExplosion(result)); + InternalChild = skinnable = new SkinnableDrawable(new TaikoSkinComponentLookup(getComponentName(result)), _ => new DefaultHitExplosion(result)); skinnable.OnSkinChanged += runAnimation; } - public void Apply([CanBeNull] DrawableHitObject drawableHitObject) + public void Apply(DrawableHitObject? drawableHitObject) { JudgedObject = drawableHitObject; secondHitTime = null; @@ -91,7 +90,6 @@ namespace osu.Game.Rulesets.Taiko.UI { using (BeginAbsoluteSequence(secondHitTime.Value)) { - this.ResizeTo(new Vector2(TaikoStrongableHitObject.DEFAULT_STRONG_SIZE), 50); (skinnable.Drawable as IAnimatableHitExplosion)?.AnimateSecondHit(); } } diff --git a/osu.Game.Rulesets.Taiko/UI/HitExplosionPool.cs b/osu.Game.Rulesets.Taiko/UI/HitExplosionPool.cs index badf34554c..02a78b0f1c 100644 --- a/osu.Game.Rulesets.Taiko/UI/HitExplosionPool.cs +++ b/osu.Game.Rulesets.Taiko/UI/HitExplosionPool.cs @@ -9,7 +9,7 @@ namespace osu.Game.Rulesets.Taiko.UI /// /// Pool for hit explosions of a specific type. /// - internal class HitExplosionPool : DrawablePool + internal partial class HitExplosionPool : DrawablePool { private readonly HitResult hitResult; diff --git a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs index 16be20f7f3..725857ed34 100644 --- a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs +++ b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs @@ -1,37 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; -using osu.Framework.Input.Bindings; -using osu.Framework.Input.Events; -using osu.Game.Graphics; -using osu.Game.Rulesets.Taiko.Objects; -using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.Taiko.Skinning.Default; using osu.Game.Skinning; -using osuTK; namespace osu.Game.Rulesets.Taiko.UI { /// /// A component of the playfield that captures input and displays input as a drum. /// - internal class InputDrum : Container + internal partial class InputDrum : Container { - private const float middle_split = 0.025f; - - [Cached] - private DrumSampleTriggerSource sampleTriggerSource; - - public InputDrum(HitObjectContainer hitObjectContainer) + public InputDrum() { - sampleTriggerSource = new DrumSampleTriggerSource(hitObjectContainer); - - RelativeSizeAxes = Axes.Both; + AutoSizeAxes = Axes.X; + RelativeSizeAxes = Axes.Y; } [BackgroundDependencyLoader] @@ -39,162 +25,12 @@ namespace osu.Game.Rulesets.Taiko.UI { Children = new Drawable[] { - new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.InputDrum), _ => new Container + new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.InputDrum), _ => new DefaultInputDrum()) { - RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit, - Scale = new Vector2(0.9f), - Children = new Drawable[] - { - new TaikoHalfDrum(false) - { - Name = "Left Half", - Anchor = Anchor.Centre, - Origin = Anchor.CentreRight, - RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.X, - X = -middle_split / 2, - RimAction = TaikoAction.LeftRim, - CentreAction = TaikoAction.LeftCentre - }, - new TaikoHalfDrum(true) - { - Name = "Right Half", - Anchor = Anchor.Centre, - Origin = Anchor.CentreLeft, - RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.X, - X = middle_split / 2, - RimAction = TaikoAction.RightRim, - CentreAction = TaikoAction.RightCentre - } - } - }), - sampleTriggerSource + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + }, }; } - - /// - /// A half-drum. Contains one centre and one rim hit. - /// - private class TaikoHalfDrum : Container, IKeyBindingHandler - { - /// - /// The key to be used for the rim of the half-drum. - /// - public TaikoAction RimAction; - - /// - /// The key to be used for the centre of the half-drum. - /// - public TaikoAction CentreAction; - - private readonly Sprite rim; - private readonly Sprite rimHit; - private readonly Sprite centre; - private readonly Sprite centreHit; - - [Resolved] - private DrumSampleTriggerSource sampleTriggerSource { get; set; } - - public TaikoHalfDrum(bool flipped) - { - Masking = true; - - Children = new Drawable[] - { - rim = new Sprite - { - Anchor = flipped ? Anchor.CentreLeft : Anchor.CentreRight, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both - }, - rimHit = new Sprite - { - Anchor = flipped ? Anchor.CentreLeft : Anchor.CentreRight, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Alpha = 0, - Blending = BlendingParameters.Additive, - }, - centre = new Sprite - { - Anchor = flipped ? Anchor.CentreLeft : Anchor.CentreRight, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.7f) - }, - centreHit = new Sprite - { - Anchor = flipped ? Anchor.CentreLeft : Anchor.CentreRight, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.7f), - Alpha = 0, - Blending = BlendingParameters.Additive - } - }; - } - - [BackgroundDependencyLoader] - private void load(TextureStore textures, OsuColour colours) - { - rim.Texture = textures.Get(@"Gameplay/taiko/taiko-drum-outer"); - rimHit.Texture = textures.Get(@"Gameplay/taiko/taiko-drum-outer-hit"); - centre.Texture = textures.Get(@"Gameplay/taiko/taiko-drum-inner"); - centreHit.Texture = textures.Get(@"Gameplay/taiko/taiko-drum-inner-hit"); - - rimHit.Colour = colours.Blue; - centreHit.Colour = colours.Pink; - } - - public bool OnPressed(KeyBindingPressEvent e) - { - Drawable target = null; - Drawable back = null; - - if (e.Action == CentreAction) - { - target = centreHit; - back = centre; - - sampleTriggerSource.Play(HitType.Centre); - } - else if (e.Action == RimAction) - { - target = rimHit; - back = rim; - - sampleTriggerSource.Play(HitType.Rim); - } - - if (target != null) - { - const float scale_amount = 0.05f; - const float alpha_amount = 0.5f; - - const float down_time = 40; - const float up_time = 1000; - - back.ScaleTo(target.Scale.X - scale_amount, down_time, Easing.OutQuint) - .Then() - .ScaleTo(1, up_time, Easing.OutQuint); - - target.Animate( - t => t.ScaleTo(target.Scale.X - scale_amount, down_time, Easing.OutQuint), - t => t.FadeTo(Math.Min(target.Alpha + alpha_amount, 1), down_time, Easing.OutQuint) - ).Then( - t => t.ScaleTo(1, up_time, Easing.OutQuint), - t => t.FadeOut(up_time, Easing.OutQuint) - ); - } - - return false; - } - - public void OnReleased(KeyBindingReleaseEvent e) - { - } - } } } diff --git a/osu.Game.Rulesets.Taiko/UI/KiaiHitExplosion.cs b/osu.Game.Rulesets.Taiko/UI/KiaiHitExplosion.cs index 20900a9352..6c1e981a97 100644 --- a/osu.Game.Rulesets.Taiko/UI/KiaiHitExplosion.cs +++ b/osu.Game.Rulesets.Taiko/UI/KiaiHitExplosion.cs @@ -6,12 +6,13 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Skinning.Default; using osu.Game.Skinning; using osuTK; namespace osu.Game.Rulesets.Taiko.UI { - public class KiaiHitExplosion : Container + public partial class KiaiHitExplosion : Container { public override bool RemoveWhenNotAlive => true; @@ -20,7 +21,7 @@ namespace osu.Game.Rulesets.Taiko.UI private readonly HitType hitType; - private SkinnableDrawable skinnable; + private SkinnableDrawable skinnable = null!; public override double LifetimeStart => skinnable.Drawable.LifetimeStart; @@ -31,7 +32,7 @@ namespace osu.Game.Rulesets.Taiko.UI JudgedObject = judgedObject; this.hitType = hitType; - Anchor = Anchor.CentreLeft; + Anchor = Anchor.Centre; Origin = Anchor.Centre; RelativeSizeAxes = Axes.Both; @@ -41,7 +42,7 @@ namespace osu.Game.Rulesets.Taiko.UI [BackgroundDependencyLoader] private void load() { - Child = skinnable = new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.TaikoExplosionKiai), _ => new DefaultKiaiHitExplosion(hitType)); + Child = skinnable = new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.TaikoExplosionKiai), _ => new DefaultKiaiHitExplosion(hitType)); } } } diff --git a/osu.Game.Rulesets.Taiko/UI/PlayfieldBackgroundLeft.cs b/osu.Game.Rulesets.Taiko/UI/PlayfieldBackgroundLeft.cs index 2a8890a95d..f676d051eb 100644 --- a/osu.Game.Rulesets.Taiko/UI/PlayfieldBackgroundLeft.cs +++ b/osu.Game.Rulesets.Taiko/UI/PlayfieldBackgroundLeft.cs @@ -11,7 +11,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Taiko.UI { - internal class PlayfieldBackgroundLeft : CompositeDrawable + internal partial class PlayfieldBackgroundLeft : CompositeDrawable { [BackgroundDependencyLoader] private void load(OsuColour colours) diff --git a/osu.Game.Rulesets.Taiko/UI/PlayfieldBackgroundRight.cs b/osu.Game.Rulesets.Taiko/UI/PlayfieldBackgroundRight.cs index 44bfdacf37..7b2dbc5f5a 100644 --- a/osu.Game.Rulesets.Taiko/UI/PlayfieldBackgroundRight.cs +++ b/osu.Game.Rulesets.Taiko/UI/PlayfieldBackgroundRight.cs @@ -12,7 +12,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Taiko.UI { - public class PlayfieldBackgroundRight : CompositeDrawable + public partial class PlayfieldBackgroundRight : CompositeDrawable { [BackgroundDependencyLoader] private void load(OsuColour colours) diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoHitTarget.cs b/osu.Game.Rulesets.Taiko/UI/TaikoHitTarget.cs index 6401c6d09f..a2f8dc83db 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoHitTarget.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoHitTarget.cs @@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Taiko.UI /// /// A component that is displayed at the hit position in the taiko playfield. /// - internal class TaikoHitTarget : Container + internal partial class TaikoHitTarget : Container { /// /// Thickness of all drawn line pieces. diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimation.cs b/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimation.cs index c496c05236..7b1e31112e 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimation.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimation.cs @@ -14,7 +14,7 @@ using osuTK; namespace osu.Game.Rulesets.Taiko.UI { - public sealed class TaikoMascotAnimation : BeatSyncedContainer + public sealed partial class TaikoMascotAnimation : BeatSyncedContainer { private readonly TextureAnimation textureAnimation; @@ -73,7 +73,7 @@ namespace osu.Game.Rulesets.Taiko.UI } } - private class ManualMascotTextureAnimation : TextureAnimation + private partial class ManualMascotTextureAnimation : TextureAnimation { private readonly TaikoMascotAnimationState state; @@ -87,7 +87,7 @@ namespace osu.Game.Rulesets.Taiko.UI [BackgroundDependencyLoader] private void load(ISkinSource source) { - ISkin skin = source.FindProvider(s => getAnimationFrame(s, state, 0) != null); + ISkin? skin = source.FindProvider(s => getAnimationFrame(s, state, 0) != null); if (skin == null) return; @@ -103,7 +103,7 @@ namespace osu.Game.Rulesets.Taiko.UI } } - private class ClearMascotTextureAnimation : TextureAnimation + private partial class ClearMascotTextureAnimation : TextureAnimation { private const float clear_animation_speed = 1000 / 10f; @@ -118,7 +118,7 @@ namespace osu.Game.Rulesets.Taiko.UI [BackgroundDependencyLoader] private void load(ISkinSource source) { - ISkin skin = source.FindProvider(s => getAnimationFrame(s, TaikoMascotAnimationState.Clear, 0) != null); + ISkin? skin = source.FindProvider(s => getAnimationFrame(s, TaikoMascotAnimationState.Clear, 0) != null); if (skin == null) return; @@ -135,12 +135,12 @@ namespace osu.Game.Rulesets.Taiko.UI } } - private static Texture getAnimationFrame(ISkin skin, TaikoMascotAnimationState state, int frameIndex) + private static Texture? getAnimationFrame(ISkin skin, TaikoMascotAnimationState state, int frameIndex) { - var texture = skin.GetTexture($"pippidon{state.ToString().ToLower()}{frameIndex}"); + var texture = skin.GetTexture($"pippidon{state.ToString().ToLowerInvariant()}{frameIndex}"); if (frameIndex == 0 && texture == null) - texture = skin.GetTexture($"pippidon{state.ToString().ToLower()}"); + texture = skin.GetTexture($"pippidon{state.ToString().ToLowerInvariant()}"); return texture; } diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs index 2dd332fc13..9f9debe7d7 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs @@ -1,10 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; @@ -25,13 +28,18 @@ using osuTK; namespace osu.Game.Rulesets.Taiko.UI { - public class TaikoPlayfield : ScrollingPlayfield + public partial class TaikoPlayfield : ScrollingPlayfield { /// /// Default height of a when inside a . /// public const float DEFAULT_HEIGHT = 200; + /// + /// Whether the hit target should be nudged further towards the left area, matching the stable "classic" position. + /// + public Bindable ClassicHitTargetPosition = new BindableBool(); + private Container hitExplosionContainer; private Container kiaiExplosionContainer; private JudgementContainer judgementContainer; @@ -43,8 +51,8 @@ namespace osu.Game.Rulesets.Taiko.UI private readonly IDictionary explosionPools = new Dictionary(); private ProxyContainer topLevelHitContainer; + private InputDrum inputDrum; private Container rightArea; - private Container leftArea; /// /// is purposefully not called on this to prevent i.e. being able to interact @@ -52,14 +60,44 @@ namespace osu.Game.Rulesets.Taiko.UI /// private BarLinePlayfield barLinePlayfield; - private Container hitTargetOffsetContent; + private Container barLineContent; + private Container hitObjectContent; + private Container overlayContent; [BackgroundDependencyLoader] private void load(OsuColour colours) { + inputDrum = new InputDrum + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + }; + InternalChildren = new[] { - new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.PlayfieldBackgroundRight), _ => new PlayfieldBackgroundRight()), + new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.PlayfieldBackgroundRight), _ => new PlayfieldBackgroundRight()), + new Container + { + Name = "Left overlay", + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + BorderColour = colours.Gray0, + Children = new[] + { + new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.PlayfieldBackgroundLeft), _ => new PlayfieldBackgroundLeft()), + inputDrum.CreateProxy(), + } + }, + mascot = new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.Mascot), _ => Empty()) + { + Origin = Anchor.BottomLeft, + Anchor = Anchor.TopLeft, + RelativePositionAxes = Axes.Y, + RelativeSizeAxes = Axes.None, + Y = 0.2f + }, rightArea = new Container { Name = "Right area", @@ -69,82 +107,73 @@ namespace osu.Game.Rulesets.Taiko.UI { new Container { - Name = "Masked elements before hit objects", + Name = "Elements before hit objects", RelativeSizeAxes = Axes.Both, FillMode = FillMode.Fit, Children = new[] { + new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.KiaiGlow), _ => Empty()) + { + RelativeSizeAxes = Axes.Both, + }, hitExplosionContainer = new Container { RelativeSizeAxes = Axes.Both, }, - HitTarget = new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.HitTarget), _ => new TaikoHitTarget()) + HitTarget = new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.HitTarget), _ => new TaikoHitTarget()) { RelativeSizeAxes = Axes.Both, } } }, - hitTargetOffsetContent = new Container + barLineContent = new Container { + Name = "Bar line content", + RelativeSizeAxes = Axes.Both, + Child = barLinePlayfield = new BarLinePlayfield(), + }, + hitObjectContent = new Container + { + Name = "Masked hit objects content", + RelativeSizeAxes = Axes.Both, + Masking = true, + Child = HitObjectContainer, + }, + overlayContent = new Container + { + Name = "Elements after hit objects", RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - barLinePlayfield = new BarLinePlayfield(), - new Container - { - Name = "Hit objects", - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - HitObjectContainer, - drumRollHitContainer = new DrumRollHitContainer() - } - }, + drumRollHitContainer = new DrumRollHitContainer(), kiaiExplosionContainer = new Container { Name = "Kiai hit explosions", + Origin = Anchor.TopCentre, RelativeSizeAxes = Axes.Both, FillMode = FillMode.Fit, }, judgementContainer = new JudgementContainer { Name = "Judgements", - RelativeSizeAxes = Axes.Y, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, }, } }, } }, - leftArea = new Container - { - Name = "Left overlay", - RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit, - BorderColour = colours.Gray0, - Children = new Drawable[] - { - new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.PlayfieldBackgroundLeft), _ => new PlayfieldBackgroundLeft()), - new InputDrum(HitObjectContainer) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - }, - } - }, - mascot = new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.Mascot), _ => Empty()) - { - Origin = Anchor.BottomLeft, - Anchor = Anchor.TopLeft, - RelativePositionAxes = Axes.Y, - RelativeSizeAxes = Axes.None, - Y = 0.2f - }, topLevelHitContainer = new ProxyContainer { Name = "Top level hit objects", RelativeSizeAxes = Axes.Both, }, drumRollHitContainer.CreateProxy(), + new DrumSamplePlayer(HitObjectContainer), + // this is added at the end of the hierarchy to receive input before taiko objects. + // but is proxied below everything to not cover visual effects such as hit explosions. + inputDrum, }; RegisterPool(50); @@ -161,7 +190,7 @@ namespace osu.Game.Rulesets.Taiko.UI var hitWindows = new TaikoHitWindows(); - foreach (var result in Enum.GetValues(typeof(HitResult)).OfType().Where(r => hitWindows.IsHitResultAllowed(r))) + foreach (var result in Enum.GetValues().Where(r => hitWindows.IsHitResultAllowed(r))) { judgementPools.Add(result, new DrawablePool(15)); explosionPools.Add(result, new HitExplosionPool(result)); @@ -191,8 +220,10 @@ namespace osu.Game.Rulesets.Taiko.UI // Padding is required to be updated for elements which are based on "absolute" X sized elements. // This is basically allowing for correct alignment as relative pieces move around them. - rightArea.Padding = new MarginPadding { Left = leftArea.DrawWidth }; - hitTargetOffsetContent.Padding = new MarginPadding { Left = HitTarget.DrawWidth / 2 }; + rightArea.Padding = new MarginPadding { Left = inputDrum.Width }; + barLineContent.Padding = new MarginPadding { Left = HitTarget.DrawWidth / 2 }; + hitObjectContent.Padding = new MarginPadding { Left = HitTarget.DrawWidth / 2 }; + overlayContent.Padding = new MarginPadding { Left = HitTarget.DrawWidth / 2 }; mascot.Scale = new Vector2(DrawHeight / DEFAULT_HEIGHT); } @@ -243,7 +274,7 @@ namespace osu.Game.Rulesets.Taiko.UI barLinePlayfield.Add(barLine); break; - case DrawableTaikoHitObject _: + case DrawableTaikoHitObject: base.Add(h); break; @@ -259,7 +290,7 @@ namespace osu.Game.Rulesets.Taiko.UI case DrawableBarLine barLine: return barLinePlayfield.Remove(barLine); - case DrawableTaikoHitObject _: + case DrawableTaikoHitObject: return base.Remove(h); default: @@ -278,12 +309,12 @@ namespace osu.Game.Rulesets.Taiko.UI switch (result.Judgement) { - case TaikoStrongJudgement _: + case TaikoStrongJudgement: if (result.IsHit) hitExplosionContainer.Children.FirstOrDefault(e => e.JudgedObject == ((DrawableStrongNestedHit)judgedObject).ParentHitObject)?.VisualiseSecondHit(result); break; - case TaikoDrumRollTickJudgement _: + case TaikoDrumRollTickJudgement: if (!result.IsHit) break; @@ -293,15 +324,10 @@ namespace osu.Game.Rulesets.Taiko.UI break; default: - judgementContainer.Add(judgementPools[result.Type].Get(j => - { - j.Apply(result, judgedObject); + if (!result.Type.IsScorable()) + break; - j.Anchor = result.IsHit ? Anchor.TopLeft : Anchor.CentreLeft; - j.Origin = result.IsHit ? Anchor.BottomCentre : Anchor.Centre; - j.RelativePositionAxes = Axes.X; - j.X = result.IsHit ? judgedObject.Position.X : 0; - })); + judgementContainer.Add(judgementPools[result.Type].Get(j => j.Apply(result, judgedObject))); var type = (judgedObject.HitObject as Hit)?.Type ?? HitType.Centre; addExplosion(judgedObject, result.Type, type); @@ -320,7 +346,7 @@ namespace osu.Game.Rulesets.Taiko.UI kiaiExplosionContainer.Add(new KiaiHitExplosion(drawableObject, type)); } - private class ProxyContainer : LifetimeManagementContainer + private partial class ProxyContainer : LifetimeManagementContainer { public void Add(Drawable proxy) => AddInternal(proxy); diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs index 9cf530e903..42732d90e4 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs @@ -8,12 +8,12 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Taiko.UI { - public class TaikoPlayfieldAdjustmentContainer : PlayfieldAdjustmentContainer + 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 LockPlayfieldAspect = new BindableBool(true); + public readonly IBindable LockPlayfieldMaxAspect = new BindableBool(true); protected override void Update() { @@ -21,7 +21,12 @@ namespace osu.Game.Rulesets.Taiko.UI float height = default_relative_height; - if (LockPlayfieldAspect.Value) + // Players coming from stable expect to be able to change the aspect ratio regardless of the window size. + // We originally wanted to limit this more, but there was considerable pushback from the community. + // + // 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; Height = height; diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoReplayRecorder.cs b/osu.Game.Rulesets.Taiko/UI/TaikoReplayRecorder.cs index e6391d1386..57ff3b59d5 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoReplayRecorder.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoReplayRecorder.cs @@ -10,7 +10,7 @@ using osuTK; namespace osu.Game.Rulesets.Taiko.UI { - public class TaikoReplayRecorder : ReplayRecorder + public partial class TaikoReplayRecorder : ReplayRecorder { public TaikoReplayRecorder(Score score) : base(score) diff --git a/osu.Game.Rulesets.Taiko/osu.Game.Rulesets.Taiko.csproj b/osu.Game.Rulesets.Taiko/osu.Game.Rulesets.Taiko.csproj index b752c13d18..f0e1cb8e8f 100644 --- a/osu.Game.Rulesets.Taiko/osu.Game.Rulesets.Taiko.csproj +++ b/osu.Game.Rulesets.Taiko/osu.Game.Rulesets.Taiko.csproj @@ -1,6 +1,6 @@  - netstandard2.1 + net6.0 Library true bash the drum. to the beat. diff --git a/osu.Game.Tests.Android/Properties/AndroidManifest.xml b/osu.Game.Tests.Android/AndroidManifest.xml similarity index 95% rename from osu.Game.Tests.Android/Properties/AndroidManifest.xml rename to osu.Game.Tests.Android/AndroidManifest.xml index 4a63f0c357..f25b2e5328 100644 --- a/osu.Game.Tests.Android/Properties/AndroidManifest.xml +++ b/osu.Game.Tests.Android/AndroidManifest.xml @@ -1,5 +1,5 @@  - + \ No newline at end of file diff --git a/osu.Game.Tests.Android/MainActivity.cs b/osu.Game.Tests.Android/MainActivity.cs index dbe74a10da..bdb947fbb4 100644 --- a/osu.Game.Tests.Android/MainActivity.cs +++ b/osu.Game.Tests.Android/MainActivity.cs @@ -1,7 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System.Reflection; using Android.App; +using Android.OS; using osu.Framework.Android; namespace osu.Game.Tests.Android @@ -10,5 +14,16 @@ namespace osu.Game.Tests.Android public class MainActivity : AndroidGameActivity { protected override Framework.Game CreateGame() => new OsuTestBrowser(); + + protected override void OnCreate(Bundle savedInstanceState) + { + base.OnCreate(savedInstanceState); + + // See the comment in OsuGameActivity + Assembly.Load("osu.Game.Rulesets.Osu"); + Assembly.Load("osu.Game.Rulesets.Taiko"); + Assembly.Load("osu.Game.Rulesets.Catch"); + Assembly.Load("osu.Game.Rulesets.Mania"); + } } } diff --git a/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj b/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj index afafec6b1f..b745d91980 100644 --- a/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj +++ b/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj @@ -1,88 +1,34 @@ - - + - Debug - AnyCPU - 8.0.30703 - 2.0 - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9} - {EFBA0AD7-5A72-4C68-AF49-83D382785DCF};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - {122416d6-6b49-4ee2-a1e8-b825f31c79fe} + net6.0-android + Exe osu.Game.Tests osu.Game.Tests.Android - Properties\AndroidManifest.xml - armeabi-v7a;x86;arm64-v8a - - - - - - $(NoWarn);CA2007 - - None - cjk;mideast;other;rare;west - true - - + %(RecursiveDir)%(Filename)%(Extension) - - + + %(RecursiveDir)%(Filename)%(Extension) - - - %(RecursiveDir)%(Filename)%(Extension) - - - %(RecursiveDir)%(Filename)%(Extension) - - - %(RecursiveDir)%(Filename)%(Extension) - - - %(RecursiveDir)%(Filename)%(Extension) - - - %(RecursiveDir)%(Filename)%(Extension) - - - %(RecursiveDir)%(Filename)%(Extension) - + Android\%(RecursiveDir)%(Filename)%(Extension) + - - {58f6c80c-1253-4a0e-a465-b8c85ebeadf3} - osu.Game.Rulesets.Catch - - - {48f4582b-7687-4621-9cbe-5c24197cb536} - osu.Game.Rulesets.Mania - - - {c92a607b-1fdd-4954-9f92-03ff547d9080} - osu.Game.Rulesets.Osu - - - {f167e17a-7de6-4af5-b920-a5112296c695} - osu.Game.Rulesets.Taiko - - - {2a66dd92-adb1-4994-89e2-c94e04acda0d} - osu.Game - + + + + + - - 5.0.0 - - diff --git a/osu.Game.Tests.iOS/AppDelegate.cs b/osu.Game.Tests.iOS/AppDelegate.cs deleted file mode 100644 index 1e703e0c0a..0000000000 --- a/osu.Game.Tests.iOS/AppDelegate.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using 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 585b495c96..e5df79f3de 100644 --- a/osu.Game.Tests.iOS/Application.cs +++ b/osu.Game.Tests.iOS/Application.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.iOS; -using UIKit; namespace osu.Game.Tests.iOS { @@ -10,7 +9,7 @@ namespace osu.Game.Tests.iOS { public static void Main(string[] args) { - UIApplication.Main(args, typeof(GameUIApplication), typeof(AppDelegate)); + GameApplication.Main(new OsuTestBrowser()); } } } diff --git a/osu.Game.Tests.iOS/Info.plist b/osu.Game.Tests.iOS/Info.plist index 31e2b3f257..ac661f6263 100644 --- a/osu.Game.Tests.iOS/Info.plist +++ b/osu.Game.Tests.iOS/Info.plist @@ -13,7 +13,7 @@ LSRequiresIPhoneOS MinimumOSVersion - 10.0 + 13.4 UIDeviceFamily 1 diff --git a/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj b/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj index 05b3cad6da..79771fcd50 100644 --- a/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj +++ b/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj @@ -1,54 +1,26 @@ - - + - Debug - iPhoneSimulator Exe - {65FF8E19-6934-469B-B690-23C6D6E56A17} + net6.0-ios + 13.4 osu.Game.Tests osu.Game.Tests.iOS - - - - Linker.xml - - - %(RecursiveDir)%(Filename)%(Extension) - - $(NoWarn);CA2007 - - - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D} - osu.Game - - - {C92A607B-1FDD-4954-9F92-03FF547D9080} - osu.Game.Rulesets.Osu - - - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3} - osu.Game.Rulesets.Catch - - - {48F4582B-7687-4621-9CBE-5C24197CB536} - osu.Game.Rulesets.Mania - - - {F167E17A-7DE6-4AF5-B920-A5112296C695} - osu.Game.Rulesets.Taiko - + + + + + - diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs index e2d9910b82..518981980b 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using System.IO; using System.Linq; using NUnit.Framework; @@ -159,6 +160,21 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + [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() { @@ -179,16 +195,19 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.AreEqual(956, timingPoint.Time); Assert.AreEqual(329.67032967033, timingPoint.BeatLength); Assert.AreEqual(TimeSignature.SimpleQuadruple, timingPoint.TimeSignature); + Assert.IsFalse(timingPoint.OmitFirstBarLine); timingPoint = controlPoints.TimingPointAt(48428); Assert.AreEqual(956, timingPoint.Time); Assert.AreEqual(329.67032967033d, timingPoint.BeatLength); Assert.AreEqual(TimeSignature.SimpleQuadruple, timingPoint.TimeSignature); + Assert.IsFalse(timingPoint.OmitFirstBarLine); timingPoint = controlPoints.TimingPointAt(119637); Assert.AreEqual(119637, timingPoint.Time); Assert.AreEqual(659.340659340659, timingPoint.BeatLength); Assert.AreEqual(TimeSignature.SimpleQuadruple, timingPoint.TimeSignature); + Assert.IsFalse(timingPoint.OmitFirstBarLine); var difficultyPoint = controlPoints.DifficultyPointAt(0); Assert.AreEqual(0, difficultyPoint.Time); @@ -220,17 +239,14 @@ namespace osu.Game.Tests.Beatmaps.Formats var effectPoint = controlPoints.EffectPointAt(0); Assert.AreEqual(0, effectPoint.Time); Assert.IsFalse(effectPoint.KiaiMode); - Assert.IsFalse(effectPoint.OmitFirstBarLine); effectPoint = controlPoints.EffectPointAt(53703); Assert.AreEqual(53703, effectPoint.Time); Assert.IsTrue(effectPoint.KiaiMode); - Assert.IsFalse(effectPoint.OmitFirstBarLine); effectPoint = controlPoints.EffectPointAt(116637); Assert.AreEqual(95901, effectPoint.Time); Assert.IsFalse(effectPoint.KiaiMode); - Assert.IsFalse(effectPoint.OmitFirstBarLine); } } @@ -271,6 +287,28 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + [Test] + public void TestDecodeOmitBarLineEffect() + { + var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; + + using (var resStream = TestResources.OpenResource("omit-barline-control-points.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + var controlPoints = (LegacyControlPointInfo)decoder.Decode(stream).ControlPointInfo; + + Assert.That(controlPoints.TimingPoints.Count, Is.EqualTo(6)); + Assert.That(controlPoints.EffectPoints.Count, Is.EqualTo(0)); + + Assert.That(controlPoints.TimingPointAt(500).OmitFirstBarLine, Is.False); + Assert.That(controlPoints.TimingPointAt(1500).OmitFirstBarLine, Is.True); + Assert.That(controlPoints.TimingPointAt(2500).OmitFirstBarLine, Is.False); + Assert.That(controlPoints.TimingPointAt(3500).OmitFirstBarLine, Is.False); + Assert.That(controlPoints.TimingPointAt(4500).OmitFirstBarLine, Is.False); + Assert.That(controlPoints.TimingPointAt(5500).OmitFirstBarLine, Is.True); + } + } + [Test] public void TestTimingPointResetsSpeedMultiplier() { @@ -296,6 +334,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), @@ -304,7 +344,7 @@ namespace osu.Game.Tests.Beatmaps.Formats new Color4(128, 255, 128, 255), new Color4(255, 187, 255, 255), new Color4(255, 177, 140, 255), - new Color4(100, 100, 100, 100), + new Color4(100, 100, 100, 255), // alpha is specified as 100, but should be ignored. }; Assert.AreEqual(expectedColors.Length, comboColors.Count); for (int i = 0; i < expectedColors.Length; i++) @@ -312,6 +352,24 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + [Test] + public void TestGetLastObjectTime() + { + var decoder = new LegacyBeatmapDecoder(); + + using (var resStream = TestResources.OpenResource("mania-last-object-not-latest.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + var beatmap = decoder.Decode(stream); + + Assert.That(beatmap.HitObjects.Last().StartTime, Is.EqualTo(2494)); + Assert.That(beatmap.HitObjects.Last().GetEndTime(), Is.EqualTo(2494)); + + Assert.That(beatmap.HitObjects.Max(h => h.GetEndTime()), Is.EqualTo(2582)); + Assert.That(beatmap.GetLastObjectTime(), Is.EqualTo(2582)); + } + } + [Test] public void TestDecodeBeatmapComboOffsetsOsu() { @@ -373,14 +431,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)); } @@ -536,8 +594,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)) @@ -554,8 +612,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)) @@ -572,8 +630,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)) @@ -590,8 +648,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)) @@ -608,8 +666,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)) @@ -636,7 +694,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)) @@ -917,5 +975,30 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.That(controlPoints[1].Position, Is.Not.EqualTo(Vector2.Zero)); } } + + [Test] + public void TestNaNControlPoints() + { + var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; + + using (var resStream = TestResources.OpenResource("nan-control-points.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + var controlPoints = (LegacyControlPointInfo)decoder.Decode(stream).ControlPointInfo; + + Assert.That(controlPoints.TimingPoints.Count, Is.EqualTo(1)); + Assert.That(controlPoints.DifficultyPoints.Count, Is.EqualTo(2)); + + Assert.That(controlPoints.TimingPointAt(1000).BeatLength, Is.EqualTo(500)); + + Assert.That(controlPoints.DifficultyPointAt(2000).SliderVelocity, Is.EqualTo(1)); + Assert.That(controlPoints.DifficultyPointAt(3000).SliderVelocity, Is.EqualTo(1)); + +#pragma warning disable 618 + Assert.That(((LegacyBeatmapDecoder.LegacyDifficultyControlPoint)controlPoints.DifficultyPointAt(2000)).GenerateTicks, Is.False); + Assert.That(((LegacyBeatmapDecoder.LegacyDifficultyControlPoint)controlPoints.DifficultyPointAt(3000)).GenerateTicks, Is.True); +#pragma warning restore 618 + } + } } } diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs index 0d436c1ef7..09130ac57d 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections; using System.Collections.Generic; diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyDecoderTest.cs index 4334c4d7a2..c1c9e0d118 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyDecoderTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using NUnit.Framework; using osu.Game.Beatmaps.Formats; diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs index 1474f2d277..93cda34ef7 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Globalization; using System.IO; @@ -14,7 +16,9 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Replays; @@ -65,6 +69,24 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + [Test] + public void TestDecodeTaikoReplay() + { + var decoder = new TestLegacyScoreDecoder(); + + using (var resourceStream = TestResources.OpenResource("Replays/taiko-replay.osr")) + { + var score = decoder.Parse(resourceStream); + + Assert.AreEqual(1, score.ScoreInfo.Ruleset.OnlineID); + Assert.AreEqual(4, score.ScoreInfo.Statistics[HitResult.Great]); + Assert.AreEqual(2, score.ScoreInfo.Statistics[HitResult.LargeBonus]); + Assert.AreEqual(4, score.ScoreInfo.MaxCombo); + + Assert.That(score.Replay.Frames, Is.Not.Empty); + } + } + [TestCase(3, true)] [TestCase(6, false)] [TestCase(LegacyBeatmapDecoder.LATEST_VERSION, false)] @@ -159,6 +181,40 @@ namespace osu.Game.Tests.Beatmaps.Formats }); } + [Test] + public void TestSoloScoreData() + { + var ruleset = new OsuRuleset().RulesetInfo; + + var scoreInfo = TestResources.CreateTestScoreInfo(ruleset); + scoreInfo.Mods = new Mod[] + { + new OsuModDoubleTime { SpeedChange = { Value = 1.1 } } + }; + + var beatmap = new TestBeatmap(ruleset); + var score = new Score + { + ScoreInfo = scoreInfo, + Replay = new Replay + { + Frames = new List + { + new OsuReplayFrame(2000, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton) + } + } + }; + + var decodedAfterEncode = encodeThenDecode(LegacyBeatmapDecoder.LATEST_VERSION, score, beatmap); + + Assert.Multiple(() => + { + Assert.That(decodedAfterEncode.ScoreInfo.Statistics, Is.EqualTo(scoreInfo.Statistics)); + Assert.That(decodedAfterEncode.ScoreInfo.MaximumStatistics, Is.EqualTo(scoreInfo.MaximumStatistics)); + Assert.That(decodedAfterEncode.ScoreInfo.Mods, Is.EqualTo(scoreInfo.Mods)); + }); + } + private static Score encodeThenDecode(int beatmapVersion, Score score, TestBeatmap beatmap) { var encodeStream = new MemoryStream(); diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs index 560e2ef894..3a776ac225 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs @@ -28,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); @@ -74,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); @@ -95,6 +95,25 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + [Test] + public void TestCorrectAnimationStartTime() + { + var decoder = new LegacyStoryboardDecoder(); + + using (var resStream = TestResources.OpenResource("animation-starts-before-alpha.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); + // This property should be used in DrawableStoryboardAnimation as a starting point for animation playback. + Assert.AreEqual(1000, (background.Elements[0] as StoryboardAnimation)?.EarliestTransformTime); + } + } + [Test] public void TestOutOfOrderStartTimes() { @@ -115,6 +134,26 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + [Test] + public void TestEarliestStartTimeWithLoopAlphas() + { + var decoder = new LegacyStoryboardDecoder(); + + using (var resStream = TestResources.OpenResource("loop-containing-earlier-non-zero-fade.osb")) + using (var stream = new LineBufferedReader(resStream)) + { + var storyboard = decoder.Decode(stream); + + StoryboardLayer background = storyboard.Layers.Single(l => l.Depth == 3); + Assert.AreEqual(2, background.Elements.Count); + + Assert.AreEqual(1000, background.Elements[0].StartTime); + Assert.AreEqual(1000, background.Elements[1].StartTime); + + Assert.AreEqual(1000, storyboard.EarliestEventTime); + } + } + [Test] public void TestDecodeVariableWithSuffix() { @@ -130,6 +169,21 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + [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 foreground = storyboard.Layers.Single(l => l.Name == "Video"); + Assert.That(foreground.Elements.Count, Is.Zero); + } + } + [Test] public void TestDecodeOutOfRangeLoopAnimationType() { @@ -173,7 +227,9 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.That(oneTime.EndTime, Is.EqualTo(4000 + loop_duration)); StoryboardSprite manyTimes = background.Elements.OfType().Single(s => s.Path == "many-times.png"); - Assert.That(manyTimes.EndTime, Is.EqualTo(9000 + 40 * loop_duration)); + // It is intentional that we don't consider the loop count (40) as part of the end time calculation to match stable's handling. + // If we were to include the loop count, storyboards which loop for stupid long loop counts would continue playing the outro forever. + Assert.That(manyTimes.EndTime, Is.EqualTo(9000 + loop_duration)); } } } diff --git a/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs index 2eb75259d9..3764467047 100644 --- a/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.IO; using System.Linq; using DeepEqual.Syntax; diff --git a/osu.Game.Tests/Beatmaps/Formats/ParsingTest.cs b/osu.Game.Tests/Beatmaps/Formats/ParsingTest.cs index 669acc3202..339063633a 100644 --- a/osu.Game.Tests/Beatmaps/Formats/ParsingTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/ParsingTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Globalization; using NUnit.Framework; @@ -12,7 +14,12 @@ namespace osu.Game.Tests.Beatmaps.Formats public class ParsingTest { [Test] - public void TestNaNHandling() => allThrow("NaN"); + public void TestNaNHandling() + { + allThrow("NaN"); + Assert.That(Parsing.ParseFloat("NaN", allowNaN: true), Is.NaN); + Assert.That(Parsing.ParseDouble("NaN", allowNaN: true), Is.NaN); + } [Test] public void TestBadStringHandling() => allThrow("Random string 123"); diff --git a/osu.Game.Tests/Beatmaps/IO/BeatmapImportHelper.cs b/osu.Game.Tests/Beatmaps/IO/BeatmapImportHelper.cs index 9e440c6bce..055832d753 100644 --- a/osu.Game.Tests/Beatmaps/IO/BeatmapImportHelper.cs +++ b/osu.Game.Tests/Beatmaps/IO/BeatmapImportHelper.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using System.IO; diff --git a/osu.Game.Tests/Beatmaps/IO/LineBufferedReaderTest.cs b/osu.Game.Tests/Beatmaps/IO/LineBufferedReaderTest.cs index 5e37f01c81..8f20fd7a68 100644 --- a/osu.Game.Tests/Beatmaps/IO/LineBufferedReaderTest.cs +++ b/osu.Game.Tests/Beatmaps/IO/LineBufferedReaderTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.IO; using System.Text; diff --git a/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs b/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs index 810ea5dbd0..04eb9a3fa2 100644 --- a/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs +++ b/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.IO; using System.Linq; using NUnit.Framework; diff --git a/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs b/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs index 9fba0f1668..d30ab3dea1 100644 --- a/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs +++ b/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Game.Rulesets.Objects; diff --git a/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs b/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs index f3456cf8e4..ab40092b3f 100644 --- a/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs +++ b/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using System.Threading; @@ -20,7 +22,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Tests.Beatmaps { [HeadlessTest] - public class TestSceneBeatmapDifficultyCache : OsuTestScene + public partial class TestSceneBeatmapDifficultyCache : OsuTestScene { public const double BASE_STARS = 5.55; @@ -155,12 +157,12 @@ namespace osu.Game.Tests.Beatmaps [TestCase(8.3, DifficultyRating.ExpertPlus)] public void TestDifficultyRatingMapping(double starRating, DifficultyRating expectedBracket) { - var actualBracket = BeatmapDifficultyCache.GetDifficultyRating(starRating); + var actualBracket = StarDifficulty.GetDifficultyRating(starRating); Assert.AreEqual(expectedBracket, actualBracket); } - private class TestBeatmapDifficultyCache : BeatmapDifficultyCache + private partial class TestBeatmapDifficultyCache : BeatmapDifficultyCache { public Func ComputeDifficulty { get; set; } diff --git a/osu.Game.Tests/Beatmaps/TestSceneEditorBeatmap.cs b/osu.Game.Tests/Beatmaps/TestSceneEditorBeatmap.cs index 153788c2cf..bb24560a44 100644 --- a/osu.Game.Tests/Beatmaps/TestSceneEditorBeatmap.cs +++ b/osu.Game.Tests/Beatmaps/TestSceneEditorBeatmap.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -16,7 +18,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Tests.Beatmaps { [HeadlessTest] - public class TestSceneEditorBeatmap : EditorClockTestScene + public partial class TestSceneEditorBeatmap : EditorClockTestScene { /// /// Tests that the addition event is correctly invoked after a hitobject is added. diff --git a/osu.Game.Tests/Beatmaps/ToStringFormattingTest.cs b/osu.Game.Tests/Beatmaps/ToStringFormattingTest.cs index 10cac4ed9d..b4a205b478 100644 --- a/osu.Game.Tests/Beatmaps/ToStringFormattingTest.cs +++ b/osu.Game.Tests/Beatmaps/ToStringFormattingTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Models; diff --git a/osu.Game.Tests/Beatmaps/WorkingBeatmapManagerTest.cs b/osu.Game.Tests/Beatmaps/WorkingBeatmapManagerTest.cs new file mode 100644 index 0000000000..89b8c8927d --- /dev/null +++ b/osu.Game.Tests/Beatmaps/WorkingBeatmapManagerTest.cs @@ -0,0 +1,138 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Extensions; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Database; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Beatmaps +{ + [HeadlessTest] + public partial class WorkingBeatmapManagerTest : OsuTestScene + { + private BeatmapManager beatmaps = null!; + + private BeatmapSetInfo importedSet = null!; + + [BackgroundDependencyLoader] + private void load(GameHost host, AudioManager audio, RulesetStore rulesets) + { + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); + } + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("import beatmap", () => + { + beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); + importedSet = beatmaps.GetAllUsableBeatmapSets().First(); + }); + } + + [Test] + public void TestGetWorkingBeatmap() => AddStep("run test", () => + { + Assert.That(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()), Is.Not.Null); + }); + + [Test] + public void TestCachedRetrievalNoFiles() => AddStep("run test", () => + { + var beatmap = importedSet.Beatmaps.First(); + + Assert.That(beatmap.BeatmapSet?.Files, Is.Empty); + + var first = beatmaps.GetWorkingBeatmap(beatmap); + var second = beatmaps.GetWorkingBeatmap(beatmap); + + Assert.That(first, Is.SameAs(second)); + Assert.That(first.BeatmapInfo.BeatmapSet?.Files, Has.Count.GreaterThan(0)); + }); + + [Test] + public void TestCachedRetrievalWithFiles() => AddStep("run test", () => + { + var beatmap = Realm.Run(r => r.Find(importedSet.Beatmaps.First().ID).Detach()); + + Assert.That(beatmap.BeatmapSet?.Files, Has.Count.GreaterThan(0)); + + var first = beatmaps.GetWorkingBeatmap(beatmap); + var second = beatmaps.GetWorkingBeatmap(beatmap); + + Assert.That(first, Is.SameAs(second)); + Assert.That(first.BeatmapInfo.BeatmapSet?.Files, Has.Count.GreaterThan(0)); + }); + + [Test] + public void TestForcedRefetchRetrievalNoFiles() => AddStep("run test", () => + { + var beatmap = importedSet.Beatmaps.First(); + + Assert.That(beatmap.BeatmapSet?.Files, Is.Empty); + + var first = beatmaps.GetWorkingBeatmap(beatmap); + var second = beatmaps.GetWorkingBeatmap(beatmap, true); + Assert.That(first, Is.Not.SameAs(second)); + }); + + [Test] + public void TestForcedRefetchRetrievalWithFiles() => AddStep("run test", () => + { + var beatmap = Realm.Run(r => r.Find(importedSet.Beatmaps.First().ID).Detach()); + + Assert.That(beatmap.BeatmapSet?.Files, Has.Count.GreaterThan(0)); + + var first = beatmaps.GetWorkingBeatmap(beatmap); + var second = beatmaps.GetWorkingBeatmap(beatmap, true); + Assert.That(first, Is.Not.SameAs(second)); + }); + + [Test] + public void TestSavePreservesCollections() => AddStep("run test", () => + { + var beatmap = Realm.Run(r => r.Find(importedSet.Beatmaps.First().ID).Detach()); + + var working = beatmaps.GetWorkingBeatmap(beatmap); + + Assert.That(working.BeatmapInfo.BeatmapSet?.Files, Has.Count.GreaterThan(0)); + + string initialHash = working.BeatmapInfo.MD5Hash; + + var preserveCollection = new BeatmapCollection("test contained"); + preserveCollection.BeatmapMD5Hashes.Add(initialHash); + + var noNewCollection = new BeatmapCollection("test not contained"); + + Realm.Write(r => + { + r.Add(preserveCollection); + r.Add(noNewCollection); + }); + + Assert.That(preserveCollection.BeatmapMD5Hashes, Does.Contain(initialHash)); + Assert.That(noNewCollection.BeatmapMD5Hashes, Does.Not.Contain(initialHash)); + + beatmaps.Save(working.BeatmapInfo, working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo)); + + string finalHash = working.BeatmapInfo.MD5Hash; + + Assert.That(finalHash, Is.Not.SameAs(initialHash)); + + Assert.That(preserveCollection.BeatmapMD5Hashes, Does.Not.Contain(initialHash)); + Assert.That(preserveCollection.BeatmapMD5Hashes, Does.Contain(finalHash)); + Assert.That(noNewCollection.BeatmapMD5Hashes, Does.Not.Contain(finalHash)); + }); + } +} diff --git a/osu.Game.Tests/Beatmaps/WorkingBeatmapTest.cs b/osu.Game.Tests/Beatmaps/WorkingBeatmapTest.cs index 5f70f08413..f4b1028c0e 100644 --- a/osu.Game.Tests/Beatmaps/WorkingBeatmapTest.cs +++ b/osu.Game.Tests/Beatmaps/WorkingBeatmapTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Threading; diff --git a/osu.Game.Tests/Chat/MessageFormatterTests.cs b/osu.Game.Tests/Chat/MessageFormatterTests.cs index cea4d510c1..3c35dc311f 100644 --- a/osu.Game.Tests/Chat/MessageFormatterTests.cs +++ b/osu.Game.Tests/Chat/MessageFormatterTests.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Online.Chat; @@ -24,6 +26,16 @@ namespace osu.Game.Tests.Chat MessageFormatter.WebsiteRootUrl = originalWebsiteRootUrl; } + [Test] + public void TestUnsupportedProtocolLink() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a gopher://really-old-protocol we don't support." }); + + Assert.AreEqual(result.Content, result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("gopher://really-old-protocol", result.Links[0].Url); + } + [Test] public void TestBareLink() { diff --git a/osu.Game.Tests/Chat/TestSceneChannelManager.cs b/osu.Game.Tests/Chat/TestSceneChannelManager.cs index eaacc623c9..3a4c55c65c 100644 --- a/osu.Game.Tests/Chat/TestSceneChannelManager.cs +++ b/osu.Game.Tests/Chat/TestSceneChannelManager.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -16,16 +18,17 @@ using osu.Game.Tests.Visual; namespace osu.Game.Tests.Chat { [HeadlessTest] - public class TestSceneChannelManager : OsuTestScene + public partial class TestSceneChannelManager : OsuTestScene { private ChannelManager channelManager; private int currentMessageId; private List sentMessages; + private List silencedUserIds; [SetUp] public void Setup() => Schedule(() => { - var container = new ChannelManagerContainer(); + var container = new ChannelManagerContainer(API); Child = container; channelManager = container.ChannelManager; }); @@ -37,6 +40,7 @@ namespace osu.Game.Tests.Chat { currentMessageId = 0; sentMessages = new List(); + silencedUserIds = new List(); ((DummyAPIAccess)API).HandleRequest = req => { @@ -53,11 +57,26 @@ namespace osu.Game.Tests.Chat case MarkChannelAsReadRequest markRead: handleMarkChannelAsReadRequest(markRead); return true; + + case ChatAckRequest ack: + ack.TriggerSuccess(new ChatAckResponse { Silences = silencedUserIds.Select(u => new ChatSilence { UserId = u }).ToList() }); + silencedUserIds.Clear(); + return true; + + case GetUpdatesRequest updatesRequest: + updatesRequest.TriggerSuccess(new GetUpdatesResponse + { + Messages = sentMessages.ToList(), + Presence = new List() + }); + return true; } return false; }; }); + + AddUntilStep("wait for notifications client", () => channelManager.NotificationsConnected); } [Test] @@ -93,6 +112,7 @@ namespace osu.Game.Tests.Chat }); AddStep("post message", () => channelManager.PostMessage("Something interesting")); + AddUntilStep("message postesd", () => !channel.Messages.Any(m => m is LocalMessage)); AddStep("post /help command", () => channelManager.PostCommand("help", channel)); AddStep("post /me command with no action", () => channelManager.PostCommand("me", channel)); @@ -104,6 +124,28 @@ namespace osu.Game.Tests.Chat AddAssert("channel's last read ID is set to the latest message", () => channel.LastReadId == sentMessages.Last().Id); } + [Test] + public void TestSilencedUsersAreRemoved() + { + Channel channel = null; + + AddStep("join channel and select it", () => + { + channelManager.JoinChannel(channel = createChannel(1, ChannelType.Public)); + channelManager.CurrentChannel.Value = channel; + }); + + AddStep("post message", () => channelManager.PostMessage("Definitely something bad")); + + AddStep("mark user as silenced and send ack request", () => + { + silencedUserIds.Add(API.LocalUser.Value.OnlineID); + channelManager.SendAck(); + }); + + AddAssert("channel has no more messages", () => channel.Messages, () => Is.Empty); + } + private void handlePostMessageRequest(PostMessageRequest request) { var message = new Message(++currentMessageId) @@ -113,7 +155,8 @@ namespace osu.Game.Tests.Chat Content = request.Message.Content, Links = request.Message.Links, Timestamp = request.Message.Timestamp, - Sender = request.Message.Sender + Sender = request.Message.Sender, + Uuid = request.Message.Uuid }; sentMessages.Add(message); @@ -142,14 +185,14 @@ namespace osu.Game.Tests.Chat LastMessageId = 0, }; - private class ChannelManagerContainer : CompositeDrawable + private partial class ChannelManagerContainer : CompositeDrawable { [Cached] - public ChannelManager ChannelManager { get; } = new ChannelManager(); + public ChannelManager ChannelManager { get; } - public ChannelManagerContainer() + public ChannelManagerContainer(IAPIProvider apiProvider) { - InternalChild = ChannelManager; + InternalChild = ChannelManager = new ChannelManager(apiProvider); } } } diff --git a/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs b/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs index 5cbede54f5..9079ecdc48 100644 --- a/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs +++ b/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs @@ -1,14 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.IO; +using System.Linq; using System.Text; using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Extensions; using osu.Framework.Platform; using osu.Framework.Testing; +using osu.Game.Collections; +using osu.Game.Database; using osu.Game.Tests.Resources; namespace osu.Game.Tests.Collections.IO @@ -27,7 +32,11 @@ namespace osu.Game.Tests.Collections.IO await importCollectionsFromStream(osu, new MemoryStream()); - Assert.That(osu.CollectionManager.Collections.Count, Is.Zero); + osu.Realm.Run(realm => + { + var collections = realm.All().ToList(); + Assert.That(collections.Count, Is.Zero); + }); } finally { @@ -47,13 +56,22 @@ namespace osu.Game.Tests.Collections.IO await importCollectionsFromStream(osu, TestResources.OpenResource("Collections/collections.db")); - Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2)); + osu.Realm.Run(realm => + { + var collections = realm.All().ToList(); + Assert.That(collections.Count, Is.EqualTo(2)); - Assert.That(osu.CollectionManager.Collections[0].Name.Value, Is.EqualTo("First")); - Assert.That(osu.CollectionManager.Collections[0].Beatmaps.Count, Is.Zero); + // Even with no beatmaps imported, collections are tracking the hashes and will continue to. + // In the future this whole mechanism will be replaced with having the collections in realm, + // but until that happens it makes rough sense that we want to track not-yet-imported beatmaps + // and have them associate with collections if/when they become available. - Assert.That(osu.CollectionManager.Collections[1].Name.Value, Is.EqualTo("Second")); - Assert.That(osu.CollectionManager.Collections[1].Beatmaps.Count, Is.Zero); + Assert.That(collections[0].Name, Is.EqualTo("First")); + Assert.That(collections[0].BeatmapMD5Hashes.Count, Is.EqualTo(1)); + + Assert.That(collections[1].Name, Is.EqualTo("Second")); + Assert.That(collections[1].BeatmapMD5Hashes.Count, Is.EqualTo(12)); + }); } finally { @@ -73,13 +91,18 @@ namespace osu.Game.Tests.Collections.IO await importCollectionsFromStream(osu, TestResources.OpenResource("Collections/collections.db")); - Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2)); + osu.Realm.Run(realm => + { + var collections = realm.All().ToList(); - Assert.That(osu.CollectionManager.Collections[0].Name.Value, Is.EqualTo("First")); - Assert.That(osu.CollectionManager.Collections[0].Beatmaps.Count, Is.EqualTo(1)); + Assert.That(collections.Count, Is.EqualTo(2)); - Assert.That(osu.CollectionManager.Collections[1].Name.Value, Is.EqualTo("Second")); - Assert.That(osu.CollectionManager.Collections[1].Beatmaps.Count, Is.EqualTo(12)); + Assert.That(collections[0].Name, Is.EqualTo("First")); + Assert.That(collections[0].BeatmapMD5Hashes.Count, Is.EqualTo(1)); + + Assert.That(collections[1].Name, Is.EqualTo("Second")); + Assert.That(collections[1].BeatmapMD5Hashes.Count, Is.EqualTo(12)); + }); } finally { @@ -92,7 +115,7 @@ namespace osu.Game.Tests.Collections.IO public async Task TestImportMalformedDatabase() { bool exceptionThrown = false; - UnhandledExceptionEventHandler setException = (_, __) => exceptionThrown = true; + UnhandledExceptionEventHandler setException = (_, _) => exceptionThrown = true; using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { @@ -116,7 +139,11 @@ namespace osu.Game.Tests.Collections.IO } Assert.That(exceptionThrown, Is.False); - Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(0)); + osu.Realm.Run(realm => + { + var collections = realm.All().ToList(); + Assert.That(collections.Count, Is.EqualTo(0)); + }); } finally { @@ -131,7 +158,7 @@ namespace osu.Game.Tests.Collections.IO { string firstRunName; - using (var host = new CleanRunHeadlessGameHost(bypassCleanup: true)) + using (var host = new CleanRunHeadlessGameHost(bypassCleanupOnDispose: true)) { firstRunName = host.Name; @@ -141,12 +168,18 @@ namespace osu.Game.Tests.Collections.IO await importCollectionsFromStream(osu, TestResources.OpenResource("Collections/collections.db")); - // Move first beatmap from second collection into the first. - osu.CollectionManager.Collections[0].Beatmaps.Add(osu.CollectionManager.Collections[1].Beatmaps[0]); - osu.CollectionManager.Collections[1].Beatmaps.RemoveAt(0); + // ReSharper disable once MethodHasAsyncOverload + osu.Realm.Write(realm => + { + var collections = realm.All().ToList(); - // Rename the second collecction. - osu.CollectionManager.Collections[1].Name.Value = "Another"; + // Move first beatmap from second collection into the first. + collections[0].BeatmapMD5Hashes.Add(collections[1].BeatmapMD5Hashes[0]); + collections[1].BeatmapMD5Hashes.RemoveAt(0); + + // Rename the second collecction. + collections[1].Name = "Another"; + }); } finally { @@ -155,19 +188,23 @@ namespace osu.Game.Tests.Collections.IO } // Name matches the automatically chosen name from `CleanRunHeadlessGameHost` above, so we end up using the same storage location. - using (HeadlessGameHost host = new TestRunHeadlessGameHost(firstRunName, null)) + using (HeadlessGameHost host = new TestRunHeadlessGameHost(firstRunName)) { try { var osu = LoadOsuIntoHost(host, true); - Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2)); + osu.Realm.Run(realm => + { + var collections = realm.All().ToList(); + Assert.That(collections.Count, Is.EqualTo(2)); - Assert.That(osu.CollectionManager.Collections[0].Name.Value, Is.EqualTo("First")); - Assert.That(osu.CollectionManager.Collections[0].Beatmaps.Count, Is.EqualTo(2)); + Assert.That(collections[0].Name, Is.EqualTo("First")); + Assert.That(collections[0].BeatmapMD5Hashes.Count, Is.EqualTo(2)); - Assert.That(osu.CollectionManager.Collections[1].Name.Value, Is.EqualTo("Another")); - Assert.That(osu.CollectionManager.Collections[1].Beatmaps.Count, Is.EqualTo(11)); + Assert.That(collections[1].Name, Is.EqualTo("Another")); + Assert.That(collections[1].BeatmapMD5Hashes.Count, Is.EqualTo(11)); + }); } finally { @@ -180,7 +217,7 @@ namespace osu.Game.Tests.Collections.IO { // intentionally spin this up on a separate task to avoid disposal deadlocks. // see https://github.com/EventStore/EventStore/issues/1179 - await Task.Factory.StartNew(() => osu.CollectionManager.Import(stream).WaitSafely(), TaskCreationOptions.LongRunning); + await Task.Factory.StartNew(() => new LegacyCollectionImporter(osu.Realm).Import(stream).WaitSafely(), TaskCreationOptions.LongRunning); } } } diff --git a/osu.Game.Tests/Database/BackgroundBeatmapProcessorTests.cs b/osu.Game.Tests/Database/BackgroundBeatmapProcessorTests.cs new file mode 100644 index 0000000000..ddb60606ec --- /dev/null +++ b/osu.Game.Tests/Database/BackgroundBeatmapProcessorTests.cs @@ -0,0 +1,132 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Screens.Play; +using osu.Game.Tests.Beatmaps.IO; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Database +{ + [HeadlessTest] + public partial class BackgroundBeatmapProcessorTests : OsuTestScene, ILocalUserPlayInfo + { + public IBindable IsPlaying => isPlaying; + + private readonly Bindable isPlaying = new Bindable(); + + private BeatmapSetInfo importedSet = null!; + + [BackgroundDependencyLoader] + private void load(OsuGameBase osu) + { + importedSet = BeatmapImportHelper.LoadQuickOszIntoOsu(osu).GetResultSafely(); + } + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("Set not playing", () => isPlaying.Value = false); + } + + [Test] + public void TestDifficultyProcessing() + { + AddAssert("Difficulty is initially set", () => + { + return Realm.Run(r => + { + var beatmapSetInfo = r.Find(importedSet.ID); + return beatmapSetInfo.Beatmaps.All(b => b.StarRating > 0); + }); + }); + + AddStep("Reset difficulty", () => + { + Realm.Write(r => + { + var beatmapSetInfo = r.Find(importedSet.ID); + foreach (var b in beatmapSetInfo.Beatmaps) + b.StarRating = -1; + }); + }); + + AddStep("Run background processor", () => + { + Add(new TestBackgroundBeatmapProcessor()); + }); + + AddUntilStep("wait for difficulties repopulated", () => + { + return Realm.Run(r => + { + var beatmapSetInfo = r.Find(importedSet.ID); + return beatmapSetInfo.Beatmaps.All(b => b.StarRating > 0); + }); + }); + } + + [Test] + public void TestDifficultyProcessingWhilePlaying() + { + AddAssert("Difficulty is initially set", () => + { + return Realm.Run(r => + { + var beatmapSetInfo = r.Find(importedSet.ID); + return beatmapSetInfo.Beatmaps.All(b => b.StarRating > 0); + }); + }); + + AddStep("Set playing", () => isPlaying.Value = true); + + AddStep("Reset difficulty", () => + { + Realm.Write(r => + { + var beatmapSetInfo = r.Find(importedSet.ID); + foreach (var b in beatmapSetInfo.Beatmaps) + b.StarRating = -1; + }); + }); + + AddStep("Run background processor", () => + { + Add(new TestBackgroundBeatmapProcessor()); + }); + + AddWaitStep("wait some", 500); + + AddAssert("Difficulty still not populated", () => + { + return Realm.Run(r => + { + var beatmapSetInfo = r.Find(importedSet.ID); + return beatmapSetInfo.Beatmaps.All(b => b.StarRating == -1); + }); + }); + + AddStep("Set not playing", () => isPlaying.Value = false); + + AddUntilStep("wait for difficulties repopulated", () => + { + return Realm.Run(r => + { + var beatmapSetInfo = r.Find(importedSet.ID); + return beatmapSetInfo.Beatmaps.All(b => b.StarRating > 0); + }); + }); + } + + public partial class TestBackgroundBeatmapProcessor : BackgroundBeatmapProcessor + { + protected override int TimeToSleepDuringGameplay => 10; + } + } +} diff --git a/osu.Game.Tests/Database/BeatmapImporterTests.cs b/osu.Game.Tests/Database/BeatmapImporterTests.cs index d4956e97e0..446eb72b04 100644 --- a/osu.Game.Tests/Database/BeatmapImporterTests.cs +++ b/osu.Game.Tests/Database/BeatmapImporterTests.cs @@ -12,15 +12,12 @@ using NUnit.Framework; using osu.Framework.Extensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Logging; -using osu.Framework.Platform; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Extensions; -using osu.Game.IO.Archives; using osu.Game.Models; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; -using osu.Game.Stores; using osu.Game.Tests.Resources; using Realms; using SharpCompress.Archives; @@ -28,8 +25,6 @@ using SharpCompress.Archives.Zip; using SharpCompress.Common; using SharpCompress.Writers.Zip; -#nullable enable - namespace osu.Game.Tests.Database { [TestFixture] @@ -40,13 +35,11 @@ namespace osu.Game.Tests.Database { RunTestWithRealmAsync(async (realm, storage) => { - using (var importer = new BeatmapModelManager(realm, storage)) + var importer = new BeatmapImporter(storage, realm); + using (new RealmRulesetStore(realm, storage)) { - Live? beatmapSet; - - using (var reader = new ZipArchiveReader(TestResources.GetTestBeatmapStream())) - beatmapSet = await importer.Import(reader); + var beatmapSet = await importer.Import(new ImportTask(TestResources.GetTestBeatmapStream(), "renatus.osz")); Assert.NotNull(beatmapSet); Debug.Assert(beatmapSet != null); @@ -84,13 +77,11 @@ namespace osu.Game.Tests.Database { RunTestWithRealmAsync(async (realm, storage) => { - using (var importer = new BeatmapModelManager(realm, storage)) + var importer = new BeatmapImporter(storage, realm); + using (new RealmRulesetStore(realm, storage)) { - Live? beatmapSet; - - using (var reader = new ZipArchiveReader(TestResources.GetTestBeatmapStream())) - beatmapSet = await importer.Import(reader); + var beatmapSet = await importer.Import(new ImportTask(TestResources.GetTestBeatmapStream(), "renatus.osz")); Assert.NotNull(beatmapSet); Debug.Assert(beatmapSet != null); @@ -143,16 +134,15 @@ namespace osu.Game.Tests.Database { BeatmapSetInfo? detachedSet = null; - using (var importer = new BeatmapModelManager(realm, storage)) + var manager = new ModelManager(storage, realm); + + var importer = new BeatmapImporter(storage, realm); + using (new RealmRulesetStore(realm, storage)) { Task.Run(async () => { - Live? beatmapSet; - - using (var reader = new ZipArchiveReader(TestResources.GetTestBeatmapStream())) - // ReSharper disable once AccessToDisposedClosure - beatmapSet = await importer.Import(reader); + var beatmapSet = await importer.Import(new ImportTask(TestResources.GetTestBeatmapStream(), "renatus.osz")); Assert.NotNull(beatmapSet); Debug.Assert(beatmapSet != null); @@ -162,7 +152,7 @@ namespace osu.Game.Tests.Database }).WaitSafely(); Debug.Assert(detachedSet != null); - importer.AddFile(detachedSet, new MemoryStream(), "test"); + manager.AddFile(detachedSet, new MemoryStream(), "test"); } }); } @@ -172,16 +162,12 @@ namespace osu.Game.Tests.Database { RunTestWithRealmAsync(async (realm, storage) => { - using (var importer = new BeatmapModelManager(realm, storage)) + var importer = new BeatmapImporter(storage, realm); + using (new RealmRulesetStore(realm, storage)) { - Live? imported; - - using (var reader = new ZipArchiveReader(TestResources.GetTestBeatmapStream())) - { - imported = await importer.Import(reader); - EnsureLoaded(realm.Realm); - } + var imported = await importer.Import(new ImportTask(TestResources.GetTestBeatmapStream(), "renatus.osz")); + EnsureLoaded(realm.Realm); Assert.AreEqual(1, realm.Realm.All().Count()); @@ -204,7 +190,7 @@ namespace osu.Game.Tests.Database { RunTestWithRealmAsync(async (realm, storage) => { - using var importer = new BeatmapModelManager(realm, storage); + var importer = new BeatmapImporter(storage, realm); using var store = new RealmRulesetStore(realm, storage); await LoadOszIntoStore(importer, realm.Realm); @@ -216,7 +202,7 @@ namespace osu.Game.Tests.Database { RunTestWithRealmAsync(async (realm, storage) => { - using var importer = new BeatmapModelManager(realm, storage); + var importer = new BeatmapImporter(storage, realm); using var store = new RealmRulesetStore(realm, storage); var imported = await LoadOszIntoStore(importer, realm.Realm); @@ -234,7 +220,7 @@ namespace osu.Game.Tests.Database { RunTestWithRealmAsync(async (realm, storage) => { - using var importer = new BeatmapModelManager(realm, storage); + var importer = new BeatmapImporter(storage, realm); using var store = new RealmRulesetStore(realm, storage); var imported = await LoadOszIntoStore(importer, realm.Realm); @@ -248,7 +234,7 @@ namespace osu.Game.Tests.Database { RunTestWithRealmAsync(async (realm, storage) => { - using var importer = new BeatmapModelManager(realm, storage); + var importer = new BeatmapImporter(storage, realm); using var store = new RealmRulesetStore(realm, storage); string? tempPath = TestResources.GetTestBeatmapForImport(); @@ -278,7 +264,7 @@ namespace osu.Game.Tests.Database { RunTestWithRealmAsync(async (realm, storage) => { - using var importer = new BeatmapModelManager(realm, storage); + var importer = new BeatmapImporter(storage, realm); using var store = new RealmRulesetStore(realm, storage); var imported = await LoadOszIntoStore(importer, realm.Realm); @@ -293,12 +279,49 @@ namespace osu.Game.Tests.Database }); } + [Test] + public void TestImportDirectoryWithEmptyOsuFiles() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var store = new RealmRulesetStore(realm, storage); + + string? temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + Directory.CreateDirectory(extractedFolder); + + try + { + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); + + foreach (var file in new DirectoryInfo(extractedFolder).GetFiles("*.osu")) + { + using (file.Open(FileMode.Create)) + { + // empty file. + } + } + + var imported = await importer.Import(new ImportTask(extractedFolder)); + Assert.IsNull(imported); + } + finally + { + File.Delete(temp); + Directory.Delete(extractedFolder, true); + } + }); + } + [Test] public void TestImportThenImportWithReZip() { RunTestWithRealmAsync(async (realm, storage) => { - using var importer = new BeatmapModelManager(realm, storage); + var importer = new BeatmapImporter(storage, realm); using var store = new RealmRulesetStore(realm, storage); string? temp = TestResources.GetTestBeatmapForImport(); @@ -347,7 +370,7 @@ namespace osu.Game.Tests.Database { RunTestWithRealmAsync(async (realm, storage) => { - using var importer = new BeatmapModelManager(realm, storage); + var importer = new BeatmapImporter(storage, realm); using var store = new RealmRulesetStore(realm, storage); string? temp = TestResources.GetTestBeatmapForImport(); @@ -394,12 +417,11 @@ namespace osu.Game.Tests.Database } [Test] - [Ignore("intentionally broken by import optimisations")] public void TestImportThenImportWithChangedFile() { RunTestWithRealmAsync(async (realm, storage) => { - using var importer = new BeatmapModelManager(realm, storage); + var importer = new BeatmapImporter(storage, realm); using var store = new RealmRulesetStore(realm, storage); string? temp = TestResources.GetTestBeatmapForImport(); @@ -447,7 +469,7 @@ namespace osu.Game.Tests.Database { RunTestWithRealmAsync(async (realm, storage) => { - using var importer = new BeatmapModelManager(realm, storage); + var importer = new BeatmapImporter(storage, realm); using var store = new RealmRulesetStore(realm, storage); string? temp = TestResources.GetTestBeatmapForImport(); @@ -491,28 +513,29 @@ namespace osu.Game.Tests.Database } [Test] - [Ignore("intentionally broken by import optimisations")] public void TestImportCorruptThenImport() { RunTestWithRealmAsync(async (realm, storage) => { - using var importer = new BeatmapModelManager(realm, storage); + var importer = new BeatmapImporter(storage, realm); using var store = new RealmRulesetStore(realm, storage); var imported = await LoadOszIntoStore(importer, realm.Realm); var firstFile = imported.Files.First(); + var fileStorage = storage.GetStorageForDirectory("files"); + long originalLength; - using (var stream = storage.GetStream(firstFile.File.GetStoragePath())) + using (var stream = fileStorage.GetStream(firstFile.File.GetStoragePath())) originalLength = stream.Length; - using (var stream = storage.CreateFileSafely(firstFile.File.GetStoragePath())) + using (var stream = fileStorage.CreateFileSafely(firstFile.File.GetStoragePath())) stream.WriteByte(0); var importedSecondTime = await LoadOszIntoStore(importer, realm.Realm); - using (var stream = storage.GetStream(firstFile.File.GetStoragePath())) + using (var stream = fileStorage.GetStream(firstFile.File.GetStoragePath())) Assert.AreEqual(stream.Length, originalLength, "Corruption was not fixed on second import"); // check the newly "imported" beatmap is actually just the restored previous import. since it matches hash. @@ -529,7 +552,7 @@ namespace osu.Game.Tests.Database { RunTestWithRealmAsync(async (realm, storage) => { - using var importer = new BeatmapModelManager(realm, storage); + var importer = new BeatmapImporter(storage, realm); using var store = new RealmRulesetStore(realm, storage); var progressNotification = new ImportProgressNotification(); @@ -541,7 +564,7 @@ namespace osu.Game.Tests.Database var imported = await importer.Import( progressNotification, - new ImportTask(zipStream, string.Empty) + new[] { new ImportTask(zipStream, string.Empty) } ); realm.Run(r => r.Refresh()); @@ -567,7 +590,7 @@ namespace osu.Game.Tests.Database Interlocked.Increment(ref loggedExceptionCount); }; - using var importer = new BeatmapModelManager(realm, storage); + var importer = new BeatmapImporter(storage, realm); using var store = new RealmRulesetStore(realm, storage); var imported = await LoadOszIntoStore(importer, realm.Realm); @@ -588,6 +611,12 @@ namespace osu.Game.Tests.Database using (var outStream = File.Open(brokenTempFilename, FileMode.CreateNew)) using (var zip = ZipArchive.Open(brokenOsz)) { + foreach (var entry in zip.Entries.ToArray()) + { + if (entry.Key.EndsWith(".osu", StringComparison.InvariantCulture)) + zip.RemoveEntry(entry); + } + zip.AddEntry("broken.osu", brokenOsu, false); zip.SaveTo(outStream, CompressionType.Deflate); } @@ -608,7 +637,7 @@ namespace osu.Game.Tests.Database checkSingleReferencedFileCount(realm.Realm, 18); - Assert.AreEqual(1, loggedExceptionCount); + Assert.AreEqual(0, loggedExceptionCount); File.Delete(brokenTempFilename); }); @@ -619,10 +648,10 @@ namespace osu.Game.Tests.Database { RunTestWithRealmAsync(async (realm, storage) => { - using var importer = new BeatmapModelManager(realm, storage); + var importer = new BeatmapImporter(storage, realm); using var store = new RealmRulesetStore(realm, storage); - var imported = await LoadOszIntoStore(importer, realm.Realm); + var imported = await LoadOszIntoStore(importer, realm.Realm, batchImport: true); deleteBeatmapSet(imported, realm.Realm); @@ -641,12 +670,71 @@ namespace osu.Game.Tests.Database }); } + [Test] + public void TestImportThenReimportWithNewDifficulty() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var store = new RealmRulesetStore(realm, storage); + + string? pathOriginal = TestResources.GetTestBeatmapForImport(); + + string pathMissingOneBeatmap = pathOriginal.Replace(".osz", "_missing_difficulty.osz"); + + string extractedFolder = $"{pathOriginal}_extracted"; + Directory.CreateDirectory(extractedFolder); + + try + { + using (var zip = ZipArchive.Open(pathOriginal)) + zip.WriteToDirectory(extractedFolder); + + // remove one difficulty before first import + new FileInfo(Directory.GetFiles(extractedFolder, "*.osu").First()).Delete(); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(pathMissingOneBeatmap, new ZipWriterOptions(CompressionType.Deflate)); + } + + var firstImport = await importer.Import(new ImportTask(pathMissingOneBeatmap)); + Assert.That(firstImport, Is.Not.Null); + + realm.Run(r => r.Refresh()); + + Assert.That(realm.Realm.All().Where(s => !s.DeletePending), Has.Count.EqualTo(1)); + Assert.That(realm.Realm.All().First(s => !s.DeletePending).Beatmaps, Has.Count.EqualTo(11)); + + // Second import matches first but contains one extra .osu file. + var secondImport = await importer.Import(new ImportTask(pathOriginal)); + Assert.That(secondImport, Is.Not.Null); + + realm.Run(r => r.Refresh()); + + Assert.That(realm.Realm.All(), Has.Count.EqualTo(23)); + Assert.That(realm.Realm.All(), Has.Count.EqualTo(2)); + + Assert.That(realm.Realm.All().Where(s => !s.DeletePending), Has.Count.EqualTo(1)); + Assert.That(realm.Realm.All().First(s => !s.DeletePending).Beatmaps, Has.Count.EqualTo(12)); + + // check the newly "imported" beatmap is not the original. + Assert.That(firstImport?.ID, Is.Not.EqualTo(secondImport?.ID)); + } + finally + { + Directory.Delete(extractedFolder, true); + } + }); + } + [Test] public void TestImportThenReimportAfterMissingFiles() { RunTestWithRealmAsync(async (realmFactory, storage) => { - using var importer = new BeatmapModelManager(realmFactory, storage); + var importer = new BeatmapImporter(storage, realmFactory); using var store = new RealmRulesetStore(realmFactory, storage); var imported = await LoadOszIntoStore(importer, realmFactory.Realm); @@ -678,7 +766,7 @@ namespace osu.Game.Tests.Database { RunTestWithRealmAsync(async (realm, storage) => { - using var importer = new NonOptimisedBeatmapImporter(realm, storage); + var importer = new BeatmapImporter(storage, realm); using var store = new RealmRulesetStore(realm, storage); var imported = await LoadOszIntoStore(importer, realm.Realm); @@ -705,7 +793,7 @@ namespace osu.Game.Tests.Database { RunTestWithRealmAsync(async (realm, storage) => { - using var importer = new BeatmapModelManager(realm, storage); + var importer = new BeatmapImporter(storage, realm); using var store = new RealmRulesetStore(realm, storage); var imported = await LoadOszIntoStore(importer, realm.Realm); @@ -713,7 +801,7 @@ namespace osu.Game.Tests.Database await realm.Realm.WriteAsync(() => { foreach (var b in imported.Beatmaps) - b.OnlineID = -1; + b.ResetOnlineInfo(); }); deleteBeatmapSet(imported, realm.Realm); @@ -731,7 +819,7 @@ namespace osu.Game.Tests.Database { RunTestWithRealm((realm, storage) => { - using var importer = new BeatmapModelManager(realm, storage); + var importer = new BeatmapImporter(storage, realm); using var store = new RealmRulesetStore(realm, storage); var metadata = new BeatmapMetadata @@ -762,7 +850,7 @@ namespace osu.Game.Tests.Database } }; - var imported = importer.Import(toImport); + var imported = importer.ImportModel(toImport); realm.Run(r => r.Refresh()); @@ -779,7 +867,7 @@ namespace osu.Game.Tests.Database { RunTestWithRealmAsync(async (realm, storage) => { - using var importer = new BeatmapModelManager(realm, storage); + var importer = new BeatmapImporter(storage, realm); using var store = new RealmRulesetStore(realm, storage); string? temp = TestResources.GetTestBeatmapForImport(); @@ -796,7 +884,7 @@ namespace osu.Game.Tests.Database { RunTestWithRealmAsync(async (realm, storage) => { - using var importer = new BeatmapModelManager(realm, storage); + var importer = new BeatmapImporter(storage, realm); using var store = new RealmRulesetStore(realm, storage); string? temp = TestResources.GetTestBeatmapForImport(); @@ -832,7 +920,7 @@ namespace osu.Game.Tests.Database { RunTestWithRealmAsync(async (realm, storage) => { - using var importer = new BeatmapModelManager(realm, storage); + var importer = new BeatmapImporter(storage, realm); using var store = new RealmRulesetStore(realm, storage); string? temp = TestResources.GetTestBeatmapForImport(); @@ -874,7 +962,7 @@ namespace osu.Game.Tests.Database { RunTestWithRealmAsync(async (realm, storage) => { - using var importer = new BeatmapModelManager(realm, storage); + var importer = new BeatmapImporter(storage, realm); using var store = new RealmRulesetStore(realm, storage); string? temp = TestResources.GetTestBeatmapForImport(); @@ -925,7 +1013,7 @@ namespace osu.Game.Tests.Database { RunTestWithRealmAsync(async (realm, storage) => { - using var importer = new BeatmapModelManager(realm, storage); + var importer = new BeatmapImporter(storage, realm); using var store = new RealmRulesetStore(realm, storage); string? temp = TestResources.GetTestBeatmapForImport(); @@ -960,11 +1048,11 @@ namespace osu.Game.Tests.Database return realm.All().FirstOrDefault(beatmapSet => beatmapSet.ID == importedSet!.ID); } - public static async Task LoadOszIntoStore(BeatmapImporter importer, Realm realm, string? path = null, bool virtualTrack = false) + public static async Task LoadOszIntoStore(BeatmapImporter importer, Realm realm, string? path = null, bool virtualTrack = false, bool batchImport = false) { string? temp = path ?? TestResources.GetTestBeatmapForImport(virtualTrack); - var importedSet = await importer.Import(new ImportTask(temp)); + var importedSet = await importer.Import(new ImportTask(temp), new ImportParameters { Batch = batchImport }); Assert.NotNull(importedSet); Debug.Assert(importedSet != null); @@ -1081,15 +1169,5 @@ namespace osu.Game.Tests.Database Assert.Fail(failureMessage); } - - public class NonOptimisedBeatmapImporter : BeatmapImporter - { - public NonOptimisedBeatmapImporter(RealmAccess realm, Storage storage) - : base(realm, storage) - { - } - - protected override bool HasCustomHashFunction => true; - } } } diff --git a/osu.Game.Tests/Database/BeatmapImporterUpdateTests.cs b/osu.Game.Tests/Database/BeatmapImporterUpdateTests.cs new file mode 100644 index 0000000000..b94cff2a9a --- /dev/null +++ b/osu.Game.Tests/Database/BeatmapImporterUpdateTests.cs @@ -0,0 +1,600 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Linq.Expressions; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Database; +using osu.Game.Models; +using osu.Game.Overlays.Notifications; +using osu.Game.Rulesets; +using osu.Game.Scoring; +using osu.Game.Tests.Resources; +using Realms; +using SharpCompress.Archives; +using SharpCompress.Archives.Zip; +using SharpCompress.Common; +using SharpCompress.Writers.Zip; + +namespace osu.Game.Tests.Database +{ + /// + /// Tests the flow where a beatmap is already loaded and an update is applied. + /// + [TestFixture] + public class BeatmapImporterUpdateTests : RealmTest + { + private const int count_beatmaps = 12; + + [Test] + public void TestNewDifficultyAdded() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + + using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchiveWithModifications(out string pathMissingOneBeatmap, directory => + { + // remove one difficulty before first import + directory.GetFiles("*.osu").First().Delete(); + }); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathMissingOneBeatmap)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + realm.Run(r => r.Refresh()); + + checkCount(realm, 1, s => !s.DeletePending); + Assert.That(importBeforeUpdate.Value.Beatmaps, Has.Count.EqualTo(count_beatmaps - 1)); + + // Second import matches first but contains one extra .osu file. + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathOriginal), importBeforeUpdate.Value); + + Assert.That(importAfterUpdate, Is.Not.Null); + Debug.Assert(importAfterUpdate != null); + + realm.Run(r => r.Refresh()); + + checkCount(realm, count_beatmaps); + checkCount(realm, count_beatmaps); + checkCount(realm, 1); + + // check the newly "imported" beatmap is not the original. + Assert.That(importBeforeUpdate.ID, Is.Not.EqualTo(importAfterUpdate.ID)); + + // Previous beatmap set has no beatmaps so will be completely purged on the spot. + Assert.That(importBeforeUpdate.Value.IsValid, Is.False); + }); + } + + /// + /// Regression test covering https://github.com/ppy/osu/issues/19369 (import potentially duplicating if original has no ). + /// + [Test] + public void TestNewDifficultyAddedNoOnlineID() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + + using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchiveWithModifications(out string pathMissingOneBeatmap, directory => + { + // remove one difficulty before first import + directory.GetFiles("*.osu").First().Delete(); + }); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathMissingOneBeatmap)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + // This test is the same as TestNewDifficultyAdded except for this block. + importBeforeUpdate.PerformWrite(s => + { + s.OnlineID = -1; + foreach (var beatmap in s.Beatmaps) + beatmap.ResetOnlineInfo(); + }); + + realm.Run(r => r.Refresh()); + + checkCount(realm, 1, s => !s.DeletePending); + Assert.That(importBeforeUpdate.Value.Beatmaps, Has.Count.EqualTo(count_beatmaps - 1)); + + // Second import matches first but contains one extra .osu file. + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathOriginal), importBeforeUpdate.Value); + + Assert.That(importAfterUpdate, Is.Not.Null); + Debug.Assert(importAfterUpdate != null); + + realm.Run(r => r.Refresh()); + + checkCount(realm, count_beatmaps); + checkCount(realm, count_beatmaps); + checkCount(realm, 1); + + // check the newly "imported" beatmap is not the original. + Assert.That(importBeforeUpdate.ID, Is.Not.EqualTo(importAfterUpdate.ID)); + + // Previous beatmap set has no beatmaps so will be completely purged on the spot. + Assert.That(importBeforeUpdate.Value.IsValid, Is.False); + }); + } + + [Test] + public void TestExistingDifficultyModified() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + + using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchiveWithModifications(out string pathModified, directory => + { + // Modify one .osu file with different content. + var firstOsuFile = directory.GetFiles("*.osu").First(); + + string existingContent = File.ReadAllText(firstOsuFile.FullName); + + File.WriteAllText(firstOsuFile.FullName, existingContent + "\n# I am new content"); + }); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathOriginal)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + realm.Run(r => r.Refresh()); + + checkCount(realm, 1, s => !s.DeletePending); + Assert.That(importBeforeUpdate.Value.Beatmaps, Has.Count.EqualTo(count_beatmaps)); + + // Second import matches first but contains one extra .osu file. + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathModified), importBeforeUpdate.Value); + + Assert.That(importAfterUpdate, Is.Not.Null); + Debug.Assert(importAfterUpdate != null); + + // should only contain the modified beatmap (others purged). + Assert.That(importBeforeUpdate.Value.Beatmaps, Has.Count.EqualTo(1)); + Assert.That(importAfterUpdate.Value.Beatmaps, Has.Count.EqualTo(count_beatmaps)); + + realm.Run(r => r.Refresh()); + + checkCount(realm, count_beatmaps + 1); + checkCount(realm, count_beatmaps + 1); + + checkCount(realm, 1, s => !s.DeletePending); + checkCount(realm, 1, s => s.DeletePending); + }); + } + + [Test] + public void TestExistingDifficultyRemoved() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + + using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchiveWithModifications(out string pathMissingOneBeatmap, directory => + { + // remove one difficulty before first import + directory.GetFiles("*.osu").First().Delete(); + }); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathOriginal)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + Assert.That(importBeforeUpdate.Value.Beatmaps, Has.Count.EqualTo(count_beatmaps)); + Assert.That(importBeforeUpdate.Value.Beatmaps.First().OnlineID, Is.GreaterThan(-1)); + + // Second import matches first but contains one extra .osu file. + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathMissingOneBeatmap), importBeforeUpdate.Value); + + Assert.That(importAfterUpdate, Is.Not.Null); + Debug.Assert(importAfterUpdate != null); + + realm.Run(r => r.Refresh()); + + checkCount(realm, count_beatmaps); + checkCount(realm, count_beatmaps); + checkCount(realm, 2); + + // previous set should contain the removed beatmap still. + Assert.That(importBeforeUpdate.Value.Beatmaps, Has.Count.EqualTo(1)); + Assert.That(importBeforeUpdate.Value.Beatmaps.First().OnlineID, Is.EqualTo(-1)); + + // Previous beatmap set has no beatmaps so will be completely purged on the spot. + Assert.That(importAfterUpdate.Value.Beatmaps, Has.Count.EqualTo(count_beatmaps - 1)); + }); + } + + [Test] + public void TestUpdatedImportContainsNothing() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + + using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchiveWithModifications(out string pathEmpty, directory => + { + foreach (var file in directory.GetFiles()) + file.Delete(); + }); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathOriginal)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathEmpty), importBeforeUpdate.Value); + Assert.That(importAfterUpdate, Is.Null); + + realm.Run(r => r.Refresh()); + + checkCount(realm, 1); + checkCount(realm, count_beatmaps); + checkCount(realm, count_beatmaps); + + Assert.That(importBeforeUpdate.Value.IsValid, Is.True); + }); + } + + [Test] + public void TestNoChanges() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + + using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchive(out string pathOriginalSecond); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathOriginal)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathOriginalSecond), importBeforeUpdate.Value); + + Assert.That(importAfterUpdate, Is.Not.Null); + Debug.Assert(importAfterUpdate != null); + + realm.Run(r => r.Refresh()); + + checkCount(realm, 1); + checkCount(realm, count_beatmaps); + checkCount(realm, count_beatmaps); + + Assert.That(importBeforeUpdate.Value.Beatmaps.First().OnlineID, Is.GreaterThan(-1)); + Assert.That(importBeforeUpdate.ID, Is.EqualTo(importAfterUpdate.ID)); + }); + } + + [Test] + public void TestScoreTransferredOnUnchanged() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + string removedFilename = null!; + + using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchiveWithModifications(out string pathMissingOneBeatmap, directory => + { + // arbitrary beatmap removal + var fileToRemove = directory.GetFiles("*.osu").First(); + + removedFilename = fileToRemove.Name; + fileToRemove.Delete(); + }); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathOriginal)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + string scoreTargetBeatmapHash = string.Empty; + + importBeforeUpdate.PerformWrite(s => + { + // make sure not to add scores to the same beatmap that is removed in the update. + var beatmapInfo = s.Beatmaps.First(b => b.File?.Filename != removedFilename); + + scoreTargetBeatmapHash = beatmapInfo.Hash; + s.Realm.Add(new ScoreInfo(beatmapInfo, s.Realm.All().First(), new RealmUser())); + }); + + realm.Run(r => r.Refresh()); + + checkCount(realm, 1); + + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathMissingOneBeatmap), importBeforeUpdate.Value); + + Assert.That(importAfterUpdate, Is.Not.Null); + Debug.Assert(importAfterUpdate != null); + + realm.Run(r => r.Refresh()); + + checkCount(realm, count_beatmaps); + checkCount(realm, count_beatmaps); + checkCount(realm, 2); + + // score is transferred across to the new set + checkCount(realm, 1); + Assert.That(importAfterUpdate.Value.Beatmaps.First(b => b.Hash == scoreTargetBeatmapHash).Scores, Has.Count.EqualTo(1)); + }); + } + + [Test] + public void TestScoreLostOnModification() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + + using var __ = getBeatmapArchive(out string pathOriginal); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathOriginal)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + string? scoreTargetFilename = string.Empty; + + importBeforeUpdate.PerformWrite(s => + { + var beatmapInfo = s.Beatmaps.Last(); + scoreTargetFilename = beatmapInfo.File?.Filename; + s.Realm.Add(new ScoreInfo(beatmapInfo, s.Realm.All().First(), new RealmUser())); + }); + + realm.Run(r => r.Refresh()); + + checkCount(realm, 1); + + using var _ = getBeatmapArchiveWithModifications(out string pathModified, directory => + { + // Modify one .osu file with different content. + var firstOsuFile = directory.GetFiles(scoreTargetFilename).First(); + + string existingContent = File.ReadAllText(firstOsuFile.FullName); + + File.WriteAllText(firstOsuFile.FullName, existingContent + "\n# I am new content"); + }); + + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathModified), importBeforeUpdate.Value); + + Assert.That(importAfterUpdate, Is.Not.Null); + Debug.Assert(importAfterUpdate != null); + + realm.Run(r => r.Refresh()); + + checkCount(realm, count_beatmaps + 1); + checkCount(realm, count_beatmaps + 1); + checkCount(realm, 2); + + // score is not transferred due to modifications. + checkCount(realm, 1); + Assert.That(importBeforeUpdate.Value.Beatmaps.AsEnumerable().First(b => b.File?.Filename == scoreTargetFilename).Scores, Has.Count.EqualTo(1)); + Assert.That(importAfterUpdate.Value.Beatmaps.AsEnumerable().First(b => b.File?.Filename == scoreTargetFilename).Scores, Has.Count.EqualTo(0)); + }); + } + + [Test] + public void TestMetadataTransferred() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + + using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchiveWithModifications(out string pathMissingOneBeatmap, directory => + { + // arbitrary beatmap removal + directory.GetFiles("*.osu").First().Delete(); + }); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathOriginal)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathMissingOneBeatmap), importBeforeUpdate.Value); + + Assert.That(importAfterUpdate, Is.Not.Null); + Debug.Assert(importAfterUpdate != null); + + Assert.That(importBeforeUpdate.ID, Is.Not.EqualTo(importAfterUpdate.ID)); + Assert.That(importBeforeUpdate.Value.DateAdded, Is.EqualTo(importAfterUpdate.Value.DateAdded)); + }); + } + + /// + /// If all difficulties in the original beatmap set are in a collection, presume the user also wants new difficulties added. + /// + [TestCase(false)] + [TestCase(true)] + public void TestCollectionTransferNewBeatmap(bool allOriginalBeatmapsInCollection) + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + + using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchiveWithModifications(out string pathMissingOneBeatmap, directory => + { + // remove one difficulty before first import + directory.GetFiles("*.osu").First().Delete(); + }); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathMissingOneBeatmap)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + int beatmapsToAddToCollection = 0; + + importBeforeUpdate.PerformWrite(s => + { + var beatmapCollection = s.Realm.Add(new BeatmapCollection("test collection")); + beatmapsToAddToCollection = s.Beatmaps.Count - (allOriginalBeatmapsInCollection ? 0 : 1); + + for (int i = 0; i < beatmapsToAddToCollection; i++) + beatmapCollection.BeatmapMD5Hashes.Add(s.Beatmaps[i].MD5Hash); + }); + + // Second import matches first but contains one extra .osu file. + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathOriginal), importBeforeUpdate.Value); + + Assert.That(importAfterUpdate, Is.Not.Null); + Debug.Assert(importAfterUpdate != null); + + importAfterUpdate.PerformRead(updated => + { + updated.Realm.Refresh(); + + string[] hashes = updated.Realm.All().Single().BeatmapMD5Hashes.ToArray(); + + if (allOriginalBeatmapsInCollection) + { + Assert.That(updated.Beatmaps.Count, Is.EqualTo(beatmapsToAddToCollection + 1)); + Assert.That(hashes, Has.Length.EqualTo(updated.Beatmaps.Count)); + } + else + { + // Collection contains one less than the original beatmap, and two less after update (new difficulty included). + Assert.That(updated.Beatmaps.Count, Is.EqualTo(beatmapsToAddToCollection + 2)); + Assert.That(hashes, Has.Length.EqualTo(beatmapsToAddToCollection)); + } + }); + }); + } + + /// + /// If a difficulty in the original beatmap set is modified, the updated version should remain in any collections it was in. + /// + [Test] + public void TestCollectionTransferModifiedBeatmap() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + + using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchiveWithModifications(out string pathModified, directory => + { + // Modify one .osu file with different content. + var firstOsuFile = directory.GetFiles("*[Hard]*.osu").First(); + + string existingContent = File.ReadAllText(firstOsuFile.FullName); + + File.WriteAllText(firstOsuFile.FullName, existingContent + "\n# I am new content"); + }); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathOriginal)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + string originalHash = string.Empty; + + importBeforeUpdate.PerformWrite(s => + { + var beatmapCollection = s.Realm.Add(new BeatmapCollection("test collection")); + originalHash = s.Beatmaps.Single(b => b.DifficultyName == "Hard").MD5Hash; + + beatmapCollection.BeatmapMD5Hashes.Add(originalHash); + }); + + // Second import matches first but contains a modified .osu file. + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathModified), importBeforeUpdate.Value); + + Assert.That(importAfterUpdate, Is.Not.Null); + Debug.Assert(importAfterUpdate != null); + + importAfterUpdate.PerformRead(updated => + { + updated.Realm.Refresh(); + + string[] hashes = updated.Realm.All().Single().BeatmapMD5Hashes.ToArray(); + string updatedHash = updated.Beatmaps.Single(b => b.DifficultyName == "Hard").MD5Hash; + + Assert.That(hashes, Has.Length.EqualTo(1)); + Assert.That(hashes.First(), Is.EqualTo(updatedHash)); + + Assert.That(updatedHash, Is.Not.EqualTo(originalHash)); + }); + }); + } + + private static void checkCount(RealmAccess realm, int expected, Expression>? condition = null) where T : RealmObject + { + var query = realm.Realm.All(); + + if (condition != null) + query = query.Where(condition); + + Assert.That(query, Has.Count.EqualTo(expected)); + } + + private static IDisposable getBeatmapArchiveWithModifications(out string path, Action applyModifications) + { + var cleanup = getBeatmapArchive(out path); + + string extractedFolder = $"{path}_extracted"; + Directory.CreateDirectory(extractedFolder); + + using (var zip = ZipArchive.Open(path)) + zip.WriteToDirectory(extractedFolder); + + applyModifications(new DirectoryInfo(extractedFolder)); + + File.Delete(path); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(path, new ZipWriterOptions(CompressionType.Deflate)); + } + + Directory.Delete(extractedFolder, true); + + return cleanup; + } + + private static IDisposable getBeatmapArchive(out string path, bool quick = true) + { + string beatmapPath = TestResources.GetTestBeatmapForImport(quick); + + path = beatmapPath; + + return new InvokeOnDisposal(() => File.Delete(beatmapPath)); + } + } +} diff --git a/osu.Game.Tests/Database/FileStoreTests.cs b/osu.Game.Tests/Database/FileStoreTests.cs index 98b0ed99b5..ab9b761b8f 100644 --- a/osu.Game.Tests/Database/FileStoreTests.cs +++ b/osu.Game.Tests/Database/FileStoreTests.cs @@ -6,11 +6,9 @@ using System.IO; using System.Linq; using NUnit.Framework; using osu.Framework.Logging; +using osu.Game.Database; using osu.Game.Extensions; using osu.Game.Models; -using osu.Game.Stores; - -#nullable enable namespace osu.Game.Tests.Database { diff --git a/osu.Game.Tests/Database/GeneralUsageTests.cs b/osu.Game.Tests/Database/GeneralUsageTests.cs index 8262ef18d4..fd0b391d0d 100644 --- a/osu.Game.Tests/Database/GeneralUsageTests.cs +++ b/osu.Game.Tests/Database/GeneralUsageTests.cs @@ -2,13 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; +using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.Database; - -#nullable enable +using osu.Game.Tests.Resources; namespace osu.Game.Tests.Database { @@ -29,12 +30,91 @@ namespace osu.Game.Tests.Database { RunTestWithRealm((realm, _) => { - using (realm.BlockAllOperations()) + using (realm.BlockAllOperations("testing")) { } }); } + [Test] + public void TestAsyncWriteAsync() + { + RunTestWithRealmAsync(async (realm, _) => + { + await realm.WriteAsync(r => r.Add(TestResources.CreateTestBeatmapSetInfo())); + + realm.Run(r => r.Refresh()); + + Assert.That(realm.Run(r => r.All().Count()), Is.EqualTo(1)); + }); + } + + [Test] + public void TestAsyncWriteWhileBlocking() + { + RunTestWithRealm((realm, _) => + { + Task writeTask; + + using (realm.BlockAllOperations("testing")) + { + writeTask = realm.WriteAsync(r => r.Add(TestResources.CreateTestBeatmapSetInfo())); + Thread.Sleep(100); + Assert.That(writeTask.IsCompleted, Is.False); + } + + writeTask.WaitSafely(); + + realm.Run(r => r.Refresh()); + Assert.That(realm.Run(r => r.All().Count()), Is.EqualTo(1)); + }); + } + + [Test] + public void TestAsyncWrite() + { + RunTestWithRealm((realm, _) => + { + realm.WriteAsync(r => r.Add(TestResources.CreateTestBeatmapSetInfo())).WaitSafely(); + + realm.Run(r => r.Refresh()); + + Assert.That(realm.Run(r => r.All().Count()), Is.EqualTo(1)); + }); + } + + [Test] + public void TestAsyncWriteAfterDisposal() + { + RunTestWithRealm((realm, _) => + { + realm.Dispose(); + Assert.ThrowsAsync(() => realm.WriteAsync(r => r.Add(TestResources.CreateTestBeatmapSetInfo()))); + }); + } + + [Test] + public void TestAsyncWriteBeforeDisposal() + { + ManualResetEventSlim resetEvent = new ManualResetEventSlim(); + + RunTestWithRealm((realm, _) => + { + var writeTask = realm.WriteAsync(r => + { + // ensure that disposal blocks for our execution + Assert.That(resetEvent.Wait(100), Is.False); + + r.Add(TestResources.CreateTestBeatmapSetInfo()); + }); + + realm.Dispose(); + resetEvent.Set(); + + writeTask.WaitSafely(); + }); + } + /// /// Test to ensure that a `CreateContext` call nested inside a subscription doesn't cause any deadlocks /// due to context fetching semaphores. @@ -48,7 +128,7 @@ namespace osu.Game.Tests.Database realm.RegisterCustomSubscription(r => { - var subscription = r.All().QueryAsyncWithNotifications((sender, changes, error) => + var subscription = r.All().QueryAsyncWithNotifications((_, _, _) => { realm.Run(_ => { @@ -81,15 +161,15 @@ namespace osu.Game.Tests.Database { hasThreadedUsage.Set(); - stopThreadedUsage.Wait(); + stopThreadedUsage.Wait(60000); }); }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler); - hasThreadedUsage.Wait(); + hasThreadedUsage.Wait(60000); Assert.Throws(() => { - using (realm.BlockAllOperations()) + using (realm.BlockAllOperations("testing")) { } }); @@ -97,7 +177,7 @@ namespace osu.Game.Tests.Database stopThreadedUsage.Set(); // Ensure we can block a second time after the usage has ended. - using (realm.BlockAllOperations()) + using (realm.BlockAllOperations("testing")) { } }); diff --git a/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs b/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs new file mode 100644 index 0000000000..e7fdb52d2f --- /dev/null +++ b/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs @@ -0,0 +1,75 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Database; +using osu.Game.IO; + +namespace osu.Game.Tests.Database +{ + [TestFixture] + public class LegacyBeatmapImporterTest + { + private readonly TestLegacyBeatmapImporter importer = new TestLegacyBeatmapImporter(); + + [Test] + public void TestSongsSubdirectories() + { + using (var storage = new TemporaryNativeStorage("stable-songs-folder")) + { + var songsStorage = storage.GetStorageForDirectory(StableStorage.STABLE_DEFAULT_SONGS_PATH); + + // normal beatmap folder + var beatmap1 = songsStorage.GetStorageForDirectory("beatmap1"); + createFile(beatmap1, "beatmap.osu"); + + // songs subdirectory + var subdirectory = songsStorage.GetStorageForDirectory("subdirectory"); + createFile(subdirectory, Path.Combine("beatmap2", "beatmap.osu")); + createFile(subdirectory, Path.Combine("beatmap3", "beatmap.osu")); + createFile(subdirectory, Path.Combine("sub-subdirectory", "beatmap4", "beatmap.osu")); + + // songs subdirectory with system file + var subdirectory2 = songsStorage.GetStorageForDirectory("subdirectory2"); + createFile(subdirectory2, ".DS_Store"); + createFile(subdirectory2, Path.Combine("beatmap5", "beatmap.osu")); + createFile(subdirectory2, Path.Combine("beatmap6", "beatmap.osu")); + + // empty songs subdirectory + songsStorage.GetStorageForDirectory("subdirectory3"); + + string[] paths = importer.GetStableImportPaths(songsStorage).ToArray(); + Assert.That(paths.Length, Is.EqualTo(6)); + Assert.That(paths.Contains(songsStorage.GetFullPath("beatmap1"))); + Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory", "beatmap2")))); + Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory", "beatmap3")))); + Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory", "sub-subdirectory", "beatmap4")))); + Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory2", "beatmap5")))); + Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory2", "beatmap6")))); + } + + static void createFile(Storage storage, string path) + { + using (var stream = storage.CreateFileSafely(path)) + stream.WriteByte(0); + } + } + + private class TestLegacyBeatmapImporter : LegacyBeatmapImporter + { + public TestLegacyBeatmapImporter() + : base(null!) + { + } + + public new IEnumerable GetStableImportPaths(Storage storage) => base.GetStableImportPaths(storage); + } + } +} diff --git a/osu.Game.Tests/Database/LegacyExporterTest.cs b/osu.Game.Tests/Database/LegacyExporterTest.cs new file mode 100644 index 0000000000..d41b3a5017 --- /dev/null +++ b/osu.Game.Tests/Database/LegacyExporterTest.cs @@ -0,0 +1,125 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Database; + +namespace osu.Game.Tests.Database +{ + [TestFixture] + public class LegacyExporterTest + { + private TestLegacyExporter legacyExporter = null!; + private TemporaryNativeStorage storage = null!; + + private const string short_filename = "normal file name"; + + private const string long_filename = + "some file with super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name super long name"; + + [SetUp] + public void SetUp() + { + storage = new TemporaryNativeStorage("export-storage"); + legacyExporter = new TestLegacyExporter(storage); + } + + [Test] + public void ExportFileWithNormalNameTest() + { + var item = new TestPathInfo(short_filename); + + Assert.That(item.Filename.Length, Is.LessThan(TestLegacyExporter.MAX_FILENAME_LENGTH)); + + exportItemAndAssert(item, short_filename); + } + + [Test] + public void ExportFileWithNormalNameMultipleTimesTest() + { + var item = new TestPathInfo(short_filename); + + Assert.That(item.Filename.Length, Is.LessThan(TestLegacyExporter.MAX_FILENAME_LENGTH)); + + //Export multiple times + for (int i = 0; i < 100; i++) + { + string expectedFileName = i == 0 ? short_filename : $"{short_filename} ({i})"; + exportItemAndAssert(item, expectedFileName); + } + } + + [Test] + public void ExportFileWithSuperLongNameTest() + { + int expectedLength = TestLegacyExporter.MAX_FILENAME_LENGTH - (legacyExporter.GetExtension().Length); + string expectedName = long_filename.Remove(expectedLength); + + var item = new TestPathInfo(long_filename); + + Assert.That(item.Filename.Length, Is.GreaterThan(TestLegacyExporter.MAX_FILENAME_LENGTH)); + exportItemAndAssert(item, expectedName); + } + + [Test] + public void ExportFileWithSuperLongNameMultipleTimesTest() + { + int expectedLength = TestLegacyExporter.MAX_FILENAME_LENGTH - (legacyExporter.GetExtension().Length); + string expectedName = long_filename.Remove(expectedLength); + + var item = new TestPathInfo(long_filename); + + Assert.That(item.Filename.Length, Is.GreaterThan(TestLegacyExporter.MAX_FILENAME_LENGTH)); + + //Export multiple times + for (int i = 0; i < 100; i++) + { + string expectedFilename = i == 0 ? expectedName : $"{expectedName} ({i})"; + exportItemAndAssert(item, expectedFilename); + } + } + + private void exportItemAndAssert(IHasNamedFiles item, string expectedName) + { + Assert.DoesNotThrow(() => legacyExporter.Export(item)); + Assert.That(storage.Exists($"exports/{expectedName}{legacyExporter.GetExtension()}"), Is.True); + } + + [TearDown] + public void TearDown() + { + if (storage.IsNotNull()) + storage.Dispose(); + } + + private class TestPathInfo : IHasNamedFiles + { + 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) + : base(storage) + { + } + + public string GetExtension() => FileExtension; + + protected override string FileExtension => ".test"; + } + } +} diff --git a/osu.Game.Tests/Database/RealmLiveTests.cs b/osu.Game.Tests/Database/RealmLiveTests.cs index 4bc1f5078a..d853e75db0 100644 --- a/osu.Game.Tests/Database/RealmLiveTests.cs +++ b/osu.Game.Tests/Database/RealmLiveTests.cs @@ -12,8 +12,6 @@ using osu.Game.Beatmaps; using osu.Game.Database; using Realms; -#nullable enable - namespace osu.Game.Tests.Database { public class RealmLiveTests : RealmTest @@ -34,30 +32,86 @@ namespace osu.Game.Tests.Database [Test] public void TestAccessAfterStorageMigrate() { - RunTestWithRealm((realm, storage) => + using (var migratedStorage = new TemporaryNativeStorage("realm-test-migration-target")) + { + RunTestWithRealm((realm, storage) => + { + var beatmap = new BeatmapInfo(CreateRuleset(), new BeatmapDifficulty(), new BeatmapMetadata()); + + Live? liveBeatmap = null; + + realm.Run(r => + { + r.Write(_ => r.Add(beatmap)); + + liveBeatmap = beatmap.ToLive(realm); + }); + + migratedStorage.DeleteDirectory(string.Empty); + + using (realm.BlockAllOperations("testing")) + storage.Migrate(migratedStorage); + + Assert.IsFalse(liveBeatmap?.PerformRead(l => l.Hidden)); + }); + } + } + + [Test] + public void TestFailedWritePerformsRollback() + { + RunTestWithRealm((realm, _) => + { + Assert.Throws(() => + { + realm.Write(r => + { + r.Add(new BeatmapInfo(CreateRuleset(), new BeatmapDifficulty(), new BeatmapMetadata())); + throw new InvalidOperationException(); + }); + }); + + Assert.That(realm.Run(r => r.All()), Is.Empty); + }); + } + + [Test] + public void TestFailedNestedWritePerformsRollback() + { + RunTestWithRealm((realm, _) => + { + Assert.Throws(() => + { + realm.Write(r => + { + realm.Write(_ => + { + r.Add(new BeatmapInfo(CreateRuleset(), new BeatmapDifficulty(), new BeatmapMetadata())); + throw new InvalidOperationException(); + }); + }); + }); + + Assert.That(realm.Run(r => r.All()), Is.Empty); + }); + } + + [Test] + public void TestNestedWriteCalls() + { + RunTestWithRealm((realm, _) => { var beatmap = new BeatmapInfo(CreateRuleset(), new BeatmapDifficulty(), new BeatmapMetadata()); - Live? liveBeatmap = null; + var liveBeatmap = beatmap.ToLive(realm); realm.Run(r => - { - r.Write(_ => r.Add(beatmap)); + r.Write(_ => + r.Write(_ => + r.Add(beatmap))) + ); - liveBeatmap = beatmap.ToLive(realm); - }); - - using (var migratedStorage = new TemporaryNativeStorage("realm-test-migration-target")) - { - migratedStorage.DeleteDirectory(string.Empty); - - using (realm.BlockAllOperations()) - { - storage.Migrate(migratedStorage); - } - - Assert.IsFalse(liveBeatmap?.PerformRead(l => l.Hidden)); - } + Assert.IsFalse(liveBeatmap.PerformRead(l => l.Hidden)); }); } @@ -93,6 +147,25 @@ namespace osu.Game.Tests.Database Assert.IsFalse(liveBeatmap.PerformRead(l => l.Hidden)); } + [Test] + public void TestTransactionRolledBackOnException() + { + RunTestWithRealm((realm, _) => + { + var beatmap = new BeatmapInfo(CreateRuleset(), new BeatmapDifficulty(), new BeatmapMetadata()); + + realm.Run(r => r.Write(_ => r.Add(beatmap))); + + var liveBeatmap = beatmap.ToLive(realm); + + Assert.Throws(() => liveBeatmap.PerformWrite(l => throw new InvalidOperationException())); + Assert.IsFalse(liveBeatmap.PerformRead(l => l.Hidden)); + + liveBeatmap.PerformWrite(l => l.Hidden = true); + Assert.IsTrue(liveBeatmap.PerformRead(l => l.Hidden)); + }); + } + [Test] public void TestScopedReadWithoutContext() { @@ -191,7 +264,7 @@ namespace osu.Game.Tests.Database }); // Can't be used, even from within a valid context. - realm.Run(threadContext => + realm.Run(_ => { Assert.Throws(() => { @@ -266,14 +339,12 @@ namespace osu.Game.Tests.Database liveBeatmap.PerformRead(resolved => { // retrieval causes an implicit refresh. even changes that aren't related to the retrieval are fired at this point. - // ReSharper disable once AccessToDisposedClosure Assert.AreEqual(2, outerRealm.All().Count()); Assert.AreEqual(1, changesTriggered); // can access properties without a crash. Assert.IsFalse(resolved.Hidden); - // ReSharper disable once AccessToDisposedClosure outerRealm.Write(r => { // can use with the main context. diff --git a/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs b/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs index d99bcc092d..4ee302bbd0 100644 --- a/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs +++ b/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs @@ -1,13 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - using System; using System.Collections.Generic; using System.Linq; using System.Threading; -using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions; @@ -86,11 +83,7 @@ namespace osu.Game.Tests.Database realm.Run(r => r.Refresh()); - // Without forcing the write onto its own thread, realm will internally run the operation synchronously, which can cause a deadlock with `WaitSafely`. - Task.Run(async () => - { - await realm.WriteAsync(r => r.Add(TestResources.CreateTestBeatmapSetInfo())); - }).WaitSafely(); + realm.WriteAsync(r => r.Add(TestResources.CreateTestBeatmapSetInfo())).WaitSafely(); realm.Run(r => r.Refresh()); @@ -143,7 +136,7 @@ namespace osu.Game.Tests.Database resolvedItems = null; lastChanges = null; - using (realm.BlockAllOperations()) + using (realm.BlockAllOperations("testing")) Assert.That(resolvedItems, Is.Empty); realm.Write(r => r.Add(TestResources.CreateTestBeatmapSetInfo())); @@ -161,7 +154,7 @@ namespace osu.Game.Tests.Database testEventsArriving(false); // And make sure even after another context loss we don't get firings. - using (realm.BlockAllOperations()) + using (realm.BlockAllOperations("testing")) Assert.That(resolvedItems, Is.Null); realm.Write(r => r.Add(TestResources.CreateTestBeatmapSetInfo())); @@ -219,7 +212,7 @@ namespace osu.Game.Tests.Database Assert.That(beatmapSetInfo, Is.Not.Null); - using (realm.BlockAllOperations()) + using (realm.BlockAllOperations("testing")) { // custom disposal action fired when context lost. Assert.That(beatmapSetInfo, Is.Null); @@ -233,7 +226,7 @@ namespace osu.Game.Tests.Database Assert.That(beatmapSetInfo, Is.Null); - using (realm.BlockAllOperations()) + using (realm.BlockAllOperations("testing")) Assert.That(beatmapSetInfo, Is.Null); realm.Run(r => r.Refresh()); @@ -258,7 +251,7 @@ namespace osu.Game.Tests.Database Assert.That(receivedValue, Is.Not.Null); receivedValue = null; - using (realm.BlockAllOperations()) + using (realm.BlockAllOperations("testing")) { } @@ -269,7 +262,7 @@ namespace osu.Game.Tests.Database subscription.Dispose(); receivedValue = null; - using (realm.BlockAllOperations()) + using (realm.BlockAllOperations("testing")) Assert.That(receivedValue, Is.Null); realm.Run(r => r.Refresh()); diff --git a/osu.Game.Tests/Database/RealmTest.cs b/osu.Game.Tests/Database/RealmTest.cs index 16072888b9..d2779e3038 100644 --- a/osu.Game.Tests/Database/RealmTest.cs +++ b/osu.Game.Tests/Database/RealmTest.cs @@ -4,40 +4,31 @@ using System; using System.Runtime.CompilerServices; using System.Threading.Tasks; +using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Extensions; using osu.Framework.Logging; using osu.Framework.Platform; -using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.IO; using osu.Game.Models; using osu.Game.Rulesets; -#nullable enable - namespace osu.Game.Tests.Database { [TestFixture] - public abstract class RealmTest + public abstract partial class RealmTest { - private static readonly TemporaryNativeStorage storage; - - static RealmTest() - { - storage = new TemporaryNativeStorage("realm-test"); - storage.DeleteDirectory(string.Empty); - } - - protected void RunTestWithRealm(Action testAction, [CallerMemberName] string caller = "") + protected void RunTestWithRealm([InstantHandle] Action testAction, [CallerMemberName] string caller = "") { using (HeadlessGameHost host = new CleanRunHeadlessGameHost(callingMethodName: caller)) { host.Run(new RealmTestGame(() => { - // ReSharper disable once AccessToDisposedClosure - var testStorage = new OsuStorage(host, storage.GetStorageForDirectory(caller)); + var defaultStorage = host.Storage; + + var testStorage = new OsuStorage(host, defaultStorage); using (var realm = new RealmAccess(testStorage, OsuGameBase.CLIENT_DATABASE_FILENAME)) { @@ -60,7 +51,7 @@ namespace osu.Game.Tests.Database { host.Run(new RealmTestGame(async () => { - var testStorage = storage.GetStorageForDirectory(caller); + var testStorage = host.Storage; using (var realm = new RealmAccess(testStorage, OsuGameBase.CLIENT_DATABASE_FILENAME)) { @@ -116,9 +107,9 @@ namespace osu.Game.Tests.Database protected static RulesetInfo CreateRuleset() => new RulesetInfo("osu", "osu!", string.Empty, 0) { Available = true }; - private class RealmTestGame : Framework.Game + private partial class RealmTestGame : Framework.Game { - public RealmTestGame(Func work) + public RealmTestGame([InstantHandle] Func work) { // ReSharper disable once AsyncVoidLambda Scheduler.Add(async () => @@ -128,7 +119,7 @@ namespace osu.Game.Tests.Database }); } - public RealmTestGame(Action work) + public RealmTestGame([InstantHandle] Action work) { Scheduler.Add(() => { diff --git a/osu.Game.Tests/Database/RulesetStoreTests.cs b/osu.Game.Tests/Database/RulesetStoreTests.cs index f48b5cba11..a5662fa121 100644 --- a/osu.Game.Tests/Database/RulesetStoreTests.cs +++ b/osu.Game.Tests/Database/RulesetStoreTests.cs @@ -1,9 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Game.Beatmaps; using osu.Game.Rulesets; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Difficulty; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.UI; namespace osu.Game.Tests.Database { @@ -49,5 +59,105 @@ namespace osu.Game.Tests.Database Assert.IsFalse(rulesets.GetRuleset("mania")?.IsManaged); }); } + + [Test] + public void TestRulesetThrowingOnMethods() + { + RunTestWithRealm((realm, storage) => + { + LoadTestRuleset.Version = Ruleset.CURRENT_RULESET_API_VERSION; + LoadTestRuleset.HasImplementations = false; + + var ruleset = new LoadTestRuleset(); + string rulesetShortName = ruleset.RulesetInfo.ShortName; + + realm.Write(r => r.Add(new RulesetInfo(rulesetShortName, ruleset.RulesetInfo.Name, ruleset.RulesetInfo.InstantiationInfo, ruleset.RulesetInfo.OnlineID) + { + Available = true, + })); + + Assert.That(realm.Run(r => r.Find(rulesetShortName).Available), Is.True); + + // Availability is updated on construction of a RealmRulesetStore + var _ = new RealmRulesetStore(realm, storage); + + Assert.That(realm.Run(r => r.Find(rulesetShortName).Available), Is.False); + }); + } + + [Test] + public void TestOutdatedRulesetNotAvailable() + { + RunTestWithRealm((realm, storage) => + { + LoadTestRuleset.Version = "2021.101.0"; + LoadTestRuleset.HasImplementations = true; + + var ruleset = new LoadTestRuleset(); + string rulesetShortName = ruleset.RulesetInfo.ShortName; + + realm.Write(r => r.Add(new RulesetInfo(rulesetShortName, ruleset.RulesetInfo.Name, ruleset.RulesetInfo.InstantiationInfo, ruleset.RulesetInfo.OnlineID) + { + Available = true, + })); + + Assert.That(realm.Run(r => r.Find(rulesetShortName).Available), Is.True); + + // Availability is updated on construction of a RealmRulesetStore + var _ = new RealmRulesetStore(realm, storage); + + Assert.That(realm.Run(r => r.Find(rulesetShortName).Available), Is.False); + + // Simulate the ruleset getting updated + LoadTestRuleset.Version = Ruleset.CURRENT_RULESET_API_VERSION; + var __ = new RealmRulesetStore(realm, storage); + + Assert.That(realm.Run(r => r.Find(rulesetShortName).Available), Is.True); + }); + } + + private class LoadTestRuleset : Ruleset + { + public override string RulesetAPIVersionSupported => Version; + + public static bool HasImplementations = true; + + public static string Version { get; set; } = CURRENT_RULESET_API_VERSION; + + public override IEnumerable GetModsFor(ModType type) + { + if (!HasImplementations) + throw new NotImplementedException(); + + return Array.Empty(); + } + + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList? mods = null) + { + if (!HasImplementations) + throw new NotImplementedException(); + + return new DrawableOsuRuleset(new OsuRuleset(), beatmap, mods); + } + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) + { + if (!HasImplementations) + throw new NotImplementedException(); + + return new OsuBeatmapConverter(beatmap, new OsuRuleset()); + } + + public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) + { + if (!HasImplementations) + throw new NotImplementedException(); + + return new OsuDifficultyCalculator(new OsuRuleset().RulesetInfo, beatmap); + } + + public override string Description => "outdated ruleset"; + public override string ShortName => "ruleset-outdated"; + } } } diff --git a/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs b/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs index 891801865f..f4467867db 100644 --- a/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs +++ b/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.IO; @@ -18,7 +20,7 @@ using Realms; namespace osu.Game.Tests.Database { [TestFixture] - public class TestRealmKeyBindingStore + public partial class TestRealmKeyBindingStore { private NativeStorage storage; @@ -121,7 +123,7 @@ namespace osu.Game.Tests.Database storage.DeleteDirectory(string.Empty); } - public class TestKeyBindingContainer : KeyBindingContainer + public partial class TestKeyBindingContainer : KeyBindingContainer { public override IEnumerable DefaultKeyBindings => new[] diff --git a/osu.Game.Tests/Editing/EditorChangeHandlerTest.cs b/osu.Game.Tests/Editing/BeatmapEditorChangeHandlerTest.cs similarity index 77% rename from osu.Game.Tests/Editing/EditorChangeHandlerTest.cs rename to osu.Game.Tests/Editing/BeatmapEditorChangeHandlerTest.cs index 2d61948a2a..80237fe6c8 100644 --- a/osu.Game.Tests/Editing/EditorChangeHandlerTest.cs +++ b/osu.Game.Tests/Editing/BeatmapEditorChangeHandlerTest.cs @@ -10,7 +10,7 @@ using osu.Game.Screens.Edit; namespace osu.Game.Tests.Editing { [TestFixture] - public class EditorChangeHandlerTest + public class BeatmapEditorChangeHandlerTest { private int stateChangedFired; @@ -21,18 +21,23 @@ namespace osu.Game.Tests.Editing } [Test] - public void TestSaveRestoreState() + public void TestSaveRestoreStateUsingTransaction() { var (handler, beatmap) = createChangeHandler(); Assert.That(handler.CanUndo.Value, Is.False); Assert.That(handler.CanRedo.Value, Is.False); - addArbitraryChange(beatmap); - handler.SaveState(); + handler.BeginChange(); + // Initial state will be saved on BeginChange Assert.That(stateChangedFired, Is.EqualTo(1)); + addArbitraryChange(beatmap); + handler.EndChange(); + + Assert.That(stateChangedFired, Is.EqualTo(2)); + Assert.That(handler.CanUndo.Value, Is.True); Assert.That(handler.CanRedo.Value, Is.False); @@ -41,7 +46,35 @@ namespace osu.Game.Tests.Editing Assert.That(handler.CanUndo.Value, Is.False); Assert.That(handler.CanRedo.Value, Is.True); + Assert.That(stateChangedFired, Is.EqualTo(3)); + } + + [Test] + public void TestSaveRestoreState() + { + var (handler, beatmap) = createChangeHandler(); + + Assert.That(handler.CanUndo.Value, Is.False); + Assert.That(handler.CanRedo.Value, Is.False); + + // Save initial state + handler.SaveState(); + Assert.That(stateChangedFired, Is.EqualTo(1)); + + addArbitraryChange(beatmap); + handler.SaveState(); + Assert.That(stateChangedFired, Is.EqualTo(2)); + + Assert.That(handler.CanUndo.Value, Is.True); + Assert.That(handler.CanRedo.Value, Is.False); + + handler.RestoreState(-1); + + Assert.That(handler.CanUndo.Value, Is.False); + Assert.That(handler.CanRedo.Value, Is.True); + + Assert.That(stateChangedFired, Is.EqualTo(3)); } [Test] @@ -52,6 +85,10 @@ namespace osu.Game.Tests.Editing Assert.That(handler.CanUndo.Value, Is.False); Assert.That(handler.CanRedo.Value, Is.False); + // Save initial state + handler.SaveState(); + Assert.That(stateChangedFired, Is.EqualTo(1)); + string originalHash = handler.CurrentStateHash; addArbitraryChange(beatmap); @@ -59,7 +96,7 @@ namespace osu.Game.Tests.Editing Assert.That(handler.CanUndo.Value, Is.True); Assert.That(handler.CanRedo.Value, Is.False); - Assert.That(stateChangedFired, Is.EqualTo(1)); + Assert.That(stateChangedFired, Is.EqualTo(2)); string hash = handler.CurrentStateHash; @@ -67,7 +104,7 @@ namespace osu.Game.Tests.Editing handler.RestoreState(-1); Assert.That(originalHash, Is.EqualTo(handler.CurrentStateHash)); - Assert.That(stateChangedFired, Is.EqualTo(2)); + Assert.That(stateChangedFired, Is.EqualTo(3)); addArbitraryChange(beatmap); handler.SaveState(); @@ -82,12 +119,16 @@ namespace osu.Game.Tests.Editing Assert.That(handler.CanUndo.Value, Is.False); Assert.That(handler.CanRedo.Value, Is.False); + // Save initial state + handler.SaveState(); + Assert.That(stateChangedFired, Is.EqualTo(1)); + addArbitraryChange(beatmap); handler.SaveState(); Assert.That(handler.CanUndo.Value, Is.True); Assert.That(handler.CanRedo.Value, Is.False); - Assert.That(stateChangedFired, Is.EqualTo(1)); + Assert.That(stateChangedFired, Is.EqualTo(2)); string hash = handler.CurrentStateHash; @@ -95,7 +136,7 @@ namespace osu.Game.Tests.Editing handler.SaveState(); Assert.That(hash, Is.EqualTo(handler.CurrentStateHash)); - Assert.That(stateChangedFired, Is.EqualTo(1)); + Assert.That(stateChangedFired, Is.EqualTo(2)); handler.RestoreState(-1); @@ -104,7 +145,7 @@ namespace osu.Game.Tests.Editing // we should only be able to restore once even though we saved twice. Assert.That(handler.CanUndo.Value, Is.False); Assert.That(handler.CanRedo.Value, Is.True); - Assert.That(stateChangedFired, Is.EqualTo(2)); + Assert.That(stateChangedFired, Is.EqualTo(3)); } [Test] @@ -112,11 +153,15 @@ namespace osu.Game.Tests.Editing { var (handler, beatmap) = createChangeHandler(); + // Save initial state + handler.SaveState(); + Assert.That(stateChangedFired, Is.EqualTo(1)); + Assert.That(handler.CanUndo.Value, Is.False); for (int i = 0; i < EditorChangeHandler.MAX_SAVED_STATES; i++) { - Assert.That(stateChangedFired, Is.EqualTo(i)); + Assert.That(stateChangedFired, Is.EqualTo(i + 1)); addArbitraryChange(beatmap); handler.SaveState(); @@ -167,7 +212,7 @@ namespace osu.Game.Tests.Editing }, }); - var changeHandler = new EditorChangeHandler(beatmap); + var changeHandler = new BeatmapEditorChangeHandler(beatmap); changeHandler.OnStateChange += () => stateChangedFired++; return (changeHandler, beatmap); diff --git a/osu.Game.Tests/Editing/Checks/CheckAudioInVideoTest.cs b/osu.Game.Tests/Editing/Checks/CheckAudioInVideoTest.cs index 614b9b4ac1..9774a8ebb6 100644 --- a/osu.Game.Tests/Editing/Checks/CheckAudioInVideoTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckAudioInVideoTest.cs @@ -18,8 +18,8 @@ namespace osu.Game.Tests.Editing.Checks [TestFixture] public class CheckAudioInVideoTest { - private CheckAudioInVideo check; - private IBeatmap beatmap; + private CheckAudioInVideo check = null!; + private IBeatmap beatmap = null!; [SetUp] public void Setup() @@ -82,7 +82,7 @@ namespace osu.Game.Tests.Editing.Checks Assert.That(issues.Single().Template is CheckAudioInVideo.IssueTemplateMissingFile); } - private BeatmapVerifierContext getContext(Stream resourceStream) + private BeatmapVerifierContext getContext(Stream? resourceStream) { var storyboard = new Storyboard(); var layer = storyboard.GetLayer("Video"); diff --git a/osu.Game.Tests/Editing/Checks/CheckAudioQualityTest.cs b/osu.Game.Tests/Editing/Checks/CheckAudioQualityTest.cs index e22dfa5f8b..61ee6a3663 100644 --- a/osu.Game.Tests/Editing/Checks/CheckAudioQualityTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckAudioQualityTest.cs @@ -5,18 +5,20 @@ using System.Linq; using Moq; using NUnit.Framework; using osu.Framework.Audio.Track; +using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Checks; using osu.Game.Rulesets.Objects; +using osu.Game.Tests.Visual; namespace osu.Game.Tests.Editing.Checks { [TestFixture] public class CheckAudioQualityTest { - private CheckAudioQuality check; - private IBeatmap beatmap; + private CheckAudioQuality check = null!; + private IBeatmap beatmap = null!; [SetUp] public void Setup() @@ -39,7 +41,7 @@ namespace osu.Game.Tests.Editing.Checks var mock = new Mock(); mock.SetupGet(w => w.Beatmap).Returns(beatmap); - mock.SetupGet(w => w.Track).Returns((Track)null); + mock.SetupGet(w => w.Track).Returns((Track)null!); Assert.That(check.Run(new BeatmapVerifierContext(beatmap, mock.Object)), Is.Empty); } @@ -107,7 +109,7 @@ namespace osu.Game.Tests.Editing.Checks /// The bitrate of the audio file the beatmap uses. private Mock getMockWorkingBeatmap(int? audioBitrate) { - var mockTrack = new Mock(); + var mockTrack = new Mock(new FramedClock(), "virtual"); mockTrack.SetupGet(t => t.Bitrate).Returns(audioBitrate); var mockWorkingBeatmap = new Mock(); diff --git a/osu.Game.Tests/Editing/Checks/CheckBackgroundQualityTest.cs b/osu.Game.Tests/Editing/Checks/CheckBackgroundQualityTest.cs index bb560054a3..295a10ba5b 100644 --- a/osu.Game.Tests/Editing/Checks/CheckBackgroundQualityTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckBackgroundQualityTest.cs @@ -4,9 +4,9 @@ using System; using System.IO; using System.Linq; -using JetBrains.Annotations; using Moq; using NUnit.Framework; +using osu.Framework.Graphics.Rendering.Dummy; using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; @@ -18,8 +18,8 @@ namespace osu.Game.Tests.Editing.Checks [TestFixture] public class CheckBackgroundQualityTest { - private CheckBackgroundQuality check; - private IBeatmap beatmap; + private CheckBackgroundQuality check = null!; + private IBeatmap beatmap = null!; [SetUp] public void Setup() @@ -45,7 +45,7 @@ namespace osu.Game.Tests.Editing.Checks { // While this is a problem, it is out of scope for this check and is caught by a different one. beatmap.Metadata.BackgroundFile = string.Empty; - var context = getContext(null, new MemoryStream(Array.Empty())); + var context = getContext(null!, new MemoryStream(Array.Empty())); Assert.That(check.Run(context), Is.Empty); } @@ -53,7 +53,7 @@ namespace osu.Game.Tests.Editing.Checks [Test] public void TestAcceptable() { - var context = getContext(new Texture(1920, 1080)); + var context = getContext(new DummyRenderer().CreateTexture(1920, 1080)); Assert.That(check.Run(context), Is.Empty); } @@ -61,7 +61,7 @@ namespace osu.Game.Tests.Editing.Checks [Test] public void TestTooHighResolution() { - var context = getContext(new Texture(3840, 2160)); + var context = getContext(new DummyRenderer().CreateTexture(3840, 2160)); var issues = check.Run(context).ToList(); @@ -72,7 +72,7 @@ namespace osu.Game.Tests.Editing.Checks [Test] public void TestLowResolution() { - var context = getContext(new Texture(640, 480)); + var context = getContext(new DummyRenderer().CreateTexture(640, 480)); var issues = check.Run(context).ToList(); @@ -83,7 +83,7 @@ namespace osu.Game.Tests.Editing.Checks [Test] public void TestTooLowResolution() { - var context = getContext(new Texture(100, 100)); + var context = getContext(new DummyRenderer().CreateTexture(100, 100)); var issues = check.Run(context).ToList(); @@ -94,7 +94,7 @@ namespace osu.Game.Tests.Editing.Checks [Test] public void TestTooUncompressed() { - var context = getContext(new Texture(1920, 1080), new MemoryStream(new byte[1024 * 1024 * 3])); + var context = getContext(new DummyRenderer().CreateTexture(1920, 1080), new MemoryStream(new byte[1024 * 1024 * 3])); var issues = check.Run(context).ToList(); @@ -105,7 +105,7 @@ namespace osu.Game.Tests.Editing.Checks [Test] public void TestStreamClosed() { - var background = new Texture(1920, 1080); + var background = new DummyRenderer().CreateTexture(1920, 1080); var stream = new Mock(new byte[1024 * 1024]); var context = getContext(background, stream.Object); @@ -115,7 +115,7 @@ namespace osu.Game.Tests.Editing.Checks stream.Verify(x => x.Close(), Times.Once()); } - private BeatmapVerifierContext getContext(Texture background, [CanBeNull] Stream stream = null) + private BeatmapVerifierContext getContext(Texture background, Stream? stream = null) { return new BeatmapVerifierContext(beatmap, getMockWorkingBeatmap(background, stream).Object); } @@ -125,7 +125,7 @@ namespace osu.Game.Tests.Editing.Checks /// /// The texture of the background. /// The stream representing the background file. - private Mock getMockWorkingBeatmap(Texture background, [CanBeNull] Stream stream = null) + private Mock getMockWorkingBeatmap(Texture background, Stream? stream = null) { stream ??= new MemoryStream(new byte[1024 * 1024]); diff --git a/osu.Game.Tests/Editing/Checks/CheckConcurrentObjectsTest.cs b/osu.Game.Tests/Editing/Checks/CheckConcurrentObjectsTest.cs index 5adb91a22e..b5c6568583 100644 --- a/osu.Game.Tests/Editing/Checks/CheckConcurrentObjectsTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckConcurrentObjectsTest.cs @@ -19,7 +19,7 @@ namespace osu.Game.Tests.Editing.Checks [TestFixture] public class CheckConcurrentObjectsTest { - private CheckConcurrentObjects check; + private CheckConcurrentObjects check = null!; [SetUp] public void Setup() diff --git a/osu.Game.Tests/Editing/Checks/CheckFewHitsoundsTest.cs b/osu.Game.Tests/Editing/Checks/CheckFewHitsoundsTest.cs index cf5b3a42a4..01781b98ad 100644 --- a/osu.Game.Tests/Editing/Checks/CheckFewHitsoundsTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckFewHitsoundsTest.cs @@ -18,10 +18,10 @@ namespace osu.Game.Tests.Editing.Checks [TestFixture] public class CheckFewHitsoundsTest { - private CheckFewHitsounds check; + private CheckFewHitsounds check = null!; - private List notHitsounded; - private List hitsounded; + private List notHitsounded = null!; + private List hitsounded = null!; [SetUp] public void Setup() diff --git a/osu.Game.Tests/Editing/Checks/CheckFilePresenceTest.cs b/osu.Game.Tests/Editing/Checks/CheckFilePresenceTest.cs index 01baaadc7d..89bb2f9396 100644 --- a/osu.Game.Tests/Editing/Checks/CheckFilePresenceTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckFilePresenceTest.cs @@ -14,8 +14,8 @@ namespace osu.Game.Tests.Editing.Checks [TestFixture] public class CheckFilePresenceTest { - private CheckBackgroundPresence check; - private IBeatmap beatmap; + private CheckBackgroundPresence check = null!; + private IBeatmap beatmap = null!; [SetUp] public void Setup() diff --git a/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs b/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs index 91d9a8753c..1e1c214c30 100644 --- a/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs @@ -19,8 +19,8 @@ namespace osu.Game.Tests.Editing.Checks [TestFixture] public class CheckMutedObjectsTest { - private CheckMutedObjects check; - private ControlPointInfo cpi; + private CheckMutedObjects check = null!; + private ControlPointInfo cpi = null!; private const int volume_regular = 50; private const int volume_low = 15; diff --git a/osu.Game.Tests/Editing/Checks/CheckPreviewTimeTest.cs b/osu.Game.Tests/Editing/Checks/CheckPreviewTimeTest.cs new file mode 100644 index 0000000000..37b01da6ee --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckPreviewTimeTest.cs @@ -0,0 +1,88 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Editing.Checks +{ + public class CheckPreviewTimeTest + { + private CheckPreviewTime check = null!; + + private IBeatmap beatmap = null!; + + [SetUp] + public void Setup() + { + check = new CheckPreviewTime(); + } + + [Test] + public void TestPreviewTimeNotSet() + { + setNoPreviewTimeBeatmap(); + var content = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + + var issues = check.Run(content).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckPreviewTime.IssueTemplateHasNoPreviewTime); + } + + [Test] + public void TestPreviewTimeconflict() + { + setPreviewTimeConflictBeatmap(); + + var content = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + + var issues = check.Run(content).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckPreviewTime.IssueTemplatePreviewTimeConflict); + Assert.That(issues.Single().Arguments.FirstOrDefault()?.ToString() == "Test1"); + } + + private void setNoPreviewTimeBeatmap() + { + beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + Metadata = new BeatmapMetadata { PreviewTime = -1 }, + } + }; + } + + private void setPreviewTimeConflictBeatmap() + { + beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + Metadata = new BeatmapMetadata { PreviewTime = 10 }, + BeatmapSet = new BeatmapSetInfo(new List + { + new BeatmapInfo + { + DifficultyName = "Test1", + Metadata = new BeatmapMetadata { PreviewTime = 5 }, + }, + new BeatmapInfo + { + DifficultyName = "Test2", + Metadata = new BeatmapMetadata { PreviewTime = 10 }, + }, + }) + } + }; + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/CheckTooShortAudioFilesTest.cs b/osu.Game.Tests/Editing/Checks/CheckTooShortAudioFilesTest.cs index 53c85defae..4918369460 100644 --- a/osu.Game.Tests/Editing/Checks/CheckTooShortAudioFilesTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckTooShortAudioFilesTest.cs @@ -20,8 +20,8 @@ namespace osu.Game.Tests.Editing.Checks [TestFixture] public class CheckTooShortAudioFilesTest { - private CheckTooShortAudioFiles check; - private IBeatmap beatmap; + private CheckTooShortAudioFiles check = null!; + private IBeatmap beatmap = null!; [SetUp] public void Setup() @@ -107,7 +107,7 @@ namespace osu.Game.Tests.Editing.Checks } } - private BeatmapVerifierContext getContext(Stream resourceStream) + private BeatmapVerifierContext getContext(Stream? resourceStream) { var mockWorkingBeatmap = new Mock(beatmap, null, null); mockWorkingBeatmap.Setup(w => w.GetStream(It.IsAny())).Returns(resourceStream); diff --git a/osu.Game.Tests/Editing/Checks/CheckUnsnappedObjectsTest.cs b/osu.Game.Tests/Editing/Checks/CheckUnsnappedObjectsTest.cs index 882baba8fa..c9335dcda5 100644 --- a/osu.Game.Tests/Editing/Checks/CheckUnsnappedObjectsTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckUnsnappedObjectsTest.cs @@ -19,8 +19,8 @@ namespace osu.Game.Tests.Editing.Checks [TestFixture] public class CheckUnsnappedObjectsTest { - private CheckUnsnappedObjects check; - private ControlPointInfo cpi; + private CheckUnsnappedObjects check = null!; + private ControlPointInfo cpi = null!; [SetUp] public void Setup() diff --git a/osu.Game.Tests/Editing/Checks/CheckZeroByteFilesTest.cs b/osu.Game.Tests/Editing/Checks/CheckZeroByteFilesTest.cs index 79d00e6a60..a39ef22b72 100644 --- a/osu.Game.Tests/Editing/Checks/CheckZeroByteFilesTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckZeroByteFilesTest.cs @@ -15,8 +15,8 @@ namespace osu.Game.Tests.Editing.Checks [TestFixture] public class CheckZeroByteFilesTest { - private CheckZeroByteFiles check; - private IBeatmap beatmap; + private CheckZeroByteFiles check = null!; + private IBeatmap beatmap = null!; [SetUp] public void Setup() @@ -72,7 +72,7 @@ namespace osu.Game.Tests.Editing.Checks private BeatmapVerifierContext getContextMissing() { var mockWorkingBeatmap = new Mock(); - mockWorkingBeatmap.Setup(w => w.GetStream(It.IsAny())).Returns((Stream)null); + mockWorkingBeatmap.Setup(w => w.GetStream(It.IsAny())).Returns((Stream)null!); return new BeatmapVerifierContext(beatmap, mockWorkingBeatmap.Object); } diff --git a/osu.Game.Tests/Editing/Checks/CheckZeroLengthObjectsTest.cs b/osu.Game.Tests/Editing/Checks/CheckZeroLengthObjectsTest.cs index 93b20cd166..648f02839f 100644 --- a/osu.Game.Tests/Editing/Checks/CheckZeroLengthObjectsTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckZeroLengthObjectsTest.cs @@ -19,7 +19,7 @@ namespace osu.Game.Tests.Editing.Checks [TestFixture] public class CheckZeroLengthObjectsTest { - private CheckZeroLengthObjects check; + private CheckZeroLengthObjects check = null!; [SetUp] public void Setup() diff --git a/osu.Game.Tests/Editing/LegacyEditorBeatmapPatcherTest.cs b/osu.Game.Tests/Editing/LegacyEditorBeatmapPatcherTest.cs index 44a908b756..5af0366e6e 100644 --- a/osu.Game.Tests/Editing/LegacyEditorBeatmapPatcherTest.cs +++ b/osu.Game.Tests/Editing/LegacyEditorBeatmapPatcherTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.IO; using System.Text; using NUnit.Framework; diff --git a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs index 82fc7a208b..f556f6e2fe 100644 --- a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs +++ b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs @@ -7,6 +7,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; @@ -19,15 +20,15 @@ using osu.Game.Tests.Visual; namespace osu.Game.Tests.Editing { [HeadlessTest] - public class TestSceneHitObjectComposerDistanceSnapping : EditorClockTestScene + public partial class TestSceneHitObjectComposerDistanceSnapping : EditorClockTestScene { - private TestHitObjectComposer composer; + private TestHitObjectComposer composer = null!; [Cached(typeof(EditorBeatmap))] [Cached(typeof(IBeatSnapProvider))] private readonly EditorBeatmap editorBeatmap; - protected override Container Content { get; } + protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; public TestSceneHitObjectComposerDistanceSnapping() { @@ -38,15 +39,9 @@ namespace osu.Game.Tests.Editing { editorBeatmap = new EditorBeatmap(new OsuBeatmap { - BeatmapInfo = - { - Ruleset = new OsuRuleset().RulesetInfo, - }, + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, }), - Content = new Container - { - RelativeSizeAxes = Axes.Both, - } + Content }, }); } @@ -72,12 +67,25 @@ namespace osu.Game.Tests.Editing { AddStep($"set slider multiplier = {multiplier}", () => composer.EditorBeatmap.Difficulty.SliderMultiplier = multiplier); - assertSnapDistance(100 * multiplier); + assertSnapDistance(100 * multiplier, null, true); } [TestCase(1)] [TestCase(2)] - public void TestSpeedMultiplier(float multiplier) + public void TestSpeedMultiplierDoesNotChangeDistanceSnap(float multiplier) + { + assertSnapDistance(100, new HitObject + { + DifficultyControlPoint = new DifficultyControlPoint + { + SliderVelocity = multiplier + } + }, false); + } + + [TestCase(1)] + [TestCase(2)] + public void TestSpeedMultiplierDoesChangeDistanceSnap(float multiplier) { assertSnapDistance(100 * multiplier, new HitObject { @@ -85,7 +93,7 @@ namespace osu.Game.Tests.Editing { SliderVelocity = multiplier } - }); + }, true); } [TestCase(1)] @@ -94,7 +102,32 @@ namespace osu.Game.Tests.Editing { AddStep($"set divisor = {divisor}", () => BeatDivisor.Value = divisor); - assertSnapDistance(100f / divisor); + assertSnapDistance(100f / divisor, null, true); + } + + /// + /// The basic distance-duration functions should always include slider velocity of the reference object. + /// + [Test] + public void TestConversionsWithSliderVelocity() + { + const float base_distance = 100; + const float slider_velocity = 1.2f; + + var referenceObject = new HitObject + { + DifficultyControlPoint = new DifficultyControlPoint + { + SliderVelocity = slider_velocity + } + }; + + assertSnapDistance(base_distance * slider_velocity, referenceObject, true); + assertSnappedDistance(base_distance * slider_velocity + 10, base_distance * slider_velocity, referenceObject); + assertSnappedDuration(base_distance * slider_velocity + 10, 1000, referenceObject); + + assertDistanceToDuration(base_distance * slider_velocity, 1000, referenceObject); + assertDurationToDistance(1000, base_distance * slider_velocity, referenceObject); } [Test] @@ -203,22 +236,22 @@ namespace osu.Game.Tests.Editing assertSnappedDistance(400, 400); } - private void assertSnapDistance(float expectedDistance, HitObject hitObject = null) - => AddAssert($"distance is {expectedDistance}", () => composer.GetBeatSnapDistanceAt(hitObject ?? new HitObject()) == expectedDistance); + private void assertSnapDistance(float expectedDistance, HitObject? referenceObject, bool includeSliderVelocity) + => AddAssert($"distance is {expectedDistance}", () => composer.GetBeatSnapDistanceAt(referenceObject ?? new HitObject(), includeSliderVelocity), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); - private void assertDurationToDistance(double duration, float expectedDistance) - => AddAssert($"duration = {duration} -> distance = {expectedDistance}", () => composer.DurationToDistance(new HitObject(), duration) == expectedDistance); + private void assertDurationToDistance(double duration, float expectedDistance, HitObject? referenceObject = null) + => AddAssert($"duration = {duration} -> distance = {expectedDistance}", () => composer.DurationToDistance(referenceObject ?? new HitObject(), duration), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); - private void assertDistanceToDuration(float distance, double expectedDuration) - => AddAssert($"distance = {distance} -> duration = {expectedDuration}", () => composer.DistanceToDuration(new HitObject(), distance) == expectedDuration); + private void assertDistanceToDuration(float distance, double expectedDuration, HitObject? referenceObject = null) + => AddAssert($"distance = {distance} -> duration = {expectedDuration}", () => composer.DistanceToDuration(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDuration).Within(Precision.FLOAT_EPSILON)); - private void assertSnappedDuration(float distance, double expectedDuration) - => AddAssert($"distance = {distance} -> duration = {expectedDuration} (snapped)", () => composer.FindSnappedDuration(new HitObject(), distance) == expectedDuration); + private void assertSnappedDuration(float distance, double expectedDuration, HitObject? referenceObject = null) + => AddAssert($"distance = {distance} -> duration = {expectedDuration} (snapped)", () => composer.FindSnappedDuration(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDuration).Within(Precision.FLOAT_EPSILON)); - private void assertSnappedDistance(float distance, float expectedDistance) - => AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", () => composer.FindSnappedDistance(new HitObject(), distance) == expectedDistance); + private void assertSnappedDistance(float distance, float expectedDistance, HitObject? referenceObject = null) + => AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", () => composer.FindSnappedDistance(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); - private class TestHitObjectComposer : OsuHitObjectComposer + private partial class TestHitObjectComposer : OsuHitObjectComposer { public new EditorBeatmap EditorBeatmap => base.EditorBeatmap; diff --git a/osu.Game.Tests/Editing/TestSceneHitObjectContainerEventBuffer.cs b/osu.Game.Tests/Editing/TestSceneHitObjectContainerEventBuffer.cs index 592971dbaf..745c636166 100644 --- a/osu.Game.Tests/Editing/TestSceneHitObjectContainerEventBuffer.cs +++ b/osu.Game.Tests/Editing/TestSceneHitObjectContainerEventBuffer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Framework.Graphics; @@ -14,7 +16,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Tests.Editing { [HeadlessTest] - public class TestSceneHitObjectContainerEventBuffer : OsuTestScene + public partial class TestSceneHitObjectContainerEventBuffer : OsuTestScene { private readonly TestHitObject testObj = new TestHitObject(); @@ -137,7 +139,7 @@ namespace osu.Game.Tests.Editing => AddAssert($"began = {began}, finished = {finished}, transferred = {transferred}", () => (beganUsage == testObj) == began && (finishedUsage == testObj) == finished && (transferredUsage == testObj) == transferred); - private class TestPlayfield : Playfield + private partial class TestPlayfield : Playfield { public TestPlayfield() { @@ -163,11 +165,11 @@ namespace osu.Game.Tests.Editing public override string ToString() => "TestHitObject"; } - private class TestDrawableHitObject : DrawableHitObject + private partial class TestDrawableHitObject : DrawableHitObject { } - private class TestDrawable : Drawable + private partial class TestDrawable : Drawable { public new void Schedule(Action action) => base.Schedule(action); } diff --git a/osu.Game.Tests/Editing/TestSceneSnappingNearZero.cs b/osu.Game.Tests/Editing/TestSceneSnappingNearZero.cs new file mode 100644 index 0000000000..59081215ba --- /dev/null +++ b/osu.Game.Tests/Editing/TestSceneSnappingNearZero.cs @@ -0,0 +1,79 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Beatmaps.ControlPoints; + +namespace osu.Game.Tests.Editing +{ + [TestFixture] + public class TestSceneSnappingNearZero + { + private readonly ControlPointInfo cpi = new ControlPointInfo(); + + [Test] + public void TestOnZero() + { + test(0, 500, 0, 0); + test(0, 500, 100, 0); + test(0, 500, 250, 500); + test(0, 500, 600, 500); + + test(0, 500, -600, 0); + } + + [Test] + public void TestAlmostOnZero() + { + test(50, 500, 0, 50); + test(50, 500, 50, 50); + test(50, 500, 100, 50); + test(50, 500, 299, 50); + test(50, 500, 300, 550); + + test(50, 500, -500, 50); + } + + [Test] + public void TestAlmostOnOne() + { + test(499, 500, -1, 499); + test(499, 500, 0, 499); + test(499, 500, 1, 499); + test(499, 500, 499, 499); + test(499, 500, 600, 499); + test(499, 500, 800, 999); + } + + [Test] + public void TestOnOne() + { + test(500, 500, -500, 0); + test(500, 500, 0, 0); + test(500, 500, 200, 0); + test(500, 500, 400, 500); + test(500, 500, 500, 500); + test(500, 500, 600, 500); + test(500, 500, 900, 1000); + } + + [Test] + public void TestNegative() + { + test(-600, 500, -600, 400); + test(-600, 500, -100, 400); + test(-600, 500, 0, 400); + test(-600, 500, 200, 400); + test(-600, 500, 400, 400); + test(-600, 500, 600, 400); + test(-600, 500, 1000, 900); + } + + private void test(double pointTime, double beatLength, double from, double expected) + { + cpi.Clear(); + cpi.Add(pointTime, new TimingControlPoint { BeatLength = beatLength }); + Assert.That(cpi.GetClosestSnappedTime(from, 1), Is.EqualTo(expected), $"From: {from}"); + } + } +} diff --git a/osu.Game.Tests/Editing/TransactionalCommitComponentTest.cs b/osu.Game.Tests/Editing/TransactionalCommitComponentTest.cs index 4ce9115ec4..7280631e32 100644 --- a/osu.Game.Tests/Editing/TransactionalCommitComponentTest.cs +++ b/osu.Game.Tests/Editing/TransactionalCommitComponentTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Game.Screens.Edit; @@ -8,7 +10,7 @@ using osu.Game.Screens.Edit; namespace osu.Game.Tests.Editing { [TestFixture] - public class TransactionalCommitComponentTest + public partial class TransactionalCommitComponentTest { private TestHandler handler; @@ -87,7 +89,7 @@ namespace osu.Game.Tests.Editing Assert.That(() => handler.EndChange(), Throws.TypeOf()); } - private class TestHandler : TransactionalCommitComponent + private partial class TestHandler : TransactionalCommitComponent { public int StateUpdateCount { get; private set; } diff --git a/osu.Game.Tests/Extensions/StringDehumanizeExtensionsTest.cs b/osu.Game.Tests/Extensions/StringDehumanizeExtensionsTest.cs new file mode 100644 index 0000000000..e7490b461b --- /dev/null +++ b/osu.Game.Tests/Extensions/StringDehumanizeExtensionsTest.cs @@ -0,0 +1,85 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Globalization; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Game.Extensions; + +namespace osu.Game.Tests.Extensions +{ + [TestFixture] + public class StringDehumanizeExtensionsTest + { + [Test] + [TestCase("single", "Single")] + [TestCase("example word", "ExampleWord")] + [TestCase("mixed Casing test", "MixedCasingTest")] + [TestCase("PascalCase", "PascalCase")] + [TestCase("camelCase", "CamelCase")] + [TestCase("snake_case", "SnakeCase")] + [TestCase("kebab-case", "KebabCase")] + [TestCase("i will not break in a different culture", "IWillNotBreakInADifferentCulture", "tr-TR")] + public void TestToPascalCase(string input, string expectedOutput, string? culture = null) + { + using (temporaryCurrentCulture(culture)) + Assert.That(input.ToPascalCase(), Is.EqualTo(expectedOutput)); + } + + [Test] + [TestCase("single", "single")] + [TestCase("example word", "exampleWord")] + [TestCase("mixed Casing test", "mixedCasingTest")] + [TestCase("PascalCase", "pascalCase")] + [TestCase("camelCase", "camelCase")] + [TestCase("snake_case", "snakeCase")] + [TestCase("kebab-case", "kebabCase")] + [TestCase("I will not break in a different culture", "iWillNotBreakInADifferentCulture", "tr-TR")] + public void TestToCamelCase(string input, string expectedOutput, string? culture = null) + { + using (temporaryCurrentCulture(culture)) + Assert.That(input.ToCamelCase(), Is.EqualTo(expectedOutput)); + } + + [Test] + [TestCase("single", "single")] + [TestCase("example word", "example_word")] + [TestCase("mixed Casing test", "mixed_casing_test")] + [TestCase("PascalCase", "pascal_case")] + [TestCase("camelCase", "camel_case")] + [TestCase("snake_case", "snake_case")] + [TestCase("kebab-case", "kebab_case")] + [TestCase("I will not break in a different culture", "i_will_not_break_in_a_different_culture", "tr-TR")] + public void TestToSnakeCase(string input, string expectedOutput, string? culture = null) + { + using (temporaryCurrentCulture(culture)) + Assert.That(input.ToSnakeCase(), Is.EqualTo(expectedOutput)); + } + + [Test] + [TestCase("single", "single")] + [TestCase("example word", "example-word")] + [TestCase("mixed Casing test", "mixed-casing-test")] + [TestCase("PascalCase", "pascal-case")] + [TestCase("camelCase", "camel-case")] + [TestCase("snake_case", "snake-case")] + [TestCase("kebab-case", "kebab-case")] + [TestCase("I will not break in a different culture", "i-will-not-break-in-a-different-culture", "tr-TR")] + public void TestToKebabCase(string input, string expectedOutput, string? culture = null) + { + using (temporaryCurrentCulture(culture)) + Assert.That(input.ToKebabCase(), Is.EqualTo(expectedOutput)); + } + + private IDisposable temporaryCurrentCulture(string? cultureName) + { + var storedCulture = CultureInfo.CurrentCulture; + + if (cultureName != null) + CultureInfo.CurrentCulture = new CultureInfo(cultureName); + + return new InvokeOnDisposal(() => CultureInfo.CurrentCulture = storedCulture); + } + } +} diff --git a/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs b/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs index a354464a8e..fd0bff101f 100644 --- a/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs +++ b/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Threading; using NUnit.Framework; using osu.Framework.Graphics; @@ -19,7 +21,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Tests.Gameplay { [HeadlessTest] - public class TestSceneDrainingHealthProcessor : OsuTestScene + public partial class TestSceneDrainingHealthProcessor : OsuTestScene { private HealthProcessor processor; private ManualClock clock; @@ -190,7 +192,7 @@ namespace osu.Game.Tests.Gameplay AddStep("apply perfect hit result", () => processor.ApplyResult(new JudgementResult(beatmap.HitObjects[0], new Judgement()) { Type = HitResult.Perfect })); AddAssert("not failed", () => !processor.HasFailed); - AddStep($"apply {resultApplied.ToString().ToLower()} hit result", () => processor.ApplyResult(new JudgementResult(beatmap.HitObjects[0], new Judgement()) { Type = resultApplied })); + AddStep($"apply {resultApplied.ToString().ToLowerInvariant()} hit result", () => processor.ApplyResult(new JudgementResult(beatmap.HitObjects[0], new Judgement()) { Type = resultApplied })); AddAssert("failed", () => processor.HasFailed); } diff --git a/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs b/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs index 58f4c4c8db..04fc4cafbd 100644 --- a/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs +++ b/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs @@ -1,11 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; @@ -14,7 +17,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Tests.Gameplay { [HeadlessTest] - public class TestSceneDrawableHitObject : OsuTestScene + public partial class TestSceneDrawableHitObject : OsuTestScene { [Test] public void TestEntryLifetime() @@ -135,7 +138,32 @@ namespace osu.Game.Tests.Gameplay AddAssert("DHO state is correct", () => dho.State.Value == ArmedState.Miss); } - private class TestDrawableHitObject : DrawableHitObject + [Test] + public void TestResultSetBeforeLoadComplete() + { + TestDrawableHitObject dho = null; + HitObjectLifetimeEntry lifetimeEntry = null; + AddStep("Create lifetime entry", () => + { + var hitObject = new HitObject { StartTime = Time.Current }; + lifetimeEntry = new HitObjectLifetimeEntry(hitObject) + { + Result = new JudgementResult(hitObject, hitObject.CreateJudgement()) + { + Type = HitResult.Great + } + }; + }); + AddStep("Create DHO and apply entry", () => + { + dho = new TestDrawableHitObject(); + dho.Apply(lifetimeEntry); + Child = dho; + }); + AddAssert("DHO state is correct", () => dho.State.Value, () => Is.EqualTo(ArmedState.Hit)); + } + + private partial class TestDrawableHitObject : DrawableHitObject { public const double INITIAL_LIFETIME_OFFSET = 100; public const double LIFETIME_ON_APPLY = 222; diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs index 5553c67141..f38c2c9416 100644 --- a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs @@ -1,17 +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; using System.Collections.Generic; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.Testing; using osu.Game.Audio; +using osu.Game.Configuration; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Objects.Types; @@ -22,12 +25,22 @@ using osuTK.Graphics; namespace osu.Game.Tests.Gameplay { [HeadlessTest] - public class TestSceneHitObjectAccentColour : OsuTestScene + public partial class TestSceneHitObjectAccentColour : OsuTestScene { + [Resolved] + private OsuConfigManager config { get; set; } + private Container skinContainer; [SetUp] - public void Setup() => Schedule(() => Child = skinContainer = new SkinProvidingContainer(new TestSkin())); + public void Setup() + { + Schedule(() => + { + config.SetValue(OsuSetting.ComboColourNormalisationAmount, 0f); + Child = skinContainer = new SkinProvidingContainer(new TestSkin()); + }); + } [Test] public void TestChangeComboIndexBeforeLoad() @@ -71,7 +84,7 @@ namespace osu.Game.Tests.Gameplay AddAssert("combo colour is green", () => hitObject.AccentColour.Value == Color4.Green); } - private class TestDrawableHitObject : DrawableHitObject + private partial class TestDrawableHitObject : DrawableHitObject { public TestDrawableHitObject() : base(new TestHitObjectWithCombo()) @@ -125,7 +138,7 @@ namespace osu.Game.Tests.Gameplay Color4.Green }; - public Drawable GetDrawableComponent(ISkinComponent component) => throw new NotImplementedException(); + public Drawable GetDrawableComponent(ISkinComponentLookup lookup) => throw new NotImplementedException(); public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectContainer.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectContainer.cs index f2bfccb6de..8322833187 100644 --- a/osu.Game.Tests/Gameplay/TestSceneHitObjectContainer.cs +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Testing; @@ -12,7 +14,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Tests.Gameplay { [HeadlessTest] - public class TestSceneHitObjectContainer : OsuTestScene + public partial class TestSceneHitObjectContainer : OsuTestScene { private HitObjectContainer container; @@ -64,7 +66,7 @@ namespace osu.Game.Tests.Gameplay AddAssert("second object index is 0", () => container.IndexOf(secondObject) == 1); } - private class TestDrawableHitObject : DrawableHitObject + private partial class TestDrawableHitObject : DrawableHitObject { public TestDrawableHitObject([NotNull] HitObject hitObject) : base(hitObject) diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs index 153d5b8e36..769ca1f9a9 100644 --- a/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.IO.Stores; using osu.Game.Rulesets; @@ -11,7 +13,7 @@ using static osu.Game.Skinning.SkinConfiguration; namespace osu.Game.Tests.Gameplay { - public class TestSceneHitObjectSamples : HitObjectSampleTest + public partial class TestSceneHitObjectSamples : HitObjectSampleTest { protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); protected override IResourceStore RulesetResources => TestResources.GetStore(); diff --git a/osu.Game.Tests/Gameplay/TestSceneMasterGameplayClockContainer.cs b/osu.Game.Tests/Gameplay/TestSceneMasterGameplayClockContainer.cs index 5c04ac88a7..393217f371 100644 --- a/osu.Game.Tests/Gameplay/TestSceneMasterGameplayClockContainer.cs +++ b/osu.Game.Tests/Gameplay/TestSceneMasterGameplayClockContainer.cs @@ -1,13 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Testing; using osu.Framework.Timing; -using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Play; @@ -16,7 +17,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Tests.Gameplay { [HeadlessTest] - public class TestSceneMasterGameplayClockContainer : OsuTestScene + public partial class TestSceneMasterGameplayClockContainer : OsuTestScene { private OsuConfigManager localConfig; @@ -40,13 +41,11 @@ namespace osu.Game.Tests.Gameplay AddStep("create container", () => { var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); - working.LoadTrack(); - Child = gameplayClockContainer = new MasterGameplayClockContainer(working, 0); }); AddStep("start clock", () => gameplayClockContainer.Start()); - AddUntilStep("elapsed greater than zero", () => gameplayClockContainer.GameplayClock.ElapsedFrameTime > 0); + AddUntilStep("elapsed greater than zero", () => gameplayClockContainer.ElapsedFrameTime > 0); } [Test] @@ -57,22 +56,20 @@ namespace osu.Game.Tests.Gameplay AddStep("create container", () => { var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); - working.LoadTrack(); - Child = gameplayClockContainer = new MasterGameplayClockContainer(working, 0); }); AddStep("start clock", () => gameplayClockContainer.Start()); - AddUntilStep("current time greater 2000", () => gameplayClockContainer.GameplayClock.CurrentTime > 2000); + AddUntilStep("current time greater 2000", () => gameplayClockContainer.CurrentTime > 2000); double timeAtReset = 0; AddStep("reset clock", () => { - timeAtReset = gameplayClockContainer.GameplayClock.CurrentTime; + timeAtReset = gameplayClockContainer.CurrentTime; gameplayClockContainer.Reset(); }); - AddAssert("current time < time at reset", () => gameplayClockContainer.GameplayClock.CurrentTime < timeAtReset); + AddAssert("current time < time at reset", () => gameplayClockContainer.CurrentTime < timeAtReset); } [Test] @@ -85,29 +82,30 @@ namespace osu.Game.Tests.Gameplay ClockBackedTestWorkingBeatmap working = null; GameplayClockContainer gameplayClockContainer = null; + // ReSharper disable once NotAccessedVariable + BindableDouble trackAdjustment = null; // keeping a reference for track adjustment + if (setAudioOffsetBeforeConstruction) AddStep($"preset audio offset to {userOffset}", () => localConfig.SetValue(OsuSetting.AudioOffset, userOffset)); AddStep("create container", () => { working = new ClockBackedTestWorkingBeatmap(new OsuRuleset().RulesetInfo, new FramedClock(new ManualClock()), Audio); - working.LoadTrack(); - Child = gameplayClockContainer = new MasterGameplayClockContainer(working, 0); gameplayClockContainer.Reset(startClock: !whileStopped); }); - AddStep($"set clock rate to {clockRate}", () => working.Track.AddAdjustment(AdjustableProperty.Frequency, new BindableDouble(clockRate))); + AddStep($"set clock rate to {clockRate}", () => working.Track.AddAdjustment(AdjustableProperty.Frequency, trackAdjustment = new BindableDouble(clockRate))); if (!setAudioOffsetBeforeConstruction) AddStep($"set audio offset to {userOffset}", () => localConfig.SetValue(OsuSetting.AudioOffset, userOffset)); AddStep("seek to 2500", () => gameplayClockContainer.Seek(2500)); - AddAssert("gameplay clock time = 2500", () => Precision.AlmostEquals(gameplayClockContainer.CurrentTime, 2500, 10f)); + AddAssert("gameplay clock time = 2500", () => gameplayClockContainer.CurrentTime, () => Is.EqualTo(2500).Within(10f)); AddStep("seek to 10000", () => gameplayClockContainer.Seek(10000)); - AddAssert("gameplay clock time = 10000", () => Precision.AlmostEquals(gameplayClockContainer.CurrentTime, 10000, 10f)); + AddAssert("gameplay clock time = 10000", () => gameplayClockContainer.CurrentTime, () => Is.EqualTo(10000).Within(10f)); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game.Tests/Gameplay/TestSceneProxyContainer.cs b/osu.Game.Tests/Gameplay/TestSceneProxyContainer.cs index 1264d575a4..be69b77e8b 100644 --- a/osu.Game.Tests/Gameplay/TestSceneProxyContainer.cs +++ b/osu.Game.Tests/Gameplay/TestSceneProxyContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Graphics; @@ -15,7 +17,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Tests.Gameplay { [HeadlessTest] - public class TestSceneProxyContainer : OsuTestScene + public partial class TestSceneProxyContainer : OsuTestScene { private HitObjectContainer hitObjectContainer; private ProxyContainer proxyContainer; @@ -60,14 +62,14 @@ namespace osu.Game.Tests.Gameplay proxyContainer.AddProxy(drawableHitObject); } - private class ProxyContainer : LifetimeManagementContainer + private partial class ProxyContainer : LifetimeManagementContainer { public IReadOnlyList AliveChildren => AliveInternalChildren; public void AddProxy(Drawable d) => AddInternal(d.CreateProxy()); } - private class TestDrawableHitObject : DrawableHitObject + private partial class TestDrawableHitObject : DrawableHitObject { protected override double InitialLifetimeOffset => 100; diff --git a/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs b/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs index 97be1dcfaa..90c7688443 100644 --- a/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs +++ b/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs @@ -1,10 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Online.Spectator; using osu.Game.Rulesets.Judgements; @@ -13,12 +17,13 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; using osu.Game.Tests.Visual; namespace osu.Game.Tests.Gameplay { [HeadlessTest] - public class TestSceneScoreProcessor : OsuTestScene + public partial class TestSceneScoreProcessor : OsuTestScene { [Test] public void TestNoScoreIncreaseFromMiss() @@ -31,7 +36,7 @@ namespace osu.Game.Tests.Gameplay // Apply a miss judgement scoreProcessor.ApplyResult(new JudgementResult(new HitObject(), new TestJudgement()) { Type = HitResult.Miss }); - Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(0.0)); + Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(0)); } [Test] @@ -89,6 +94,75 @@ namespace osu.Game.Tests.Gameplay Assert.That(scoreProcessor.Combo.Value, Is.EqualTo(0)); } + [Test] + public void TestFailScore() + { + var beatmap = new Beatmap + { + HitObjects = + { + new TestHitObject(), + new TestHitObject(HitResult.LargeTickHit), + new TestHitObject(HitResult.SmallTickHit), + new TestHitObject(HitResult.SmallBonus), + new TestHitObject(), + new TestHitObject(HitResult.LargeTickHit), + new TestHitObject(HitResult.SmallTickHit), + new TestHitObject(HitResult.LargeBonus), + } + }; + + var scoreProcessor = new ScoreProcessor(new OsuRuleset()); + scoreProcessor.ApplyBeatmap(beatmap); + + scoreProcessor.ApplyResult(new JudgementResult(beatmap.HitObjects[0], beatmap.HitObjects[0].CreateJudgement()) { Type = HitResult.Ok }); + scoreProcessor.ApplyResult(new JudgementResult(beatmap.HitObjects[1], beatmap.HitObjects[1].CreateJudgement()) { Type = HitResult.LargeTickHit }); + scoreProcessor.ApplyResult(new JudgementResult(beatmap.HitObjects[2], beatmap.HitObjects[2].CreateJudgement()) { Type = HitResult.SmallTickMiss }); + scoreProcessor.ApplyResult(new JudgementResult(beatmap.HitObjects[3], beatmap.HitObjects[3].CreateJudgement()) { Type = HitResult.SmallBonus }); + + var score = new ScoreInfo { Ruleset = new OsuRuleset().RulesetInfo }; + scoreProcessor.FailScore(score); + + Assert.That(score.Rank, Is.EqualTo(ScoreRank.F)); + Assert.That(score.Passed, Is.False); + Assert.That(score.Statistics.Sum(kvp => kvp.Value), Is.EqualTo(4)); + Assert.That(score.MaximumStatistics.Sum(kvp => kvp.Value), Is.EqualTo(8)); + + Assert.That(score.Statistics[HitResult.Ok], Is.EqualTo(1)); + Assert.That(score.Statistics[HitResult.LargeTickHit], Is.EqualTo(1)); + Assert.That(score.Statistics[HitResult.SmallTickMiss], Is.EqualTo(1)); + Assert.That(score.Statistics[HitResult.SmallBonus], Is.EqualTo(1)); + + Assert.That(score.MaximumStatistics[HitResult.Perfect], Is.EqualTo(2)); + Assert.That(score.MaximumStatistics[HitResult.LargeTickHit], Is.EqualTo(2)); + Assert.That(score.MaximumStatistics[HitResult.SmallTickHit], Is.EqualTo(2)); + Assert.That(score.MaximumStatistics[HitResult.SmallBonus], Is.EqualTo(1)); + Assert.That(score.MaximumStatistics[HitResult.LargeBonus], Is.EqualTo(1)); + } + + [Test] + public void TestAccuracyModes() + { + var beatmap = new Beatmap + { + HitObjects = Enumerable.Range(0, 4).Select(_ => new TestHitObject(HitResult.Great)).ToList() + }; + + var scoreProcessor = new ScoreProcessor(new OsuRuleset()); + scoreProcessor.ApplyBeatmap(beatmap); + + Assert.That(scoreProcessor.Accuracy.Value, Is.EqualTo(1)); + Assert.That(scoreProcessor.MinimumAccuracy.Value, Is.EqualTo(0)); + Assert.That(scoreProcessor.MaximumAccuracy.Value, Is.EqualTo(1)); + + scoreProcessor.ApplyResult(new JudgementResult(beatmap.HitObjects[0], beatmap.HitObjects[0].CreateJudgement()) { Type = HitResult.Ok }); + scoreProcessor.ApplyResult(new JudgementResult(beatmap.HitObjects[1], beatmap.HitObjects[1].CreateJudgement()) { Type = HitResult.Great }); + + Assert.That(scoreProcessor.Accuracy.Value, Is.EqualTo((double)(100 + 300) / (2 * 300)).Within(Precision.DOUBLE_EPSILON)); + Assert.That(scoreProcessor.MinimumAccuracy.Value, Is.EqualTo((double)(100 + 300) / (4 * 300)).Within(Precision.DOUBLE_EPSILON)); + Assert.That(scoreProcessor.MaximumAccuracy.Value, Is.EqualTo((double)(100 + 3 * 300) / (4 * 300)).Within(Precision.DOUBLE_EPSILON)); + } + private class TestJudgement : Judgement { public override HitResult MaxResult { get; } @@ -98,5 +172,17 @@ namespace osu.Game.Tests.Gameplay MaxResult = maxResult; } } + + private class TestHitObject : HitObject + { + private readonly HitResult maxResult; + + public TestHitObject(HitResult maxResult = HitResult.Perfect) + { + this.maxResult = maxResult; + } + + public override Judgement CreateJudgement() => new TestJudgement(maxResult); + } } } diff --git a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs index e0a497cf24..2cad7d33c2 100644 --- a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs +++ b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.IO; using System.Threading; @@ -9,8 +11,10 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; +using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Audio; using osu.Game.Configuration; @@ -29,11 +33,14 @@ using osu.Game.Tests.Visual; namespace osu.Game.Tests.Gameplay { [HeadlessTest] - public class TestSceneStoryboardSamples : OsuTestScene, IStorageResourceProvider + public partial class TestSceneStoryboardSamples : OsuTestScene, IStorageResourceProvider { [Resolved] private OsuConfigManager config { get; set; } + [Resolved] + private GameHost host { get; set; } + [Test] public void TestRetrieveTopLevelSample() { @@ -67,11 +74,9 @@ namespace osu.Game.Tests.Gameplay AddStep("create container", () => { var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); - working.LoadTrack(); Add(gameplayContainer = new MasterGameplayClockContainer(working, 0) { - IsPaused = { Value = true }, Child = new FrameStabilityContainer { Child = sample = new DrawableStoryboardSample(new StoryboardSampleInfo(string.Empty, 0, 1)) @@ -79,12 +84,15 @@ namespace osu.Game.Tests.Gameplay }); }); - AddStep("reset clock", () => gameplayContainer.Start()); + AddStep("reset clock", () => gameplayContainer.Reset(startClock: true)); AddUntilStep("sample played", () => sample.RequestedPlaying); AddUntilStep("sample has lifetime end", () => sample.LifetimeEnd < double.MaxValue); } + /// + /// Sample at 0ms, start time at 1000ms (so the sample should not be played). + /// [Test] public void TestSampleHasLifetimeEndWithInitialClockTime() { @@ -94,19 +102,18 @@ namespace osu.Game.Tests.Gameplay AddStep("create container", () => { var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); - working.LoadTrack(); const double start_time = 1000; Add(gameplayContainer = new MasterGameplayClockContainer(working, start_time) { - StartTime = start_time, - IsPaused = { Value = true }, Child = new FrameStabilityContainer { Child = sample = new DrawableStoryboardSample(new StoryboardSampleInfo(string.Empty, 0, 1)) } }); + + gameplayContainer.Reset(start_time); }); AddStep("start time", () => gameplayContainer.Start()); @@ -136,11 +143,11 @@ namespace osu.Game.Tests.Gameplay beatmapSkinSourceContainer.Add(sample = new TestDrawableStoryboardSample(new StoryboardSampleInfo("test-sample", 1, 1)) { - Clock = gameplayContainer.GameplayClock + Clock = gameplayContainer }); }); - AddStep("start", () => gameplayContainer.Start()); + AddStep("reset clock", () => gameplayContainer.Reset(startClock: true)); AddUntilStep("sample played", () => sample.IsPlayed); AddUntilStep("sample has lifetime end", () => sample.LifetimeEnd < double.MaxValue); @@ -192,7 +199,7 @@ namespace osu.Game.Tests.Gameplay protected internal override ISkin GetSkin() => new TestSkin("test-sample", resources); } - private class TestDrawableStoryboardSample : DrawableStoryboardSample + private partial class TestDrawableStoryboardSample : DrawableStoryboardSample { public TestDrawableStoryboardSample(StoryboardSampleInfo sampleInfo) : base(sampleInfo) @@ -202,6 +209,7 @@ namespace osu.Game.Tests.Gameplay #region IResourceStorageProvider + public IRenderer Renderer => host.Renderer; public AudioManager AudioManager => Audio; public IResourceStore Files => null; public new IResourceStore Resources => base.Resources; diff --git a/osu.Game.Tests/ImportTest.cs b/osu.Game.Tests/ImportTest.cs index a658a0eaeb..9e2c9cd7e0 100644 --- a/osu.Game.Tests/ImportTest.cs +++ b/osu.Game.Tests/ImportTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Threading; using System.Threading.Tasks; @@ -8,12 +10,12 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Platform; -using osu.Game.Collections; +using osu.Game.Database; using osu.Game.Tests.Resources; namespace osu.Game.Tests { - public abstract class ImportTest + public abstract partial class ImportTest { protected virtual TestOsuGameBase LoadOsuIntoHost(GameHost host, bool withBeatmap = false) { @@ -43,9 +45,9 @@ namespace osu.Game.Tests Assert.IsTrue(task.Wait(timeout), failureMessage); } - public class TestOsuGameBase : OsuGameBase + public partial class TestOsuGameBase : OsuGameBase { - public CollectionManager CollectionManager { get; private set; } + public RealmAccess Realm => Dependencies.Get(); private readonly bool withBeatmap; @@ -60,8 +62,6 @@ namespace osu.Game.Tests // Beatmap must be imported before the collection manager is loaded. if (withBeatmap) BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely(); - - AddInternal(CollectionManager = new CollectionManager(Storage)); } } } diff --git a/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs b/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs index 28937b2120..3c296b2ff5 100644 --- a/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs +++ b/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Configuration; @@ -13,7 +15,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Tests.Input { [HeadlessTest] - public class ConfineMouseTrackerTest : OsuGameTestScene + public partial class ConfineMouseTrackerTest : OsuGameTestScene { [Resolved] private FrameworkConfigManager frameworkConfigManager { get; set; } diff --git a/osu.Game.Tests/Localisation/BeatmapMetadataRomanisationTest.cs b/osu.Game.Tests/Localisation/BeatmapMetadataRomanisationTest.cs index 9926acf772..d01eaca714 100644 --- a/osu.Game.Tests/Localisation/BeatmapMetadataRomanisationTest.cs +++ b/osu.Game.Tests/Localisation/BeatmapMetadataRomanisationTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Beatmaps; diff --git a/osu.Game.Tests/Models/DisplayStringTest.cs b/osu.Game.Tests/Models/DisplayStringTest.cs index 2d0bda88e4..d585a0eb9f 100644 --- a/osu.Game.Tests/Models/DisplayStringTest.cs +++ b/osu.Game.Tests/Models/DisplayStringTest.cs @@ -10,8 +10,6 @@ using osu.Game.Rulesets; using osu.Game.Scoring; using osu.Game.Users; -#nullable enable - namespace osu.Game.Tests.Models { [TestFixture] diff --git a/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs b/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs index 85b4688eb9..4101652c49 100644 --- a/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs +++ b/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs @@ -15,7 +15,7 @@ namespace osu.Game.Tests.Mods [TestFixture] public class ModDifficultyAdjustTest { - private TestModDifficultyAdjust testMod; + private TestModDifficultyAdjust testMod = null!; [SetUp] public void Setup() @@ -146,7 +146,7 @@ namespace osu.Game.Tests.Mods yield return new TestModDifficultyAdjust(); } - public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList? mods = null) { throw new System.NotImplementedException(); } diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs index 6c9dddf51f..aa41fd830b 100644 --- a/osu.Game.Tests/Mods/ModUtilsTest.cs +++ b/osu.Game.Tests/Mods/ModUtilsTest.cs @@ -5,6 +5,7 @@ using System; using System.Linq; using Moq; using NUnit.Framework; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Utils; @@ -162,19 +163,19 @@ namespace osu.Game.Tests.Mods new object[] { new Mod[] { new OsuModHidden(), new InvalidMultiplayerMod() }, - null + Array.Empty() }, // invalid free mod is valid for local. new object[] { new Mod[] { new OsuModHidden(), new InvalidMultiplayerFreeMod() }, - null + Array.Empty() }, // valid pair. new object[] { new Mod[] { new OsuModHidden(), new OsuModHardRock() }, - null + Array.Empty() }, }; @@ -214,13 +215,13 @@ namespace osu.Game.Tests.Mods new object[] { new Mod[] { new OsuModHidden(), new InvalidMultiplayerFreeMod() }, - null + Array.Empty() }, // valid pair. new object[] { new Mod[] { new OsuModHidden(), new OsuModHardRock() }, - null + Array.Empty() }, }; @@ -254,19 +255,19 @@ namespace osu.Game.Tests.Mods new object[] { new Mod[] { new OsuModHidden(), new OsuModApproachDifferent() }, - null, + Array.Empty(), }, // incompatible pair with derived class is valid for free mods. new object[] { new Mod[] { new OsuModDeflate(), new OsuModSpinIn() }, - null, + Array.Empty(), }, // valid pair. new object[] { new Mod[] { new OsuModHidden(), new OsuModHardRock() }, - null + Array.Empty() }, }; @@ -275,12 +276,12 @@ namespace osu.Game.Tests.Mods { bool isValid = ModUtils.CheckValidForGameplay(inputMods, out var invalid); - Assert.That(isValid, Is.EqualTo(expectedInvalid == null)); + Assert.That(isValid, Is.EqualTo(expectedInvalid.Length == 0)); if (isValid) Assert.IsNull(invalid); else - Assert.That(invalid.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); + Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); } [TestCaseSource(nameof(invalid_multiplayer_mod_test_scenarios))] @@ -288,12 +289,12 @@ namespace osu.Game.Tests.Mods { bool isValid = ModUtils.CheckValidRequiredModsForMultiplayer(inputMods, out var invalid); - Assert.That(isValid, Is.EqualTo(expectedInvalid == null)); + Assert.That(isValid, Is.EqualTo(expectedInvalid.Length == 0)); if (isValid) Assert.IsNull(invalid); else - Assert.That(invalid.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); + Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); } [TestCaseSource(nameof(invalid_free_mod_test_scenarios))] @@ -301,12 +302,12 @@ namespace osu.Game.Tests.Mods { bool isValid = ModUtils.CheckValidFreeModsForMultiplayer(inputMods, out var invalid); - Assert.That(isValid, Is.EqualTo(expectedInvalid == null)); + Assert.That(isValid, Is.EqualTo(expectedInvalid.Length == 0)); if (isValid) Assert.IsNull(invalid); else - Assert.That(invalid.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); + Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); } public abstract class CustomMod1 : Mod, IModCompatibilitySpecification @@ -320,7 +321,7 @@ namespace osu.Game.Tests.Mods public class InvalidMultiplayerMod : Mod { public override string Name => string.Empty; - public override string Description => string.Empty; + public override LocalisableString Description => string.Empty; public override string Acronym => string.Empty; public override double ScoreMultiplier => 1; public override bool HasImplementation => true; @@ -331,7 +332,7 @@ namespace osu.Game.Tests.Mods private class InvalidMultiplayerFreeMod : Mod { public override string Name => string.Empty; - public override string Description => string.Empty; + public override LocalisableString Description => string.Empty; public override string Acronym => string.Empty; public override double ScoreMultiplier => 1; public override bool HasImplementation => true; diff --git a/osu.Game.Tests/Mods/MultiModIncompatibilityTest.cs b/osu.Game.Tests/Mods/MultiModIncompatibilityTest.cs index 312b939315..ca5240a39d 100644 --- a/osu.Game.Tests/Mods/MultiModIncompatibilityTest.cs +++ b/osu.Game.Tests/Mods/MultiModIncompatibilityTest.cs @@ -27,10 +27,10 @@ namespace osu.Game.Tests.Mods [TestCase(typeof(ManiaRuleset))] public void TestAllMultiModsFromRulesetAreIncompatible(Type rulesetType) { - var ruleset = (Ruleset)Activator.CreateInstance(rulesetType); + var ruleset = Activator.CreateInstance(rulesetType) as Ruleset; Assert.That(ruleset, Is.Not.Null); - var allMultiMods = getMultiMods(ruleset); + var allMultiMods = getMultiMods(ruleset!); Assert.Multiple(() => { @@ -60,6 +60,6 @@ namespace osu.Game.Tests.Mods /// This local helper is used rather than , because the aforementioned method flattens multi mods. /// > private static IEnumerable getMultiMods(Ruleset ruleset) - => Enum.GetValues(typeof(ModType)).Cast().SelectMany(ruleset.GetModsFor).OfType(); + => Enum.GetValues().SelectMany(ruleset.GetModsFor).OfType(); } } diff --git a/osu.Game.Tests/Mods/SettingsSourceAttributeTest.cs b/osu.Game.Tests/Mods/SettingSourceAttributeTest.cs similarity index 92% rename from osu.Game.Tests/Mods/SettingsSourceAttributeTest.cs rename to osu.Game.Tests/Mods/SettingSourceAttributeTest.cs index dd105787fa..5da303d3a7 100644 --- a/osu.Game.Tests/Mods/SettingsSourceAttributeTest.cs +++ b/osu.Game.Tests/Mods/SettingSourceAttributeTest.cs @@ -12,7 +12,7 @@ using osu.Game.Overlays.Settings; namespace osu.Game.Tests.Mods { [TestFixture] - public class SettingsSourceAttributeTest + public partial class SettingSourceAttributeTest { [Test] public void TestOrdering() @@ -60,11 +60,11 @@ namespace osu.Game.Tests.Mods public BindableInt UnorderedSetting { get; set; } = new BindableInt(); } - private class CustomSettingsControl : SettingsItem + private partial class CustomSettingsControl : SettingsItem { protected override Drawable CreateControl() => new CustomControl(); - private class CustomControl : Drawable, IHasCurrentValue + private partial class CustomControl : Drawable, IHasCurrentValue { public Bindable Current { get; set; } = new Bindable(); } diff --git a/osu.Game.Tests/Mods/TestCustomisableModRuleset.cs b/osu.Game.Tests/Mods/TestCustomisableModRuleset.cs index 3992d9abe6..7df5448ff7 100644 --- a/osu.Game.Tests/Mods/TestCustomisableModRuleset.cs +++ b/osu.Game.Tests/Mods/TestCustomisableModRuleset.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using osu.Framework.Bindables; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Rulesets; @@ -31,7 +32,7 @@ namespace osu.Game.Tests.Mods return Array.Empty(); } - public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => throw new NotImplementedException(); + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList? mods = null) => throw new NotImplementedException(); public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => throw new NotImplementedException(); @@ -60,7 +61,7 @@ namespace osu.Game.Tests.Mods { public override double ScoreMultiplier => 1.0; - public override string Description => "This is a customisable test mod."; + public override LocalisableString Description => "This is a customisable test mod."; public override ModType Type => ModType.Conversion; diff --git a/osu.Game.Tests/NonVisual/BarLineGeneratorTest.cs b/osu.Game.Tests/NonVisual/BarLineGeneratorTest.cs index 6ae8231deb..e7827a7398 100644 --- a/osu.Game.Tests/NonVisual/BarLineGeneratorTest.cs +++ b/osu.Game.Tests/NonVisual/BarLineGeneratorTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Utils; diff --git a/osu.Game.Tests/NonVisual/BeatmapSetInfoEqualityTest.cs b/osu.Game.Tests/NonVisual/BeatmapSetInfoEqualityTest.cs index 1b6049fcb7..a229331ef0 100644 --- a/osu.Game.Tests/NonVisual/BeatmapSetInfoEqualityTest.cs +++ b/osu.Game.Tests/NonVisual/BeatmapSetInfoEqualityTest.cs @@ -1,10 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; +using System.Linq; using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Extensions; +using osu.Game.Models; +using osu.Game.Tests.Resources; namespace osu.Game.Tests.NonVisual { @@ -21,6 +26,100 @@ namespace osu.Game.Tests.NonVisual Assert.IsTrue(ourInfo.MatchesOnlineID(otherInfo)); } + [Test] + public void TestAudioEqualityNoFile() + { + var beatmapSetA = TestResources.CreateTestBeatmapSetInfo(1); + var beatmapSetB = TestResources.CreateTestBeatmapSetInfo(1); + + Assert.AreNotEqual(beatmapSetA, beatmapSetB); + Assert.IsTrue(beatmapSetA.Beatmaps.Single().AudioEquals(beatmapSetB.Beatmaps.Single())); + } + + [Test] + public void TestAudioEqualityCaseSensitivity() + { + var beatmapSetA = TestResources.CreateTestBeatmapSetInfo(1); + var beatmapSetB = TestResources.CreateTestBeatmapSetInfo(1); + + // empty by default so let's set it.. + beatmapSetA.Beatmaps.First().Metadata.AudioFile = "audio.mp3"; + beatmapSetB.Beatmaps.First().Metadata.AudioFile = "audio.mp3"; + + addAudioFile(beatmapSetA, "abc", "AuDiO.mP3"); + addAudioFile(beatmapSetB, "abc", "audio.mp3"); + + Assert.AreNotEqual(beatmapSetA, beatmapSetB); + Assert.IsTrue(beatmapSetA.Beatmaps.Single().AudioEquals(beatmapSetB.Beatmaps.Single())); + } + + [Test] + public void TestAudioEqualitySameHash() + { + var beatmapSetA = TestResources.CreateTestBeatmapSetInfo(1); + var beatmapSetB = TestResources.CreateTestBeatmapSetInfo(1); + + addAudioFile(beatmapSetA, "abc"); + addAudioFile(beatmapSetB, "abc"); + + Assert.AreNotEqual(beatmapSetA, beatmapSetB); + Assert.IsTrue(beatmapSetA.Beatmaps.Single().AudioEquals(beatmapSetB.Beatmaps.Single())); + } + + [Test] + public void TestAudioEqualityDifferentHash() + { + var beatmapSetA = TestResources.CreateTestBeatmapSetInfo(1); + var beatmapSetB = TestResources.CreateTestBeatmapSetInfo(1); + + addAudioFile(beatmapSetA); + addAudioFile(beatmapSetB); + + Assert.AreNotEqual(beatmapSetA, beatmapSetB); + Assert.IsTrue(beatmapSetA.Beatmaps.Single().AudioEquals(beatmapSetB.Beatmaps.Single())); + } + + [Test] + public void TestAudioEqualityBeatmapInfoSameHash() + { + var beatmapSet = TestResources.CreateTestBeatmapSetInfo(2); + + addAudioFile(beatmapSet); + + var beatmap1 = beatmapSet.Beatmaps.First(); + var beatmap2 = beatmapSet.Beatmaps.Last(); + + Assert.AreNotEqual(beatmap1, beatmap2); + Assert.IsTrue(beatmap1.AudioEquals(beatmap2)); + } + + [Test] + public void TestAudioEqualityBeatmapInfoDifferentHash() + { + var beatmapSet = TestResources.CreateTestBeatmapSetInfo(2); + + const string filename1 = "audio1.mp3"; + const string filename2 = "audio2.mp3"; + + addAudioFile(beatmapSet, filename: filename1); + addAudioFile(beatmapSet, filename: filename2); + + var beatmap1 = beatmapSet.Beatmaps.First(); + var beatmap2 = beatmapSet.Beatmaps.Last(); + + Assert.AreNotEqual(beatmap1, beatmap2); + + beatmap1.Metadata.AudioFile = filename1; + beatmap2.Metadata.AudioFile = filename2; + + Assert.IsFalse(beatmap1.AudioEquals(beatmap2)); + } + + private static void addAudioFile(BeatmapSetInfo beatmapSetInfo, string hash = null, string filename = null) + { + beatmapSetInfo.Files.Add(new RealmNamedFileUsage(new RealmFile { Hash = hash ?? Guid.NewGuid().ToString() }, filename ?? "audio.mp3")); + } + [Test] public void TestDatabasedWithDatabased() { diff --git a/osu.Game.Tests/NonVisual/ClosestBeatDivisorTest.cs b/osu.Game.Tests/NonVisual/ClosestBeatDivisorTest.cs index 08cd80dcfa..8ebf34b1ca 100644 --- a/osu.Game.Tests/NonVisual/ClosestBeatDivisorTest.cs +++ b/osu.Game.Tests/NonVisual/ClosestBeatDivisorTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using NUnit.Framework; using osu.Game.Beatmaps; @@ -15,7 +17,7 @@ namespace osu.Game.Tests.NonVisual public void TestExactDivisors() { var cpi = new ControlPointInfo(); - cpi.Add(-1000, new TimingControlPoint { BeatLength = 1000 }); + cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); double[] divisors = { 3, 1, 16, 12, 8, 6, 4, 3, 2, 1 }; @@ -45,7 +47,7 @@ namespace osu.Game.Tests.NonVisual public void TestExactDivisorsHighBPMStream() { var cpi = new ControlPointInfo(); - cpi.Add(-50, new TimingControlPoint { BeatLength = 50 }); // 1200 BPM 1/4 (limit testing) + cpi.Add(0, new TimingControlPoint { BeatLength = 50 }); // 1200 BPM 1/4 (limit testing) // A 1/4 stream should land on 1/1, 1/2 and 1/4 divisors. double[] divisors = { 4, 4, 4, 4, 4, 4, 4, 4 }; @@ -58,7 +60,7 @@ namespace osu.Game.Tests.NonVisual public void TestApproximateDivisors() { var cpi = new ControlPointInfo(); - cpi.Add(-1000, new TimingControlPoint { BeatLength = 1000 }); + cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); double[] divisors = { 3.03d, 0.97d, 14, 13, 7.94d, 6.08d, 3.93d, 2.96d, 2.02d, 64 }; double[] closestDivisors = { 3, 1, 16, 12, 8, 6, 4, 3, 2, 1 }; @@ -66,7 +68,7 @@ namespace osu.Game.Tests.NonVisual assertClosestDivisors(divisors, closestDivisors, cpi); } - private void assertClosestDivisors(IReadOnlyList divisors, IReadOnlyList closestDivisors, ControlPointInfo cpi, double step = 1) + private static void assertClosestDivisors(IReadOnlyList divisors, IReadOnlyList closestDivisors, ControlPointInfo cpi, double step = 1) { List hitobjects = new List(); double offset = cpi.TimingPoints[0].Time; diff --git a/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs b/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs index cfda4f6422..a2ded643fa 100644 --- a/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs +++ b/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Game.Beatmaps.ControlPoints; @@ -41,6 +43,18 @@ namespace osu.Game.Tests.NonVisual Assert.That(cpi.Groups.Count, Is.EqualTo(2)); Assert.That(cpi.TimingPoints.Count, Is.EqualTo(2)); Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(2)); + + cpi.Add(1200, new TimingControlPoint { OmitFirstBarLine = true }); // is not redundant + + Assert.That(cpi.Groups.Count, Is.EqualTo(3)); + Assert.That(cpi.TimingPoints.Count, Is.EqualTo(3)); + Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(3)); + + cpi.Add(1500, new TimingControlPoint { OmitFirstBarLine = true }); // is not redundant + + Assert.That(cpi.Groups.Count, Is.EqualTo(4)); + Assert.That(cpi.TimingPoints.Count, Is.EqualTo(4)); + Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(4)); } [Test] @@ -93,12 +107,12 @@ namespace osu.Game.Tests.NonVisual Assert.That(cpi.EffectPoints.Count, Is.EqualTo(0)); Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(0)); - cpi.Add(1000, new EffectControlPoint { KiaiMode = true, OmitFirstBarLine = true }); // is not redundant - cpi.Add(1400, new EffectControlPoint { KiaiMode = true, OmitFirstBarLine = true }); // same settings, but is not redundant + cpi.Add(1000, new EffectControlPoint { KiaiMode = true }); // is not redundant + cpi.Add(1400, new EffectControlPoint { KiaiMode = true }); // is redundant - Assert.That(cpi.Groups.Count, Is.EqualTo(2)); - Assert.That(cpi.EffectPoints.Count, Is.EqualTo(2)); - Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(2)); + Assert.That(cpi.Groups.Count, Is.EqualTo(1)); + Assert.That(cpi.EffectPoints.Count, Is.EqualTo(1)); + Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(1)); } [Test] diff --git a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs index fd5691a9f4..216db2121c 100644 --- a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs +++ b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.IO; using System.Linq; @@ -42,8 +44,7 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestCustomDirectory() { - string customPath = prepareCustomPath(); - + using (prepareCustomPath(out string customPath)) using (var host = new CustomTestHeadlessGameHost()) { using (var storageConfig = new StorageConfigManager(host.InitialStorage)) @@ -61,7 +62,6 @@ namespace osu.Game.Tests.NonVisual finally { host.Exit(); - cleanupPath(customPath); } } } @@ -69,8 +69,7 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestSubDirectoryLookup() { - string customPath = prepareCustomPath(); - + using (prepareCustomPath(out string customPath)) using (var host = new CustomTestHeadlessGameHost()) { using (var storageConfig = new StorageConfigManager(host.InitialStorage)) @@ -95,7 +94,6 @@ namespace osu.Game.Tests.NonVisual finally { host.Exit(); - cleanupPath(customPath); } } } @@ -103,8 +101,7 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestMigration() { - string customPath = prepareCustomPath(); - + using (prepareCustomPath(out string customPath)) using (var host = new CustomTestHeadlessGameHost()) { try @@ -171,7 +168,6 @@ namespace osu.Game.Tests.NonVisual finally { host.Exit(); - cleanupPath(customPath); } } } @@ -179,9 +175,8 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestMigrationBetweenTwoTargets() { - string customPath = prepareCustomPath(); - string customPath2 = prepareCustomPath(); - + using (prepareCustomPath(out string customPath)) + using (prepareCustomPath(out string customPath2)) using (var host = new CustomTestHeadlessGameHost()) { try @@ -203,8 +198,6 @@ namespace osu.Game.Tests.NonVisual finally { host.Exit(); - cleanupPath(customPath); - cleanupPath(customPath2); } } } @@ -212,8 +205,7 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestMigrationToSameTargetFails() { - string customPath = prepareCustomPath(); - + using (prepareCustomPath(out string customPath)) using (var host = new CustomTestHeadlessGameHost()) { try @@ -226,7 +218,6 @@ namespace osu.Game.Tests.NonVisual finally { host.Exit(); - cleanupPath(customPath); } } } @@ -234,9 +225,8 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestMigrationFailsOnExistingData() { - string customPath = prepareCustomPath(); - string customPath2 = prepareCustomPath(); - + using (prepareCustomPath(out string customPath)) + using (prepareCustomPath(out string customPath2)) using (var host = new CustomTestHeadlessGameHost()) { try @@ -252,7 +242,7 @@ namespace osu.Game.Tests.NonVisual Assert.That(File.Exists(Path.Combine(customPath, OsuGameBase.CLIENT_DATABASE_FILENAME))); Directory.CreateDirectory(customPath2); - File.Copy(Path.Combine(customPath, OsuGameBase.CLIENT_DATABASE_FILENAME), Path.Combine(customPath2, OsuGameBase.CLIENT_DATABASE_FILENAME)); + File.WriteAllText(Path.Combine(customPath2, OsuGameBase.CLIENT_DATABASE_FILENAME), "I am a text"); // Fails because file already exists. Assert.Throws(() => osu.Migrate(customPath2)); @@ -265,8 +255,6 @@ namespace osu.Game.Tests.NonVisual finally { host.Exit(); - cleanupPath(customPath); - cleanupPath(customPath2); } } } @@ -274,8 +262,7 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestMigrationToNestedTargetFails() { - string customPath = prepareCustomPath(); - + using (prepareCustomPath(out string customPath)) using (var host = new CustomTestHeadlessGameHost()) { try @@ -296,7 +283,6 @@ namespace osu.Game.Tests.NonVisual finally { host.Exit(); - cleanupPath(customPath); } } } @@ -304,8 +290,7 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestMigrationToSeeminglyNestedTarget() { - string customPath = prepareCustomPath(); - + using (prepareCustomPath(out string customPath)) using (var host = new CustomTestHeadlessGameHost()) { try @@ -326,7 +311,26 @@ namespace osu.Game.Tests.NonVisual finally { host.Exit(); - cleanupPath(customPath); + } + } + } + + [Test] + public void TestBackupCreatedOnCorruptRealm() + { + using (var host = new CustomTestHeadlessGameHost()) + { + try + { + File.WriteAllText(host.InitialStorage.GetFullPath(OsuGameBase.CLIENT_DATABASE_FILENAME, true), "i am definitely not a realm file"); + + LoadOsuIntoHost(host); + + Assert.That(host.InitialStorage.GetFiles(string.Empty, "*_corrupt.realm"), Has.One.Items); + } + finally + { + host.Exit(); } } } @@ -341,14 +345,17 @@ namespace osu.Game.Tests.NonVisual return path; } - private static string prepareCustomPath() => Path.Combine(TestRunHeadlessGameHost.TemporaryTestDirectory, $"custom-path-{Guid.NewGuid()}"); + private static IDisposable prepareCustomPath(out string path) + { + path = Path.Combine(TestRunHeadlessGameHost.TemporaryTestDirectory, $"custom-path-{Guid.NewGuid()}"); + return new InvokeOnDisposal(path, cleanupPath); + } private static void cleanupPath(string path) { try { - if (Directory.Exists(path)) - Directory.Delete(path, true); + if (Directory.Exists(path)) Directory.Delete(path, true); } catch { @@ -360,7 +367,7 @@ namespace osu.Game.Tests.NonVisual public Storage InitialStorage { get; } public CustomTestHeadlessGameHost([CallerMemberName] string callingMethodName = @"") - : base(callingMethodName: callingMethodName) + : base(callingMethodName: callingMethodName, bypassCleanupOnSetup: true) { string defaultStorageLocation = getDefaultLocationFor(this); diff --git a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs index ae8eec2629..6637d640b2 100644 --- a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs +++ b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs @@ -1,10 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty.Preprocessing; @@ -158,7 +161,7 @@ namespace osu.Game.Tests.NonVisual { public override string Name => nameof(ModA); public override string Acronym => nameof(ModA); - public override string Description => string.Empty; + public override LocalisableString Description => string.Empty; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => new[] { typeof(ModIncompatibleWithA), typeof(ModIncompatibleWithAAndB) }; @@ -167,7 +170,7 @@ namespace osu.Game.Tests.NonVisual private class ModB : Mod { public override string Name => nameof(ModB); - public override string Description => string.Empty; + public override LocalisableString Description => string.Empty; public override string Acronym => nameof(ModB); public override double ScoreMultiplier => 1; @@ -178,7 +181,7 @@ namespace osu.Game.Tests.NonVisual { public override string Name => nameof(ModC); public override string Acronym => nameof(ModC); - public override string Description => string.Empty; + public override LocalisableString Description => string.Empty; public override double ScoreMultiplier => 1; } @@ -186,7 +189,7 @@ namespace osu.Game.Tests.NonVisual { public override string Name => $"Incompatible With {nameof(ModA)}"; public override string Acronym => $"Incompatible With {nameof(ModA)}"; - public override string Description => string.Empty; + public override LocalisableString Description => string.Empty; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => new[] { typeof(ModA) }; @@ -205,7 +208,7 @@ namespace osu.Game.Tests.NonVisual { public override string Name => $"Incompatible With {nameof(ModA)} and {nameof(ModB)}"; public override string Acronym => $"Incompatible With {nameof(ModA)} and {nameof(ModB)}"; - public override string Description => string.Empty; + public override LocalisableString Description => string.Empty; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => new[] { typeof(ModA), typeof(ModB) }; diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index 460f89528b..da32edb8fb 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs @@ -118,17 +118,31 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.IsNull(filterCriteria.BPM.Max); } - private static readonly object[] length_query_examples = + private static readonly object[] correct_length_query_examples = { - new object[] { "6ms", TimeSpan.FromMilliseconds(6), TimeSpan.FromMilliseconds(1) }, new object[] { "23s", TimeSpan.FromSeconds(23), TimeSpan.FromSeconds(1) }, new object[] { "9m", TimeSpan.FromMinutes(9), TimeSpan.FromMinutes(1) }, new object[] { "0.25h", TimeSpan.FromHours(0.25), TimeSpan.FromHours(1) }, new object[] { "70", TimeSpan.FromSeconds(70), TimeSpan.FromSeconds(1) }, + new object[] { "7m27s", TimeSpan.FromSeconds(447), TimeSpan.FromSeconds(1) }, + new object[] { "7:27", TimeSpan.FromSeconds(447), TimeSpan.FromSeconds(1) }, + new object[] { "1h2m3s", TimeSpan.FromSeconds(3723), TimeSpan.FromSeconds(1) }, + new object[] { "1h2m3.5s", TimeSpan.FromSeconds(3723.5), TimeSpan.FromSeconds(1) }, + new object[] { "1:2:3", TimeSpan.FromSeconds(3723), TimeSpan.FromSeconds(1) }, + new object[] { "1:02:03", TimeSpan.FromSeconds(3723), TimeSpan.FromSeconds(1) }, + new object[] { "6", TimeSpan.FromSeconds(6), TimeSpan.FromSeconds(1) }, + new object[] { "6.5", TimeSpan.FromSeconds(6.5), TimeSpan.FromSeconds(1) }, + new object[] { "6.5s", TimeSpan.FromSeconds(6.5), TimeSpan.FromSeconds(1) }, + new object[] { "6.5m", TimeSpan.FromMinutes(6.5), TimeSpan.FromMinutes(1) }, + new object[] { "6h5m", TimeSpan.FromMinutes(365), TimeSpan.FromMinutes(1) }, + new object[] { "65m", TimeSpan.FromMinutes(65), TimeSpan.FromMinutes(1) }, + new object[] { "90s", TimeSpan.FromSeconds(90), TimeSpan.FromSeconds(1) }, + new object[] { "80m20s", TimeSpan.FromSeconds(4820), TimeSpan.FromSeconds(1) }, + new object[] { "1h20s", TimeSpan.FromSeconds(3620), TimeSpan.FromSeconds(1) }, }; [Test] - [TestCaseSource(nameof(length_query_examples))] + [TestCaseSource(nameof(correct_length_query_examples))] public void TestApplyLengthQueries(string lengthQuery, TimeSpan expectedLength, TimeSpan scale) { string query = $"length={lengthQuery} time"; @@ -140,6 +154,29 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.AreEqual(expectedLength.TotalMilliseconds + scale.TotalMilliseconds / 2.0, filterCriteria.Length.Max); } + private static readonly object[] incorrect_length_query_examples = + { + new object[] { "7.5m27s" }, + new object[] { "7m27" }, + new object[] { "7m7m7m" }, + new object[] { "7m70s" }, + new object[] { "5s6m" }, + new object[] { "0:" }, + new object[] { ":0" }, + new object[] { "0:3:" }, + new object[] { "3:15.5" }, + }; + + [Test] + [TestCaseSource(nameof(incorrect_length_query_examples))] + public void TestInvalidLengthQueries(string lengthQuery) + { + string query = $"length={lengthQuery} time"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual(false, filterCriteria.Length.HasFilter); + } + [Test] public void TestApplyDivisorQueries() { @@ -154,6 +191,16 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.IsTrue(filterCriteria.BeatDivisor.IsUpperInclusive); } + [Test] + public void TestPartialStatusMatch() + { + const string query = "status=r"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual(BeatmapOnlineStatus.Ranked, filterCriteria.OnlineStatus.Min); + Assert.AreEqual(BeatmapOnlineStatus.Ranked, filterCriteria.OnlineStatus.Max); + } + [Test] public void TestApplyStatusQueries() { @@ -254,7 +301,7 @@ namespace osu.Game.Tests.NonVisual.Filtering private class CustomFilterCriteria : IRulesetFilterCriteria { - public string CustomValue { get; set; } + public string? CustomValue { get; set; } public bool Matches(BeatmapInfo beatmapInfo) => true; diff --git a/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs b/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs index 8386a10ebb..0bdd0ceae6 100644 --- a/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs +++ b/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs @@ -1,10 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using NUnit.Framework; +using osu.Framework.Audio; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; @@ -16,7 +19,7 @@ using osu.Game.Scoring; namespace osu.Game.Tests.NonVisual { - public class FirstAvailableHitWindowsTest + public partial class FirstAvailableHitWindowsTest { private TestDrawableRuleset testDrawableRuleset; @@ -74,23 +77,24 @@ namespace osu.Game.Tests.NonVisual } [SuppressMessage("ReSharper", "UnassignedGetOnlyAutoProperty")] - private class TestDrawableRuleset : DrawableRuleset + private partial class TestDrawableRuleset : DrawableRuleset { public List HitObjects; public override IEnumerable Objects => HitObjects; public override event Action NewResult { - add => throw new InvalidOperationException(); - remove => throw new InvalidOperationException(); + add => throw new InvalidOperationException($"{nameof(NewResult)} operations not supported in test context"); + remove => throw new InvalidOperationException($"{nameof(NewResult)} operations not supported in test context"); } public override event Action RevertResult { - add => throw new InvalidOperationException(); - remove => throw new InvalidOperationException(); + add => throw new InvalidOperationException($"{nameof(RevertResult)} operations not supported in test context"); + remove => throw new InvalidOperationException($"{nameof(RevertResult)} operations not supported in test context"); } + public override IAdjustableAudioComponent Audio { get; } public override Playfield Playfield { get; } public override Container Overlays { get; } public override Container FrameStableComponents { get; } diff --git a/osu.Game.Tests/NonVisual/FormatUtilsTest.cs b/osu.Game.Tests/NonVisual/FormatUtilsTest.cs index d69822cdc5..4d2fc53bc3 100644 --- a/osu.Game.Tests/NonVisual/FormatUtilsTest.cs +++ b/osu.Game.Tests/NonVisual/FormatUtilsTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Utils; diff --git a/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs b/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs index 99d394b454..541ad1e8bb 100644 --- a/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs +++ b/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -362,12 +364,12 @@ namespace osu.Game.Tests.NonVisual private void confirmCurrentFrame(int? frame) { - Assert.AreEqual(frame is int x ? replay.Frames[x].Time : (double?)null, handler.CurrentFrame?.Time, "Unexpected current frame"); + Assert.AreEqual(frame is int x ? replay.Frames[x].Time : null, handler.CurrentFrame?.Time, "Unexpected current frame"); } private void confirmNextFrame(int? frame) { - Assert.AreEqual(frame is int x ? replay.Frames[x].Time : (double?)null, handler.NextFrame?.Time, "Unexpected next frame"); + Assert.AreEqual(frame is int x ? replay.Frames[x].Time : null, handler.NextFrame?.Time, "Unexpected next frame"); } private class TestReplayFrame : ReplayFrame diff --git a/osu.Game.Tests/NonVisual/GameplayClockContainerTest.cs b/osu.Game.Tests/NonVisual/GameplayClockContainerTest.cs new file mode 100644 index 0000000000..d67a3cb824 --- /dev/null +++ b/osu.Game.Tests/NonVisual/GameplayClockContainerTest.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Timing; +using osu.Game.Screens.Play; + +namespace osu.Game.Tests.NonVisual +{ + [TestFixture] + public partial class GameplayClockContainerTest + { + [TestCase(0)] + [TestCase(1)] + public void TestTrueGameplayRateWithGameplayAdjustment(double underlyingClockRate) + { + var framedClock = new FramedClock(new ManualClock { Rate = underlyingClockRate }); + var gameplayClock = new TestGameplayClockContainer(framedClock); + + Assert.That(gameplayClock.GetTrueGameplayRate(), Is.EqualTo(2)); + } + + private partial class TestGameplayClockContainer : GameplayClockContainer + { + public TestGameplayClockContainer(IFrameBasedClock underlyingClock) + : base(underlyingClock) + { + AdjustmentsFromMods.AddAdjustment(AdjustableProperty.Frequency, new BindableDouble(2.0)); + } + } + } +} diff --git a/osu.Game.Tests/NonVisual/GameplayClockTest.cs b/osu.Game.Tests/NonVisual/GameplayClockTest.cs deleted file mode 100644 index 3fd7c364b7..0000000000 --- a/osu.Game.Tests/NonVisual/GameplayClockTest.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using NUnit.Framework; -using osu.Framework.Bindables; -using osu.Framework.Timing; -using osu.Game.Screens.Play; - -namespace osu.Game.Tests.NonVisual -{ - [TestFixture] - public class GameplayClockTest - { - [TestCase(0)] - [TestCase(1)] - public void TestTrueGameplayRateWithZeroAdjustment(double underlyingClockRate) - { - var framedClock = new FramedClock(new ManualClock { Rate = underlyingClockRate }); - var gameplayClock = new TestGameplayClock(framedClock); - - gameplayClock.MutableNonGameplayAdjustments.Add(new BindableDouble()); - - Assert.That(gameplayClock.TrueGameplayRate, Is.EqualTo(0)); - } - - private class TestGameplayClock : GameplayClock - { - public List> MutableNonGameplayAdjustments { get; } = new List>(); - - public override IEnumerable> NonGameplayAdjustments => MutableNonGameplayAdjustments; - - public TestGameplayClock(IFrameBasedClock underlyingClock) - : base(underlyingClock) - { - } - } - } -} diff --git a/osu.Game.Tests/NonVisual/LimitedCapacityQueueTest.cs b/osu.Game.Tests/NonVisual/LimitedCapacityQueueTest.cs index e1eaf213d6..8809ce3adc 100644 --- a/osu.Game.Tests/NonVisual/LimitedCapacityQueueTest.cs +++ b/osu.Game.Tests/NonVisual/LimitedCapacityQueueTest.cs @@ -1,9 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; -using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Utils; namespace osu.Game.Tests.NonVisual { diff --git a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs index 4adb7002a0..ae6a76f6cd 100644 --- a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs +++ b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using Humanizer; using NUnit.Framework; @@ -14,7 +16,7 @@ using osu.Game.Tests.Visual.Multiplayer; namespace osu.Game.Tests.NonVisual.Multiplayer { [HeadlessTest] - public class StatefulMultiplayerClientTest : MultiplayerTestScene + public partial class StatefulMultiplayerClientTest : MultiplayerTestScene { [Test] public void TestUserAddedOnJoin() @@ -22,7 +24,7 @@ namespace osu.Game.Tests.NonVisual.Multiplayer var user = new APIUser { Id = 33 }; AddRepeatStep("add user multiple times", () => MultiplayerClient.AddUser(user), 3); - AddAssert("room has 2 users", () => MultiplayerClient.Room?.Users.Count == 2); + AddUntilStep("room has 2 users", () => MultiplayerClient.ClientRoom?.Users.Count == 2); } [Test] @@ -31,10 +33,10 @@ namespace osu.Game.Tests.NonVisual.Multiplayer var user = new APIUser { Id = 44 }; AddStep("add user", () => MultiplayerClient.AddUser(user)); - AddAssert("room has 2 users", () => MultiplayerClient.Room?.Users.Count == 2); + AddUntilStep("room has 2 users", () => MultiplayerClient.ClientRoom?.Users.Count == 2); - AddRepeatStep("remove user multiple times", () => MultiplayerClient.RemoveUser(user), 3); - AddAssert("room has 1 user", () => MultiplayerClient.Room?.Users.Count == 1); + AddStep("remove user", () => MultiplayerClient.RemoveUser(user)); + AddUntilStep("room has 1 user", () => MultiplayerClient.ClientRoom?.Users.Count == 1); } [Test] @@ -57,7 +59,7 @@ namespace osu.Game.Tests.NonVisual.Multiplayer changeState(6, MultiplayerUserState.WaitingForLoad); checkPlayingUserCount(6); - AddStep("another user left", () => MultiplayerClient.RemoveUser((MultiplayerClient.Room?.Users.Last().User).AsNonNull())); + AddStep("another user left", () => MultiplayerClient.RemoveUser((MultiplayerClient.ServerRoom?.Users.Last().User).AsNonNull())); checkPlayingUserCount(5); AddStep("leave room", () => MultiplayerClient.LeaveRoom()); @@ -101,7 +103,7 @@ namespace osu.Game.Tests.NonVisual.Multiplayer { for (int i = 0; i < userCount; ++i) { - int userId = MultiplayerClient.Room?.Users[i].UserID ?? throw new AssertionException("Room cannot be null!"); + int userId = MultiplayerClient.ServerRoom?.Users[i].UserID ?? throw new AssertionException("Room cannot be null!"); MultiplayerClient.ChangeUserState(userId, state); } }); diff --git a/osu.Game.Tests/NonVisual/OngoingOperationTrackerTest.cs b/osu.Game.Tests/NonVisual/OngoingOperationTrackerTest.cs index 10216c3339..0bcae05f44 100644 --- a/osu.Game.Tests/NonVisual/OngoingOperationTrackerTest.cs +++ b/osu.Game.Tests/NonVisual/OngoingOperationTrackerTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Framework.Allocation; @@ -15,7 +17,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Tests.NonVisual { [HeadlessTest] - public class OngoingOperationTrackerTest : OsuTestScene + public partial class OngoingOperationTrackerTest : OsuTestScene { private OngoingOperationTracker tracker; private IBindable operationInProgress; @@ -92,7 +94,7 @@ namespace osu.Game.Tests.NonVisual AddAssert("operation ended", () => !screen.OngoingOperationTracker.InProgress.Value); } - private class TestScreenWithTracker : OsuScreen + private partial class TestScreenWithTracker : OsuScreen { public OngoingOperationTracker OngoingOperationTracker { get; private set; } diff --git a/osu.Game.Tests/NonVisual/PeriodTrackerTest.cs b/osu.Game.Tests/NonVisual/PeriodTrackerTest.cs index 62c7732b66..b589f7c9f1 100644 --- a/osu.Game.Tests/NonVisual/PeriodTrackerTest.cs +++ b/osu.Game.Tests/NonVisual/PeriodTrackerTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; diff --git a/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs b/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs index 103831822c..27c8270f0f 100644 --- a/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs +++ b/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; diff --git a/osu.Game.Tests/NonVisual/ReverseQueueTest.cs b/osu.Game.Tests/NonVisual/ReverseQueueTest.cs index 808c8d14f0..d0ad2e22a4 100644 --- a/osu.Game.Tests/NonVisual/ReverseQueueTest.cs +++ b/osu.Game.Tests/NonVisual/ReverseQueueTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Game.Rulesets.Difficulty.Utils; diff --git a/osu.Game.Tests/NonVisual/RulesetInfoOrderingTest.cs b/osu.Game.Tests/NonVisual/RulesetInfoOrderingTest.cs index ae999d08d5..8654abd49d 100644 --- a/osu.Game.Tests/NonVisual/RulesetInfoOrderingTest.cs +++ b/osu.Game.Tests/NonVisual/RulesetInfoOrderingTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Game.Rulesets; diff --git a/osu.Game.Tests/NonVisual/ScoreInfoTest.cs b/osu.Game.Tests/NonVisual/ScoreInfoTest.cs index 41b08a9e98..dcc4f91dba 100644 --- a/osu.Game.Tests/NonVisual/ScoreInfoTest.cs +++ b/osu.Game.Tests/NonVisual/ScoreInfoTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Online.API; using osu.Game.Rulesets.Mania; diff --git a/osu.Game.Tests/NonVisual/SessionStaticsTest.cs b/osu.Game.Tests/NonVisual/SessionStaticsTest.cs index cd02f15adf..5c8254b947 100644 --- a/osu.Game.Tests/NonVisual/SessionStaticsTest.cs +++ b/osu.Game.Tests/NonVisual/SessionStaticsTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Game.Configuration; diff --git a/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs b/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs index 4209f188cc..197e9404ff 100644 --- a/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs +++ b/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -9,7 +11,7 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Textures; using osu.Framework.Testing; using osu.Framework.Timing; @@ -20,11 +22,14 @@ using osu.Game.Tests.Visual; namespace osu.Game.Tests.NonVisual.Skinning { [HeadlessTest] - public class LegacySkinAnimationTest : OsuTestScene + public partial class LegacySkinAnimationTest : OsuTestScene { private const string animation_name = "animation"; private const int frame_count = 6; + [Resolved] + private IRenderer renderer { get; set; } + [Cached(typeof(IAnimationTimeReference))] private TestAnimationTimeReference animationTimeReference = new TestAnimationTimeReference(); @@ -33,9 +38,12 @@ namespace osu.Game.Tests.NonVisual.Skinning [Test] public void TestAnimationTimeReferenceChange() { - ISkin skin = new TestSkin(); + AddStep("get animation", () => + { + ISkin skin = new TestSkin(renderer); + Add(animation = (TextureAnimation)skin.GetAnimation(animation_name, true, false)); + }); - AddStep("get animation", () => Add(animation = (TextureAnimation)skin.GetAnimation(animation_name, true, false))); AddAssert("frame count correct", () => animation.FrameCount == frame_count); assertPlaybackPosition(0); @@ -53,12 +61,19 @@ namespace osu.Game.Tests.NonVisual.Skinning { private static readonly string[] lookup_names = Enumerable.Range(0, frame_count).Select(frame => $"{animation_name}-{frame}").ToArray(); - public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) + private readonly IRenderer renderer; + + public TestSkin(IRenderer renderer) { - return lookup_names.Contains(componentName) ? Texture.WhitePixel : null; + this.renderer = renderer; } - public Drawable GetDrawableComponent(ISkinComponent component) => throw new NotSupportedException(); + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) + { + return lookup_names.Contains(componentName) ? renderer.WhitePixel : null; + } + + public Drawable GetDrawableComponent(ISkinComponentLookup lookup) => throw new NotSupportedException(); public ISample GetSample(ISampleInfo sampleInfo) => throw new NotSupportedException(); public IBindable GetConfig(TLookup lookup) => throw new NotSupportedException(); } diff --git a/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs b/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs index 76c49edf78..ca0d4d3cf3 100644 --- a/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs +++ b/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.IO; @@ -9,6 +11,8 @@ using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Audio; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Rendering.Dummy; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Game.Database; @@ -139,6 +143,7 @@ namespace osu.Game.Tests.NonVisual.Skinning this.textureStore = textureStore; } + public IRenderer Renderer => new DummyRenderer(); public AudioManager AudioManager => null; public IResourceStore Files => null; public IResourceStore Resources => null; @@ -155,7 +160,7 @@ namespace osu.Game.Tests.NonVisual.Skinning { // use an incrementing width to allow assertion matching on correct textures as they turn from uploads into actual textures. int width = 1; - Textures = fileNames.ToDictionary(fileName => fileName, fileName => new TextureUpload(new Image(width, width++))); + Textures = fileNames.ToDictionary(fileName => fileName, _ => new TextureUpload(new Image(width, width++))); } public TextureUpload Get(string name) => Textures.GetValueOrDefault(name); diff --git a/osu.Game.Tests/NonVisual/TaskChainTest.cs b/osu.Game.Tests/NonVisual/TaskChainTest.cs index 3678279035..ad1a3fd63f 100644 --- a/osu.Game.Tests/NonVisual/TaskChainTest.cs +++ b/osu.Game.Tests/NonVisual/TaskChainTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Threading; using System.Threading.Tasks; using NUnit.Framework; diff --git a/osu.Game.Tests/NonVisual/TimeDisplayExtensionTest.cs b/osu.Game.Tests/NonVisual/TimeDisplayExtensionTest.cs index 97d7880def..861e342cdb 100644 --- a/osu.Game.Tests/NonVisual/TimeDisplayExtensionTest.cs +++ b/osu.Game.Tests/NonVisual/TimeDisplayExtensionTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Game.Extensions; diff --git a/osu.Game.Tests/Online/Chat/MessageNotifierTest.cs b/osu.Game.Tests/Online/Chat/MessageNotifierTest.cs index 855de9b656..13f1ed5c57 100644 --- a/osu.Game.Tests/Online/Chat/MessageNotifierTest.cs +++ b/osu.Game.Tests/Online/Chat/MessageNotifierTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Online.Chat; diff --git a/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs index 0622514783..da250c1e05 100644 --- a/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs +++ b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs @@ -1,16 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using NUnit.Framework; using osu.Framework.Bindables; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; -using osu.Game.Online.Solo; using osu.Game.Rulesets; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; @@ -108,30 +111,30 @@ namespace osu.Game.Tests.Online } [Test] - public void TestDeserialiseSubmittableScoreWithEmptyMods() + public void TestDeserialiseSoloScoreWithEmptyMods() { - var score = new SubmittableScore(new ScoreInfo + var score = SoloScoreInfo.ForSubmission(new ScoreInfo { User = new APIUser(), Ruleset = new OsuRuleset().RulesetInfo, }); - var deserialised = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(score)); + var deserialised = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(score)); Assert.That(deserialised?.Mods.Length, Is.Zero); } [Test] - public void TestDeserialiseSubmittableScoreWithCustomModSetting() + public void TestDeserialiseSoloScoreWithCustomModSetting() { - var score = new SubmittableScore(new ScoreInfo + var score = SoloScoreInfo.ForSubmission(new ScoreInfo { Mods = new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 2 } } }, User = new APIUser(), Ruleset = new OsuRuleset().RulesetInfo, }); - var deserialised = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(score)); + var deserialised = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(score)); Assert.That((deserialised?.Mods[0])?.Settings["speed_change"], Is.EqualTo(2)); } @@ -147,6 +150,16 @@ namespace osu.Game.Tests.Online Assert.That(apiMod.Settings["speed_change"], Is.EqualTo(1.01d)); } + [Test] + public void TestSerialisedModSettingPresence() + { + var mod = new TestMod(); + + mod.TestSetting.Value = mod.TestSetting.Default; + JObject serialised = JObject.Parse(JsonConvert.SerializeObject(new APIMod(mod))); + Assert.False(serialised.ContainsKey("settings")); + } + private class TestRuleset : Ruleset { public override IEnumerable GetModsFor(ModType type) => new Mod[] @@ -170,7 +183,7 @@ namespace osu.Game.Tests.Online { public override string Name => "Test Mod"; public override string Acronym => "TM"; - public override string Description => "This is a test mod."; + public override LocalisableString Description => "This is a test mod."; public override double ScoreMultiplier => 1; [SettingSource("Test")] @@ -187,35 +200,27 @@ namespace osu.Game.Tests.Online { public override string Name => "Test Mod"; public override string Acronym => "TMTR"; - public override string Description => "This is a test mod."; + public override LocalisableString Description => "This is a test mod."; public override double ScoreMultiplier => 1; [SettingSource("Initial rate", "The starting speed of the track")] - public override BindableNumber InitialRate { get; } = new BindableDouble + public override BindableNumber InitialRate { get; } = new BindableDouble(1.5) { MinValue = 1, MaxValue = 2, - Default = 1.5, - Value = 1.5, Precision = 0.01, }; [SettingSource("Final rate", "The speed increase to ramp towards")] - public override BindableNumber FinalRate { get; } = new BindableDouble + public override BindableNumber FinalRate { get; } = new BindableDouble(0.5) { MinValue = 0, MaxValue = 1, - Default = 0.5, - Value = 0.5, Precision = 0.01, }; [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] - public override BindableBool AdjustPitch { get; } = new BindableBool - { - Default = true, - Value = true - }; + public override BindableBool AdjustPitch { get; } = new BindableBool(true); } private class TestModDifficultyAdjust : ModDifficultyAdjust diff --git a/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs b/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs index 69e19826fd..1d8cbffcdb 100644 --- a/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs +++ b/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs @@ -1,10 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using MessagePack; using NUnit.Framework; using osu.Framework.Bindables; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Online.API; @@ -100,7 +103,7 @@ namespace osu.Game.Tests.Online { public override string Name => "Test Mod"; public override string Acronym => "TM"; - public override string Description => "This is a test mod."; + public override LocalisableString Description => "This is a test mod."; public override double ScoreMultiplier => 1; [SettingSource("Test")] @@ -117,42 +120,34 @@ namespace osu.Game.Tests.Online { public override string Name => "Test Mod"; public override string Acronym => "TMTR"; - public override string Description => "This is a test mod."; + public override LocalisableString Description => "This is a test mod."; public override double ScoreMultiplier => 1; [SettingSource("Initial rate", "The starting speed of the track")] - public override BindableNumber InitialRate { get; } = new BindableDouble + public override BindableNumber InitialRate { get; } = new BindableDouble(1.5) { MinValue = 1, MaxValue = 2, - Default = 1.5, - Value = 1.5, Precision = 0.01, }; [SettingSource("Final rate", "The speed increase to ramp towards")] - public override BindableNumber FinalRate { get; } = new BindableDouble + public override BindableNumber FinalRate { get; } = new BindableDouble(0.5) { MinValue = 0, MaxValue = 1, - Default = 0.5, - Value = 0.5, Precision = 0.01, }; [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] - public override BindableBool AdjustPitch { get; } = new BindableBool - { - Default = true, - Value = true - }; + public override BindableBool AdjustPitch { get; } = new BindableBool(true); } private class TestModEnum : Mod { public override string Name => "Test Mod"; public override string Acronym => "TM"; - public override string Description => "This is a test mod."; + public override LocalisableString Description => "This is a test mod."; public override double ScoreMultiplier => 1; [SettingSource("Test")] diff --git a/osu.Game.Tests/Online/TestDummyAPIRequestHandling.cs b/osu.Game.Tests/Online/TestDummyAPIRequestHandling.cs index 91c6b6c008..f41c3ad6af 100644 --- a/osu.Game.Tests/Online/TestDummyAPIRequestHandling.cs +++ b/osu.Game.Tests/Online/TestDummyAPIRequestHandling.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Testing; using osu.Game.Online.API; @@ -12,7 +14,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Tests.Online { [HeadlessTest] - public class TestDummyAPIRequestHandling : OsuTestScene + public partial class TestDummyAPIRequestHandling : OsuTestScene { [Test] public void TestGenericRequestHandling() diff --git a/osu.Game.Tests/Online/TestMultiplayerMessagePackSerialization.cs b/osu.Game.Tests/Online/TestMultiplayerMessagePackSerialization.cs index 81475f2fbe..aea579a82d 100644 --- a/osu.Game.Tests/Online/TestMultiplayerMessagePackSerialization.cs +++ b/osu.Game.Tests/Online/TestMultiplayerMessagePackSerialization.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using MessagePack; using NUnit.Framework; using osu.Game.Online; diff --git a/osu.Game.Tests/Online/TestSceneBeatmapDownloading.cs b/osu.Game.Tests/Online/TestSceneBeatmapDownloading.cs index ad9ea79646..c959132332 100644 --- a/osu.Game.Tests/Online/TestSceneBeatmapDownloading.cs +++ b/osu.Game.Tests/Online/TestSceneBeatmapDownloading.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Testing; @@ -13,7 +15,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Tests.Online { [HeadlessTest] - public class TestSceneBeatmapDownloading : OsuTestScene + public partial class TestSceneBeatmapDownloading : OsuTestScene { private BeatmapModelDownloader beatmaps; private ProgressNotification recentNotification; @@ -78,7 +80,7 @@ namespace osu.Game.Tests.Online { AddStep("download beatmap", () => beatmaps.Download(test_db_model)); - AddStep("cancel download from request", () => beatmaps.GetExistingDownload(test_db_model).Cancel()); + AddStep("cancel download from request", () => beatmaps.GetExistingDownload(test_db_model)!.Cancel()); AddUntilStep("is removed from download list", () => beatmaps.GetExistingDownload(test_db_model) == null); AddAssert("is notification cancelled", () => recentNotification.State == ProgressNotificationState.Cancelled); @@ -89,7 +91,7 @@ namespace osu.Game.Tests.Online { AddStep("download beatmap", () => beatmaps.Download(test_db_model)); - AddStep("cancel download from notification", () => recentNotification.Close()); + AddStep("cancel download from notification", () => recentNotification.Close(true)); AddUntilStep("is removed from download list", () => beatmaps.GetExistingDownload(test_db_model) == null); AddAssert("is notification cancelled", () => recentNotification.State == ProgressNotificationState.Cancelled); diff --git a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs index db988a544d..585fd516bd 100644 --- a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs @@ -1,12 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; @@ -33,7 +34,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Tests.Online { [HeadlessTest] - public class TestSceneOnlinePlayBeatmapAvailabilityTracker : OsuTestScene + public partial class TestSceneOnlinePlayBeatmapAvailabilityTracker : OsuTestScene { private RulesetStore rulesets; private TestBeatmapManager beatmaps; @@ -76,7 +77,7 @@ namespace osu.Game.Tests.Online } }; - beatmaps.AllowImport = new TaskCompletionSource(); + beatmaps.AllowImport.Reset(); testBeatmapFile = TestResources.GetQuickTestBeatmapForImport(); @@ -124,13 +125,13 @@ namespace osu.Game.Tests.Online AddStep("start downloading", () => beatmapDownloader.Download(testBeatmapSet)); addAvailabilityCheckStep("state downloading 0%", () => BeatmapAvailability.Downloading(0.0f)); - AddStep("set progress 40%", () => ((TestDownloadRequest)beatmapDownloader.GetExistingDownload(testBeatmapSet)).SetProgress(0.4f)); + AddStep("set progress 40%", () => ((TestDownloadRequest)beatmapDownloader.GetExistingDownload(testBeatmapSet))!.SetProgress(0.4f)); addAvailabilityCheckStep("state downloading 40%", () => BeatmapAvailability.Downloading(0.4f)); - AddStep("finish download", () => ((TestDownloadRequest)beatmapDownloader.GetExistingDownload(testBeatmapSet)).TriggerSuccess(testBeatmapFile)); + AddStep("finish download", () => ((TestDownloadRequest)beatmapDownloader.GetExistingDownload(testBeatmapSet))!.TriggerSuccess(testBeatmapFile)); addAvailabilityCheckStep("state importing", BeatmapAvailability.Importing); - AddStep("allow importing", () => beatmaps.AllowImport.SetResult(true)); + AddStep("allow importing", () => beatmaps.AllowImport.Set()); AddUntilStep("wait for import", () => beatmaps.CurrentImport != null); AddUntilStep("ensure beatmap available", () => beatmaps.IsAvailableLocally(testBeatmapSet)); addAvailabilityCheckStep("state is locally available", BeatmapAvailability.LocallyAvailable); @@ -139,7 +140,7 @@ namespace osu.Game.Tests.Online [Test] public void TestTrackerRespectsSoftDeleting() { - AddStep("allow importing", () => beatmaps.AllowImport.SetResult(true)); + AddStep("allow importing", () => beatmaps.AllowImport.Set()); AddStep("import beatmap", () => beatmaps.Import(testBeatmapFile).WaitSafely()); addAvailabilityCheckStep("state locally available", BeatmapAvailability.LocallyAvailable); @@ -153,7 +154,7 @@ namespace osu.Game.Tests.Online [Test] public void TestTrackerRespectsChecksum() { - AddStep("allow importing", () => beatmaps.AllowImport.SetResult(true)); + AddStep("allow importing", () => beatmaps.AllowImport.Set()); AddStep("import beatmap", () => beatmaps.Import(testBeatmapFile).WaitSafely()); addAvailabilityCheckStep("initially locally available", BeatmapAvailability.LocallyAvailable); @@ -200,35 +201,37 @@ namespace osu.Game.Tests.Online private class TestBeatmapManager : BeatmapManager { - public TaskCompletionSource AllowImport = new TaskCompletionSource(); + public readonly ManualResetEventSlim AllowImport = new ManualResetEventSlim(); public Live CurrentImport { get; private set; } public TestBeatmapManager(Storage storage, RealmAccess realm, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore resources, GameHost host = null, WorkingBeatmap defaultBeatmap = null) - : base(storage, realm, rulesets, api, audioManager, resources, host, defaultBeatmap) + : base(storage, realm, api, audioManager, resources, host, defaultBeatmap) { } - protected override BeatmapModelManager CreateBeatmapModelManager(Storage storage, RealmAccess realm, RulesetStore rulesets, BeatmapOnlineLookupQueue onlineLookupQueue) + protected override BeatmapImporter CreateBeatmapImporter(Storage storage, RealmAccess realm) { - return new TestBeatmapModelManager(this, storage, realm, onlineLookupQueue); + return new TestBeatmapImporter(this, storage, realm); } - internal class TestBeatmapModelManager : BeatmapModelManager + internal class TestBeatmapImporter : BeatmapImporter { private readonly TestBeatmapManager testBeatmapManager; - public TestBeatmapModelManager(TestBeatmapManager testBeatmapManager, Storage storage, RealmAccess databaseAccess, BeatmapOnlineLookupQueue beatmapOnlineLookupQueue) - : base(databaseAccess, storage, beatmapOnlineLookupQueue) + public TestBeatmapImporter(TestBeatmapManager testBeatmapManager, Storage storage, RealmAccess databaseAccess) + : base(storage, databaseAccess) { this.testBeatmapManager = testBeatmapManager; } - public override Live Import(BeatmapSetInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) + public override Live ImportModel(BeatmapSetInfo item, ArchiveReader archive = null, ImportParameters parameters = default, CancellationToken cancellationToken = default) { - testBeatmapManager.AllowImport.Task.WaitSafely(); - return (testBeatmapManager.CurrentImport = base.Import(item, archive, lowPriority, cancellationToken)); + if (!testBeatmapManager.AllowImport.Wait(TimeSpan.FromSeconds(10), cancellationToken)) + throw new TimeoutException("Timeout waiting for import to be allowed."); + + return (testBeatmapManager.CurrentImport = base.ImportModel(item, archive, parameters, cancellationToken)); } } } @@ -244,7 +247,7 @@ namespace osu.Game.Tests.Online => new TestDownloadRequest(set); } - private class TestDownloadRequest : ArchiveDownloadRequest + internal class TestDownloadRequest : ArchiveDownloadRequest { public new void SetProgress(float progress) => base.SetProgress(progress); public new void TriggerSuccess(string filename) => base.TriggerSuccess(filename); diff --git a/osu.Game.Tests/Online/TestSubmittableScoreJsonSerialization.cs b/osu.Game.Tests/Online/TestSoloScoreInfoJsonSerialization.cs similarity index 78% rename from osu.Game.Tests/Online/TestSubmittableScoreJsonSerialization.cs rename to osu.Game.Tests/Online/TestSoloScoreInfoJsonSerialization.cs index 662660bce4..8ff0b67b5b 100644 --- a/osu.Game.Tests/Online/TestSubmittableScoreJsonSerialization.cs +++ b/osu.Game.Tests/Online/TestSoloScoreInfoJsonSerialization.cs @@ -1,10 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Newtonsoft.Json; using NUnit.Framework; using osu.Game.IO.Serialization; -using osu.Game.Online.Solo; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Tests.Resources; namespace osu.Game.Tests.Online @@ -13,12 +15,12 @@ namespace osu.Game.Tests.Online /// Basic testing to ensure our attribute-based naming is correctly working. /// [TestFixture] - public class TestSubmittableScoreJsonSerialization + public class TestSoloScoreInfoJsonSerialization { [Test] public void TestScoreSerialisationViaExtensionMethod() { - var score = new SubmittableScore(TestResources.CreateTestScoreInfo()); + var score = SoloScoreInfo.ForSubmission(TestResources.CreateTestScoreInfo()); string serialised = score.Serialize(); @@ -29,7 +31,7 @@ namespace osu.Game.Tests.Online [Test] public void TestScoreSerialisationWithoutSettings() { - var score = new SubmittableScore(TestResources.CreateTestScoreInfo()); + var score = SoloScoreInfo.ForSubmission(TestResources.CreateTestScoreInfo()); string serialised = JsonConvert.SerializeObject(score); diff --git a/osu.Game.Tests/OnlinePlay/PlaylistExtensionsTest.cs b/osu.Game.Tests/OnlinePlay/PlaylistExtensionsTest.cs index 9e7ea02101..73ed2bb868 100644 --- a/osu.Game.Tests/OnlinePlay/PlaylistExtensionsTest.cs +++ b/osu.Game.Tests/OnlinePlay/PlaylistExtensionsTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Game.Online.API.Requests.Responses; diff --git a/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs b/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs index 6851df3832..1d568a9dc2 100644 --- a/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs +++ b/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs @@ -1,33 +1,51 @@ // 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; -using osu.Framework.Bindables; +using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Framework.Timing; using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; +using osu.Game.Screens.Play; using osu.Game.Tests.Visual; namespace osu.Game.Tests.OnlinePlay { [HeadlessTest] - public class TestSceneCatchUpSyncManager : OsuTestScene + public partial class TestSceneCatchUpSyncManager : OsuTestScene { - private TestManualClock master; - private CatchUpSyncManager syncManager; + private GameplayClockContainer master; + private SpectatorSyncManager syncManager; - private TestSpectatorPlayerClock player1; - private TestSpectatorPlayerClock player2; + private Dictionary clocksById; + private SpectatorPlayerClock player1; + private SpectatorPlayerClock player2; [SetUp] public void Setup() { - syncManager = new CatchUpSyncManager(master = new TestManualClock()); - syncManager.AddPlayerClock(player1 = new TestSpectatorPlayerClock(1)); - syncManager.AddPlayerClock(player2 = new TestSpectatorPlayerClock(2)); + syncManager = new SpectatorSyncManager(master = new GameplayClockContainer(new TestManualClock())); + player1 = syncManager.CreateManagedClock(); + player2 = syncManager.CreateManagedClock(); - Schedule(() => Child = syncManager); + clocksById = new Dictionary + { + { player1, 1 }, + { player2, 2 } + }; + + Schedule(() => + { + Children = new Drawable[] + { + syncManager, + master + }; + }); } [Test] @@ -46,7 +64,7 @@ namespace osu.Game.Tests.OnlinePlay public void TestReadyPlayersStartWhenReadyForMaximumDelayTime() { setWaiting(() => player1, false); - AddWaitStep($"wait {CatchUpSyncManager.MAXIMUM_START_DELAY} milliseconds", (int)Math.Ceiling(CatchUpSyncManager.MAXIMUM_START_DELAY / TimePerAction)); + AddWaitStep($"wait {SpectatorSyncManager.MAXIMUM_START_DELAY} milliseconds", (int)Math.Ceiling(SpectatorSyncManager.MAXIMUM_START_DELAY / TimePerAction)); assertPlayerClockState(() => player1, true); assertPlayerClockState(() => player2, false); } @@ -56,7 +74,7 @@ namespace osu.Game.Tests.OnlinePlay { setAllWaiting(false); - setMasterTime(CatchUpSyncManager.SYNC_TARGET + 1); + setMasterTime(SpectatorSyncManager.SYNC_TARGET + 1); assertCatchingUp(() => player1, false); } @@ -65,7 +83,7 @@ namespace osu.Game.Tests.OnlinePlay { setAllWaiting(false); - setMasterTime(CatchUpSyncManager.MAX_SYNC_OFFSET + 1); + setMasterTime(SpectatorSyncManager.MAX_SYNC_OFFSET + 1); assertCatchingUp(() => player1, true); assertCatchingUp(() => player2, true); } @@ -75,8 +93,8 @@ namespace osu.Game.Tests.OnlinePlay { setAllWaiting(false); - setMasterTime(CatchUpSyncManager.MAX_SYNC_OFFSET + 1); - setPlayerClockTime(() => player1, CatchUpSyncManager.SYNC_TARGET + 1); + setMasterTime(SpectatorSyncManager.MAX_SYNC_OFFSET + 1); + setPlayerClockTime(() => player1, SpectatorSyncManager.SYNC_TARGET + 1); assertCatchingUp(() => player1, true); } @@ -85,8 +103,8 @@ namespace osu.Game.Tests.OnlinePlay { setAllWaiting(false); - setMasterTime(CatchUpSyncManager.MAX_SYNC_OFFSET + 2); - setPlayerClockTime(() => player1, CatchUpSyncManager.SYNC_TARGET); + setMasterTime(SpectatorSyncManager.MAX_SYNC_OFFSET + 2); + setPlayerClockTime(() => player1, SpectatorSyncManager.SYNC_TARGET); assertCatchingUp(() => player1, false); assertCatchingUp(() => player2, true); } @@ -96,7 +114,7 @@ namespace osu.Game.Tests.OnlinePlay { setAllWaiting(false); - setPlayerClockTime(() => player1, -CatchUpSyncManager.SYNC_TARGET); + setPlayerClockTime(() => player1, -SpectatorSyncManager.SYNC_TARGET); assertCatchingUp(() => player1, false); assertPlayerClockState(() => player1, true); } @@ -106,7 +124,7 @@ namespace osu.Game.Tests.OnlinePlay { setAllWaiting(false); - setPlayerClockTime(() => player1, -CatchUpSyncManager.SYNC_TARGET - 1); + setPlayerClockTime(() => player1, -SpectatorSyncManager.SYNC_TARGET - 1); // This is a silent catchup, where IsCatchingUp = false but IsRunning = false also. assertCatchingUp(() => player1, false); @@ -127,13 +145,13 @@ namespace osu.Game.Tests.OnlinePlay assertPlayerClockState(() => player1, false); } - private void setWaiting(Func playerClock, bool waiting) - => AddStep($"set player clock {playerClock().Id} waiting = {waiting}", () => playerClock().WaitingOnFrames.Value = waiting); + private void setWaiting(Func playerClock, bool waiting) + => AddStep($"set player clock {clocksById[playerClock()]} waiting = {waiting}", () => playerClock().WaitingOnFrames = waiting); private void setAllWaiting(bool waiting) => AddStep($"set all player clocks waiting = {waiting}", () => { - player1.WaitingOnFrames.Value = waiting; - player2.WaitingOnFrames.Value = waiting; + player1.WaitingOnFrames = waiting; + player2.WaitingOnFrames = waiting; }); private void setMasterTime(double time) @@ -142,51 +160,14 @@ namespace osu.Game.Tests.OnlinePlay /// /// clock.Time = master.Time - offsetFromMaster /// - private void setPlayerClockTime(Func playerClock, double offsetFromMaster) - => AddStep($"set player clock {playerClock().Id} = master - {offsetFromMaster}", () => playerClock().Seek(master.CurrentTime - offsetFromMaster)); + private void setPlayerClockTime(Func playerClock, double offsetFromMaster) + => AddStep($"set player clock {clocksById[playerClock()]} = master - {offsetFromMaster}", () => playerClock().Seek(master.CurrentTime - offsetFromMaster)); - private void assertCatchingUp(Func playerClock, bool catchingUp) => - AddAssert($"player clock {playerClock().Id} {(catchingUp ? "is" : "is not")} catching up", () => playerClock().IsCatchingUp == catchingUp); + private void assertCatchingUp(Func playerClock, bool catchingUp) => + AddAssert($"player clock {clocksById[playerClock()]} {(catchingUp ? "is" : "is not")} catching up", () => playerClock().IsCatchingUp == catchingUp); - private void assertPlayerClockState(Func playerClock, bool running) - => AddAssert($"player clock {playerClock().Id} {(running ? "is" : "is not")} running", () => playerClock().IsRunning == running); - - private class TestSpectatorPlayerClock : TestManualClock, ISpectatorPlayerClock - { - public Bindable WaitingOnFrames { get; } = new Bindable(true); - - public bool IsCatchingUp { get; set; } - - public IFrameBasedClock Source - { - set => throw new NotImplementedException(); - } - - public readonly int Id; - - public TestSpectatorPlayerClock(int id) - { - Id = id; - - WaitingOnFrames.BindValueChanged(waiting => - { - if (waiting.NewValue) - Stop(); - else - Start(); - }); - } - - public void ProcessFrame() - { - } - - public double ElapsedFrameTime => 0; - - public double FramesPerSecond => 0; - - public FrameTimeInfo TimeInfo => default; - } + private void assertPlayerClockState(Func playerClock, bool running) + => AddAssert($"player clock {clocksById[playerClock()]} {(running ? "is" : "is not")} running", () => playerClock().IsRunning == running); private class TestManualClock : ManualClock, IAdjustableClock { diff --git a/osu.Game.Tests/Resources/Archives/conflicting-filenames-beatmap.osz b/osu.Game.Tests/Resources/Archives/conflicting-filenames-beatmap.osz new file mode 100644 index 0000000000..c6b5f083ff Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/conflicting-filenames-beatmap.osz differ diff --git a/osu.Game.Tests/Resources/Archives/conflicting-filenames-skin.osk b/osu.Game.Tests/Resources/Archives/conflicting-filenames-skin.osk new file mode 100644 index 0000000000..73576f3e22 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/conflicting-filenames-skin.osk differ diff --git a/osu.Game.Tests/Resources/Archives/modified-argon-20221024.osk b/osu.Game.Tests/Resources/Archives/modified-argon-20221024.osk new file mode 100644 index 0000000000..28b6a001eb Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-argon-20221024.osk differ diff --git a/osu.Game.Tests/Resources/Archives/modified-argon-20230305.osk b/osu.Game.Tests/Resources/Archives/modified-argon-20230305.osk new file mode 100644 index 0000000000..c85f3b6352 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-argon-20230305.osk differ diff --git a/osu.Game.Tests/Resources/Archives/modified-classic-20220723.osk b/osu.Game.Tests/Resources/Archives/modified-classic-20220723.osk new file mode 100644 index 0000000000..8e7a1b42df Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-classic-20220723.osk differ diff --git a/osu.Game.Tests/Resources/Archives/modified-classic-20220801.osk b/osu.Game.Tests/Resources/Archives/modified-classic-20220801.osk new file mode 100644 index 0000000000..9236e1d77f Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-classic-20220801.osk differ diff --git a/osu.Game.Tests/Resources/Archives/modified-default-20220723.osk b/osu.Game.Tests/Resources/Archives/modified-default-20220723.osk new file mode 100644 index 0000000000..7547162165 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-default-20220723.osk differ diff --git a/osu.Game.Tests/Resources/Archives/modified-default-20220818.osk b/osu.Game.Tests/Resources/Archives/modified-default-20220818.osk new file mode 100644 index 0000000000..92215cbf86 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-default-20220818.osk differ diff --git a/osu.Game.Tests/Resources/Archives/modified-default-20221012.osk b/osu.Game.Tests/Resources/Archives/modified-default-20221012.osk new file mode 100644 index 0000000000..74ff4f31d5 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-default-20221012.osk differ diff --git a/osu.Game.Tests/Resources/Archives/modified-default-20221102.osk b/osu.Game.Tests/Resources/Archives/modified-default-20221102.osk new file mode 100644 index 0000000000..c1333acd13 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-default-20221102.osk differ diff --git a/osu.Game.Tests/Resources/Archives/modified-default-20221205.osk b/osu.Game.Tests/Resources/Archives/modified-default-20221205.osk new file mode 100644 index 0000000000..ae421fc323 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-default-20221205.osk differ diff --git a/osu.Game.Tests/Resources/Archives/modified-default-20230117.osk b/osu.Game.Tests/Resources/Archives/modified-default-20230117.osk new file mode 100644 index 0000000000..810bc4edf0 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-default-20230117.osk differ diff --git a/osu.Game.Tests/Resources/Replays/taiko-replay.osr b/osu.Game.Tests/Resources/Replays/taiko-replay.osr new file mode 100644 index 0000000000..986b3116ab Binary files /dev/null and b/osu.Game.Tests/Resources/Replays/taiko-replay.osr differ diff --git a/osu.Game.Tests/Resources/TestResources.cs b/osu.Game.Tests/Resources/TestResources.cs index 81b624f908..a2d81c0a75 100644 --- a/osu.Game.Tests/Resources/TestResources.cs +++ b/osu.Game.Tests/Resources/TestResources.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.IO; @@ -10,6 +12,7 @@ using System.Threading; using NUnit.Framework; using osu.Framework.Extensions; using osu.Framework.IO.Stores; +using osu.Framework.Logging; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; @@ -91,10 +94,12 @@ namespace osu.Game.Tests.Resources { // Create random metadata, then we can check if sorting works based on these Artist = "Some Artist " + RNG.Next(0, 9), - Title = $"Some Song (set id {setId}) {Guid.NewGuid()}", + Title = $"Some Song (set id {setId:000}) {Guid.NewGuid()}", Author = { Username = "Some Guy " + RNG.Next(0, 9) }, }; + Logger.Log($"🛠️ Generating beatmap set \"{metadata}\" for test consumption."); + var beatmapSet = new BeatmapSetInfo { OnlineID = setId, @@ -126,16 +131,20 @@ namespace osu.Game.Tests.Resources var rulesetInfo = getRuleset(); + string hash = Guid.NewGuid().ToString().ComputeMD5Hash(); + yield return new BeatmapInfo { OnlineID = beatmapId, DifficultyName = $"{version} {beatmapId} (length {TimeSpan.FromMilliseconds(length):m\\:ss}, bpm {bpm:0.#})", StarRating = diff, Length = length, + BeatmapSet = beatmapSet, BPM = bpm, - Hash = Guid.NewGuid().ToString().ComputeMD5Hash(), + Hash = hash, + MD5Hash = hash, Ruleset = rulesetInfo, - Metadata = metadata, + Metadata = metadata.DeepClone(), Difficulty = new BeatmapDifficulty { OverallDifficulty = diff, @@ -167,6 +176,7 @@ namespace osu.Game.Tests.Resources CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", }, BeatmapInfo = beatmap, + BeatmapHash = beatmap.Hash, Ruleset = beatmap.Ruleset, Mods = new Mod[] { new TestModHardRock(), new TestModDoubleTime() }, TotalScore = 2845370, @@ -188,8 +198,16 @@ namespace osu.Game.Tests.Resources [HitResult.LargeTickHit] = 100, [HitResult.LargeTickMiss] = 50, [HitResult.SmallBonus] = 10, - [HitResult.SmallBonus] = 50 + [HitResult.LargeBonus] = 50 }, + MaximumStatistics = new Dictionary + { + [HitResult.Perfect] = 971, + [HitResult.SmallTickHit] = 75, + [HitResult.LargeTickHit] = 150, + [HitResult.SmallBonus] = 10, + [HitResult.LargeBonus] = 50, + } }; private class TestModHardRock : ModHardRock diff --git a/osu.Game.Tests/Resources/animation-starts-before-alpha.osb b/osu.Game.Tests/Resources/animation-starts-before-alpha.osb new file mode 100644 index 0000000000..ceef204f3f --- /dev/null +++ b/osu.Game.Tests/Resources/animation-starts-before-alpha.osb @@ -0,0 +1,5 @@ +[Events] +//Storyboard Layer 0 (Background) +Animation,Background,Centre,"img.jpg",320,240,2,150,LoopForever + S,0,1000,1500,0.08 // animation should start playing from this point in time.. + F,0,2000,,0,1 // .. not this point in time 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/loop-containing-earlier-non-zero-fade.osb b/osu.Game.Tests/Resources/loop-containing-earlier-non-zero-fade.osb new file mode 100644 index 0000000000..2ff7f9f56f --- /dev/null +++ b/osu.Game.Tests/Resources/loop-containing-earlier-non-zero-fade.osb @@ -0,0 +1,14 @@ +osu file format v14 + +[Events] +//Storyboard Layer 0 (Background) +Sprite,Background,TopCentre,"img.jpg",320,240 + L,1000,1 + F,0,0,,1 // fade inside a loop with non-zero alpha and an earlier start time should be the true start time.. + F,0,2000,,0 // ..not a zero alpha fade with a later start time + +Sprite,Background,TopCentre,"img.jpg",320,240 + L,2000,1 + F,0,0,24,0 // fade inside a loop with zero alpha but later start time than the top-level zero alpha start time. + F,0,24,48,1 + F,0,1000,,1 // ..so this should be the true start time diff --git a/osu.Game.Tests/Resources/mania-last-object-not-latest.osu b/osu.Game.Tests/Resources/mania-last-object-not-latest.osu new file mode 100644 index 0000000000..51893383d8 --- /dev/null +++ b/osu.Game.Tests/Resources/mania-last-object-not-latest.osu @@ -0,0 +1,39 @@ +osu file format v14 + +[General] +SampleSet: Normal +StackLeniency: 0.7 +Mode: 3 + +[Difficulty] +HPDrainRate:3 +CircleSize:5 +OverallDifficulty:8 +ApproachRate:8 +SliderMultiplier:3.59999990463257 +SliderTickRate:2 + +[TimingPoints] +24,352.941176470588,4,1,1,100,1,0 +6376,-50,4,1,1,100,0,0 + +[HitObjects] +51,192,24,1,0,0:0:0:0: +153,192,200,1,0,0:0:0:0: +358,192,376,1,0,0:0:0:0: +460,192,553,1,0,0:0:0:0: +460,192,729,128,0,1435:0:0:0:0: +358,192,906,128,0,1612:0:0:0:0: +256,192,1082,128,0,1788:0:0:0:0: +153,192,1259,128,0,1965:0:0:0:0: +51,192,1435,128,0,2141:0:0:0:0: +51,192,2318,1,12,0:0:0:0: +153,192,2318,1,4,0:0:0:0: +256,192,2318,1,6,0:0:0:0: +358,192,2318,1,14,0:0:0:0: +460,192,2318,1,0,0:0:0:0: +51,192,2494,128,0,2582:0:0:0:0: +153,192,2494,128,14,2582:0:0:0:0: +256,192,2494,128,6,2582:0:0:0:0: +358,192,2494,128,4,2582:0:0:0:0: +460,192,2494,1,12,0:0:0:0:0: diff --git a/osu.Game.Tests/Resources/nan-control-points.osu b/osu.Game.Tests/Resources/nan-control-points.osu new file mode 100644 index 0000000000..dcaa705116 --- /dev/null +++ b/osu.Game.Tests/Resources/nan-control-points.osu @@ -0,0 +1,15 @@ +osu file format v14 + +[TimingPoints] + +// NaN bpm (should be rejected) +0,NaN,4,2,0,100,1,0 + +// 120 bpm +1000,500,4,2,0,100,1,0 + +// NaN slider velocity +2000,NaN,4,3,0,100,0,1 + +// 1.0x slider velocity +3000,-100,4,3,0,100,0,1 \ No newline at end of file diff --git a/osu.Game.Tests/Resources/omit-barline-control-points.osu b/osu.Game.Tests/Resources/omit-barline-control-points.osu new file mode 100644 index 0000000000..839a59215b --- /dev/null +++ b/osu.Game.Tests/Resources/omit-barline-control-points.osu @@ -0,0 +1,27 @@ +osu file format v14 + +[TimingPoints] + +// Uninherited: none, inherited: none +0,500,4,2,0,100,1,0 +0,-50,4,3,0,100,0,0 + +// Uninherited: omit, inherited: none +1000,500,4,2,0,100,1,8 +1000,-50,4,3,0,100,0,0 + +// Uninherited: none, inherited: omit (should be ignored, inheriting cannot omit) +2000,500,4,2,0,100,1,0 +2000,-50,4,3,0,100,0,8 + +// Inherited: none, uninherited: none +3000,-50,4,3,0,100,0,0 +3000,500,4,2,0,100,1,0 + +// Inherited: omit, uninherited: none (should be ignored, inheriting cannot omit) +4000,-50,4,3,0,100,0,8 +4000,500,4,2,0,100,1,0 + +// Inherited: none, uninherited: omit +5000,-50,4,3,0,100,0,0 +5000,500,4,2,0,100,1,8 diff --git a/osu.Game.Tests/Rulesets/Mods/ModTimeRampTest.cs b/osu.Game.Tests/Rulesets/Mods/ModTimeRampTest.cs index 51163efd6a..4601737558 100644 --- a/osu.Game.Tests/Rulesets/Mods/ModTimeRampTest.cs +++ b/osu.Game.Tests/Rulesets/Mods/ModTimeRampTest.cs @@ -17,8 +17,8 @@ namespace osu.Game.Tests.Rulesets.Mods private const double start_time = 1000; private const double duration = 9000; - private TrackVirtual track; - private OsuPlayfield playfield; + private TrackVirtual track = null!; + private OsuPlayfield playfield = null!; [SetUp] public void SetUp() diff --git a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs index 7ecd509193..826c610f56 100644 --- a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs +++ b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs @@ -1,9 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Rulesets.Difficulty; @@ -19,7 +23,7 @@ using osu.Game.Tests.Beatmaps; namespace osu.Game.Tests.Rulesets.Scoring { - public class ScoreProcessorTest + public partial class ScoreProcessorTest { private ScoreProcessor scoreProcessor; private IBeatmap beatmap; @@ -304,7 +308,7 @@ namespace osu.Game.Tests.Rulesets.Scoring HitObjects = { new TestHitObject(result) } }); - Assert.That(scoreProcessor.ComputeFinalScore(ScoringMode.Standardised, new ScoreInfo + Assert.That(scoreProcessor.ComputeScore(ScoringMode.Standardised, new ScoreInfo { Ruleset = new TestRuleset().RulesetInfo, MaxCombo = result.AffectsCombo() ? 1 : 0, @@ -312,15 +316,77 @@ namespace osu.Game.Tests.Rulesets.Scoring }), Is.EqualTo(expectedScore).Within(0.5d)); } +#pragma warning disable CS0618 + [Test] + public void TestLegacyComboIncrease() + { + Assert.That(HitResult.LegacyComboIncrease.IncreasesCombo(), Is.True); + Assert.That(HitResult.LegacyComboIncrease.BreaksCombo(), Is.False); + Assert.That(HitResult.LegacyComboIncrease.AffectsCombo(), Is.True); + Assert.That(HitResult.LegacyComboIncrease.AffectsAccuracy(), Is.False); + Assert.That(HitResult.LegacyComboIncrease.IsBasic(), Is.False); + Assert.That(HitResult.LegacyComboIncrease.IsTick(), Is.False); + Assert.That(HitResult.LegacyComboIncrease.IsBonus(), Is.False); + 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 + + [Test] + public void TestAccuracyWhenNearPerfect() + { + const int count_judgements = 1000; + const int count_misses = 1; + + double actual = new TestScoreProcessor().ComputeAccuracy(new ScoreInfo + { + Statistics = new Dictionary + { + { HitResult.Great, count_judgements - count_misses }, + { HitResult.Miss, count_misses } + } + }); + + const double expected = (count_judgements - count_misses) / (double)count_judgements; + + 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 System.NotImplementedException(); + public override IEnumerable GetModsFor(ModType type) => throw new NotImplementedException(); - public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => throw new System.NotImplementedException(); + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => throw new NotImplementedException(); - public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => throw new System.NotImplementedException(); + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => throw new NotImplementedException(); - public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => throw new System.NotImplementedException(); + public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => throw new NotImplementedException(); public override string Description => string.Empty; public override string ShortName => string.Empty; @@ -350,5 +416,33 @@ namespace osu.Game.Tests.Rulesets.Scoring this.maxResult = maxResult; } } + + private partial class TestScoreProcessor : ScoreProcessor + { + protected override double DefaultAccuracyPortion => 0.5; + protected override double DefaultComboPortion => 0.5; + + public TestScoreProcessor() + : base(new TestRuleset()) + { + } + + // ReSharper disable once MemberHidesStaticFromOuterClass + private class TestRuleset : Ruleset + { + protected override IEnumerable GetValidHitResults() => new[] { HitResult.Great }; + + 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; + } + } } } diff --git a/osu.Game.Tests/Rulesets/TestSceneBrokenRulesetHandling.cs b/osu.Game.Tests/Rulesets/TestSceneBrokenRulesetHandling.cs new file mode 100644 index 0000000000..c3a6b7c474 --- /dev/null +++ b/osu.Game.Tests/Rulesets/TestSceneBrokenRulesetHandling.cs @@ -0,0 +1,79 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.UI; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Rulesets +{ + [HeadlessTest] + public partial class TestSceneBrokenRulesetHandling : OsuTestScene + { + [Resolved] + private OsuGameBase gameBase { get; set; } = null!; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("reset ruleset", () => Ruleset.Value = new OsuRuleset().RulesetInfo); + } + + [Test] + public void TestNullModsReturnedByRulesetAreIgnored() + { + AddStep("set ruleset with null mods", () => Ruleset.Value = new TestRulesetWithNullMods().RulesetInfo); + AddAssert("no null mods in available mods", () => gameBase.AvailableMods.Value.SelectMany(kvp => kvp.Value).All(mod => mod != null)); + } + + [Test] + public void TestRulesetRevertedIfModsCannotBeRetrieved() + { + RulesetInfo ruleset = null!; + + AddStep("store current ruleset", () => ruleset = Ruleset.Value); + + AddStep("set API incompatible ruleset", () => Ruleset.Value = new TestAPIIncompatibleRuleset().RulesetInfo); + AddAssert("ruleset not changed", () => Ruleset.Value.Equals(ruleset)); + } + +#nullable disable // purposefully disabling nullability to simulate broken or unannotated API user code. + + private class TestRulesetWithNullMods : Ruleset + { + public override string ShortName => "nullmods"; + public override string Description => "nullmods"; + + public override IEnumerable GetModsFor(ModType type) => new Mod[] { 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 TestAPIIncompatibleRuleset : Ruleset + { + public override string ShortName => "incompatible"; + public override string Description => "incompatible"; + + // simulate API incompatibility by throwing similar exceptions. + public override IEnumerable GetModsFor(ModType type) => throw new MissingMethodException(); + + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => null; + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => null; + public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => null; + } + +#nullable enable + } +} diff --git a/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs index 20439ac969..6639b6dd68 100644 --- a/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs +++ b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.IO; @@ -13,11 +15,12 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Shaders; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; +using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.UI; @@ -26,7 +29,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Tests.Rulesets { [HeadlessTest] - public class TestSceneDrawableRulesetDependencies : OsuTestScene + public partial class TestSceneDrawableRulesetDependencies : OsuTestScene { [Test] public void TestDisposalDoesNotDisposeParentStores() @@ -60,7 +63,7 @@ namespace osu.Game.Tests.Rulesets AddAssert("parent shader manager not disposed", () => !shaderManager.IsDisposed); } - private class DrawableWithDependencies : CompositeDrawable + private partial class DrawableWithDependencies : CompositeDrawable { public TestTextureStore ParentTextureStore { get; private set; } public TestSampleStore ParentSampleStore { get; private set; } @@ -75,9 +78,9 @@ namespace osu.Game.Tests.Rulesets { var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - dependencies.CacheAs(ParentTextureStore = new TestTextureStore()); + dependencies.CacheAs(ParentTextureStore = new TestTextureStore(parent.Get().Renderer)); dependencies.CacheAs(ParentSampleStore = new TestSampleStore()); - dependencies.CacheAs(ParentShaderManager = new TestShaderManager()); + dependencies.CacheAs(ParentShaderManager = new TestShaderManager(parent.Get().Renderer, parent.Get())); return new DrawableRulesetDependencies(new OsuRuleset(), dependencies); } @@ -93,6 +96,11 @@ namespace osu.Game.Tests.Rulesets private class TestTextureStore : TextureStore { + public TestTextureStore(IRenderer renderer) + : base(renderer) + { + } + public override Texture Get(string name, WrapMode wrapModeS, WrapMode wrapModeT) => null; public bool IsDisposed { get; private set; } @@ -142,16 +150,21 @@ namespace osu.Game.Tests.Rulesets public IBindable AggregateTempo => throw new NotImplementedException(); public int PlaybackConcurrency { get; set; } + + public void AddExtension(string extension) => throw new NotImplementedException(); } private class TestShaderManager : ShaderManager { - public TestShaderManager() - : base(new ResourceStore()) + private readonly ShaderManager parentManager; + + public TestShaderManager(IRenderer renderer, ShaderManager parentManager) + : base(renderer, new ResourceStore()) { + this.parentManager = parentManager; } - public override byte[] LoadRaw(string name) => null; + public override byte[] GetRawData(string fileName) => parentManager.GetRawData(fileName); public bool IsDisposed { get; private set; } diff --git a/osu.Game.Tests/Rulesets/TestSceneRulesetSkinProvidingContainer.cs b/osu.Game.Tests/Rulesets/TestSceneRulesetSkinProvidingContainer.cs index 28ad7ed6a7..11f3fe660d 100644 --- a/osu.Game.Tests/Rulesets/TestSceneRulesetSkinProvidingContainer.cs +++ b/osu.Game.Tests/Rulesets/TestSceneRulesetSkinProvidingContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Framework.Allocation; @@ -8,7 +10,6 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.Testing; using osu.Game.Audio; @@ -20,7 +21,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Tests.Rulesets { [HeadlessTest] - public class TestSceneRulesetSkinProvidingContainer : OsuTestScene + public partial class TestSceneRulesetSkinProvidingContainer : OsuTestScene { private SkinRequester requester; @@ -63,7 +64,7 @@ namespace osu.Game.Tests.Rulesets }); } - private class SkinRequester : Drawable, ISkin + private partial class SkinRequester : Drawable, ISkin { private ISkinSource skin; @@ -77,7 +78,7 @@ namespace osu.Game.Tests.Rulesets OnLoadAsync?.Invoke(); } - public Drawable GetDrawableComponent(ISkinComponent component) => skin.GetDrawableComponent(component); + public Drawable GetDrawableComponent(ISkinComponentLookup lookup) => skin.GetDrawableComponent(lookup); public Texture GetTexture(string componentName, WrapMode wrapModeS = default, WrapMode wrapModeT = default) => skin.GetTexture(componentName); diff --git a/osu.Game.Tests/Scores/IO/ImportScoreTest.cs b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs index 8de9f0a292..892ceea185 100644 --- a/osu.Game.Tests/Scores/IO/ImportScoreTest.cs +++ b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.IO; diff --git a/osu.Game.Tests/Scores/IO/TestScoreEquality.cs b/osu.Game.Tests/Scores/IO/TestScoreEquality.cs index f898774ce6..d44fd786d7 100644 --- a/osu.Game.Tests/Scores/IO/TestScoreEquality.cs +++ b/osu.Game.Tests/Scores/IO/TestScoreEquality.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Game.Scoring; diff --git a/osu.Game.Tests/ScrollAlgorithms/ConstantScrollTest.cs b/osu.Game.Tests/ScrollAlgorithms/ConstantScrollTest.cs index a6e8622b6f..0994803d83 100644 --- a/osu.Game.Tests/ScrollAlgorithms/ConstantScrollTest.cs +++ b/osu.Game.Tests/ScrollAlgorithms/ConstantScrollTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Rulesets.UI.Scrolling.Algorithms; diff --git a/osu.Game.Tests/ScrollAlgorithms/OverlappingScrollTest.cs b/osu.Game.Tests/ScrollAlgorithms/OverlappingScrollTest.cs index 1429d22c1a..c1f647cb07 100644 --- a/osu.Game.Tests/ScrollAlgorithms/OverlappingScrollTest.cs +++ b/osu.Game.Tests/ScrollAlgorithms/OverlappingScrollTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Lists; using osu.Game.Rulesets.Timing; diff --git a/osu.Game.Tests/ScrollAlgorithms/SequentialScrollTest.cs b/osu.Game.Tests/ScrollAlgorithms/SequentialScrollTest.cs index bd578dcbc4..ca6ac63619 100644 --- a/osu.Game.Tests/ScrollAlgorithms/SequentialScrollTest.cs +++ b/osu.Game.Tests/ScrollAlgorithms/SequentialScrollTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Lists; using osu.Game.Rulesets.Timing; diff --git a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs index dde8715764..81ebc59729 100644 --- a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs +++ b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.IO; using System.Linq; @@ -13,7 +15,6 @@ using osu.Framework.Platform; using osu.Game.Database; using osu.Game.Extensions; using osu.Game.IO; -using osu.Game.IO.Archives; using osu.Game.Skinning; using SharpCompress.Archives.Zip; @@ -26,7 +27,7 @@ namespace osu.Game.Tests.Skins.IO [Test] public Task TestSingleImportDifferentFilename() => runSkinTest(async osu => { - var import1 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni("test skin", "skinner"), "skin.osk")); + var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("test skin", "skinner"), "skin.osk")); // When the import filename doesn't match, it should be appended (and update the skin.ini). assertCorrectMetadata(import1, "test skin [skin]", "skinner", osu); @@ -35,7 +36,7 @@ namespace osu.Game.Tests.Skins.IO [Test] public Task TestSingleImportWeirdIniFileCase() => runSkinTest(async osu => { - var import1 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni("test skin", "skinner", iniFilename: "Skin.InI"), "skin.osk")); + var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("test skin", "skinner", iniFilename: "Skin.InI"), "skin.osk")); // When the import filename doesn't match, it should be appended (and update the skin.ini). assertCorrectMetadata(import1, "test skin [skin]", "skinner", osu); @@ -44,7 +45,7 @@ namespace osu.Game.Tests.Skins.IO [Test] public Task TestSingleImportMissingSectionHeader() => runSkinTest(async osu => { - var import1 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni("test skin", "skinner", includeSectionHeader: false), "skin.osk")); + var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("test skin", "skinner", includeSectionHeader: false), "skin.osk")); // When the import filename doesn't match, it should be appended (and update the skin.ini). assertCorrectMetadata(import1, "test skin [skin]", "skinner", osu); @@ -53,7 +54,7 @@ namespace osu.Game.Tests.Skins.IO [Test] public Task TestSingleImportMatchingFilename() => runSkinTest(async osu => { - var import1 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni("test skin", "skinner"), "test skin.osk")); + var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("test skin", "skinner"), "test skin.osk")); // When the import filename matches it shouldn't be appended. assertCorrectMetadata(import1, "test skin", "skinner", osu); @@ -62,7 +63,7 @@ namespace osu.Game.Tests.Skins.IO [Test] public Task TestSingleImportNoIniFile() => runSkinTest(async osu => { - var import1 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithNonIniFile(), "test skin.osk")); + var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithNonIniFile(), "test skin.osk")); // When the import filename matches it shouldn't be appended. assertCorrectMetadata(import1, "test skin", "Unknown", osu); @@ -71,7 +72,7 @@ namespace osu.Game.Tests.Skins.IO [Test] public Task TestEmptyImportImportsWithFilename() => runSkinTest(async osu => { - var import1 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createEmptyOsk(), "test skin.osk")); + var import1 = await loadSkinIntoOsu(osu, new ImportTask(createEmptyOsk(), "test skin.osk")); // When the import filename matches it shouldn't be appended. assertCorrectMetadata(import1, "test skin", "Unknown", osu); @@ -82,20 +83,20 @@ namespace osu.Game.Tests.Skins.IO #region Cases where imports should match existing [Test] - public Task TestImportTwiceWithSameMetadataAndFilename() => runSkinTest(async osu => + public Task TestImportTwiceWithSameMetadataAndFilename([Values] bool batchImport) => runSkinTest(async osu => { - var import1 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni("test skin", "skinner"), "skin.osk")); - var import2 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni("test skin", "skinner"), "skin.osk")); + var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("test skin", "skinner"), "skin.osk"), batchImport); + var import2 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("test skin", "skinner"), "skin.osk"), batchImport); assertImportedOnce(import1, import2); }); [Test] - public Task TestImportTwiceWithNoMetadataSameDownloadFilename() => runSkinTest(async osu => + public Task TestImportTwiceWithNoMetadataSameDownloadFilename([Values] bool batchImport) => runSkinTest(async osu => { // if a user downloads two skins that do have skin.ini files but don't have any creator metadata in the skin.ini, they should both import separately just for safety. - var import1 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni(string.Empty, string.Empty), "download.osk")); - var import2 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni(string.Empty, string.Empty), "download.osk")); + var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni(string.Empty, string.Empty), "download.osk"), batchImport); + var import2 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni(string.Empty, string.Empty), "download.osk"), batchImport); assertImportedOnce(import1, import2); }); @@ -103,10 +104,10 @@ namespace osu.Game.Tests.Skins.IO [Test] public Task TestImportUpperCasedOskArchive() => runSkinTest(async osu => { - var import1 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni("name 1", "author 1"), "name 1.OsK")); + var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 1", "author 1"), "name 1.OsK")); assertCorrectMetadata(import1, "name 1", "author 1", osu); - var import2 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni("name 1", "author 1"), "name 1.oSK")); + var import2 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 1", "author 1"), "name 1.oSK")); assertImportedOnce(import1, import2); }); @@ -116,7 +117,7 @@ namespace osu.Game.Tests.Skins.IO { MemoryStream exportStream = new MemoryStream(); - var import1 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni("name 1", "author 1"), "custom.osk")); + 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 => @@ -126,17 +127,36 @@ namespace osu.Game.Tests.Skins.IO string exportFilename = import1.GetDisplayString(); - var import2 = await loadSkinIntoOsu(osu, new ZipArchiveReader(exportStream, $"{exportFilename}.osk")); + var import2 = await loadSkinIntoOsu(osu, new ImportTask(exportStream, $"{exportFilename}.osk")); assertCorrectMetadata(import2, "name 1 [custom]", "author 1", osu); assertImportedOnce(import1, import2); }); [Test] - public Task TestSameMetadataNameSameFolderName() => runSkinTest(async osu => + public Task TestImportExportedNonAsciiSkinFilename() => runSkinTest(async osu => { - var import1 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni("name 1", "author 1"), "my custom skin 1")); - var import2 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni("name 1", "author 1"), "my custom skin 1")); + MemoryStream exportStream = new MemoryStream(); + + 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); + }); + + string exportFilename = import1.GetDisplayString().GetValidFilename(); + + var import2 = await loadSkinIntoOsu(osu, new ImportTask(exportStream, $"{exportFilename}.osk")); + assertCorrectMetadata(import2, "name 『1』 [custom]", "author 1", osu); + }); + + [Test] + public Task TestSameMetadataNameSameFolderName([Values] bool batchImport) => runSkinTest(async osu => + { + var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 1", "author 1"), "my custom skin 1"), batchImport); + var import2 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 1", "author 1"), "my custom skin 1"), batchImport); assertImportedOnce(import1, import2); assertCorrectMetadata(import1, "name 1 [my custom skin 1]", "author 1", osu); @@ -149,8 +169,8 @@ namespace osu.Game.Tests.Skins.IO [Test] public Task TestImportTwiceWithSameMetadataButDifferentFilename() => runSkinTest(async osu => { - var import1 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni("test skin", "skinner"), "skin.osk")); - var import2 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni("test skin", "skinner"), "skin2.osk")); + var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("test skin", "skinner"), "skin.osk")); + var import2 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("test skin", "skinner"), "skin2.osk")); assertImportedBoth(import1, import2); }); @@ -159,8 +179,8 @@ namespace osu.Game.Tests.Skins.IO public Task TestImportTwiceWithNoMetadataDifferentDownloadFilename() => runSkinTest(async osu => { // if a user downloads two skins that do have skin.ini files but don't have any creator metadata in the skin.ini, they should both import separately just for safety. - var import1 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni(string.Empty, string.Empty), "download.osk")); - var import2 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni(string.Empty, string.Empty), "download2.osk")); + var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni(string.Empty, string.Empty), "download.osk")); + var import2 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni(string.Empty, string.Empty), "download2.osk")); assertImportedBoth(import1, import2); }); @@ -168,8 +188,8 @@ namespace osu.Game.Tests.Skins.IO [Test] public Task TestImportTwiceWithSameFilenameDifferentMetadata() => runSkinTest(async osu => { - var import1 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni("test skin v2", "skinner"), "skin.osk")); - var import2 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni("test skin v2.1", "skinner"), "skin.osk")); + var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("test skin v2", "skinner"), "skin.osk")); + var import2 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("test skin v2.1", "skinner"), "skin.osk")); assertImportedBoth(import1, import2); assertCorrectMetadata(import1, "test skin v2 [skin]", "skinner", osu); @@ -179,8 +199,8 @@ namespace osu.Game.Tests.Skins.IO [Test] public Task TestSameMetadataNameDifferentFolderName() => runSkinTest(async osu => { - var import1 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni("name 1", "author 1"), "my custom skin 1")); - var import2 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni("name 1", "author 1"), "my custom skin 2")); + var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 1", "author 1"), "my custom skin 1")); + var import2 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 1", "author 1"), "my custom skin 2")); assertImportedBoth(import1, import2); assertCorrectMetadata(import1, "name 1 [my custom skin 1]", "author 1", osu); @@ -201,7 +221,7 @@ namespace osu.Game.Tests.Skins.IO skinManager.CurrentSkinInfo.Value.PerformRead(s => { Assert.IsFalse(s.Protected); - Assert.AreEqual(typeof(DefaultSkin), s.CreateInstance(skinManager).GetType()); + Assert.AreEqual(typeof(ArgonSkin), s.CreateInstance(skinManager).GetType()); new LegacySkinExporter(osu.Dependencies.Get()).ExportModelTo(s, exportStream); @@ -214,7 +234,7 @@ namespace osu.Game.Tests.Skins.IO { Assert.IsFalse(s.Protected); Assert.AreNotEqual(originalSkinId, s.ID); - Assert.AreEqual(typeof(DefaultSkin), s.CreateInstance(skinManager).GetType()); + Assert.AreEqual(typeof(ArgonSkin), s.CreateInstance(skinManager).GetType()); }); return Task.CompletedTask; @@ -225,7 +245,7 @@ namespace osu.Game.Tests.Skins.IO { var skinManager = osu.Dependencies.Get(); - skinManager.CurrentSkinInfo.Value = skinManager.DefaultLegacySkin.SkinInfo; + skinManager.CurrentSkinInfo.Value = skinManager.DefaultClassicSkin.SkinInfo; skinManager.EnsureMutableSkin(); @@ -356,10 +376,10 @@ namespace osu.Game.Tests.Skins.IO } } - private async Task> loadSkinIntoOsu(OsuGameBase osu, ArchiveReader archive = null) + private async Task> loadSkinIntoOsu(OsuGameBase osu, ImportTask import, bool batchImport = false) { var skinManager = osu.Dependencies.Get(); - return await skinManager.Import(archive); + return await skinManager.Import(import, new ImportParameters { Batch = batchImport }); } } } diff --git a/osu.Game.Tests/Skins/LegacyManiaSkinDecoderTest.cs b/osu.Game.Tests/Skins/LegacyManiaSkinDecoderTest.cs index e811979aed..6da335a9b7 100644 --- a/osu.Game.Tests/Skins/LegacyManiaSkinDecoderTest.cs +++ b/osu.Game.Tests/Skins/LegacyManiaSkinDecoderTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.IO; using osu.Game.Skinning; diff --git a/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs b/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs index cfc140ce39..9466fdf888 100644 --- a/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs +++ b/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using NUnit.Framework; using osu.Game.IO; @@ -27,10 +29,10 @@ namespace osu.Game.Tests.Skins new Color4(142, 199, 255, 255), new Color4(255, 128, 128, 255), new Color4(128, 255, 255, 255), - new Color4(100, 100, 100, 100), + new Color4(100, 100, 100, 255), // alpha is specified as 100, but should be ignored. }; - Assert.AreEqual(expectedColors.Count, comboColors.Count); + Assert.AreEqual(expectedColors.Count, comboColors?.Count); for (int i = 0; i < expectedColors.Count; i++) Assert.AreEqual(expectedColors[i], comboColors[i]); } @@ -47,7 +49,7 @@ namespace osu.Game.Tests.Skins var comboColors = decoder.Decode(stream).ComboColours; var expectedColors = SkinConfiguration.DefaultComboColours; - Assert.AreEqual(expectedColors.Count, comboColors.Count); + Assert.AreEqual(expectedColors.Count, comboColors?.Count); for (int i = 0; i < expectedColors.Count; i++) Assert.AreEqual(expectedColors[i], comboColors[i]); } diff --git a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs new file mode 100644 index 0000000000..bd8088cfb6 --- /dev/null +++ b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs @@ -0,0 +1,141 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; +using osu.Game.Audio; +using osu.Game.IO; +using osu.Game.IO.Archives; +using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Play.HUD.HitErrorMeters; +using osu.Game.Skinning; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Skins +{ + /// + /// Test that the main components (which are serialised based on namespace/class name) + /// remain compatible with any changes. + /// + /// + /// If this test breaks, check any naming or class structure changes. + /// Migration rules may need to be added to . + /// + [TestFixture] + public class SkinDeserialisationTest + { + private static readonly string[] available_skins = + { + // Covers song progress before namespace changes, and most other components. + "Archives/modified-default-20220723.osk", + "Archives/modified-classic-20220723.osk", + // Covers legacy song progress, UR counter, colour hit error metre. + "Archives/modified-classic-20220801.osk", + // Covers clicks/s counter + "Archives/modified-default-20220818.osk", + // Covers longest combo counter + "Archives/modified-default-20221012.osk", + // Covers Argon variant of song progress bar + "Archives/modified-argon-20221024.osk", + // Covers TextElement and BeatmapInfoDrawable + "Archives/modified-default-20221102.osk", + // Covers BPM counter. + "Archives/modified-default-20221205.osk", + // Covers judgement counter. + "Archives/modified-default-20230117.osk", + // Covers player avatar and flag. + "Archives/modified-argon-20230305.osk", + }; + + /// + /// If this test fails, new test resources should be added to include new components. + /// + [Test] + public void TestSkinnableComponentsCoveredByDeserialisationTests() + { + HashSet instantiatedTypes = new HashSet(); + + foreach (string oskFile in available_skins) + { + using (var stream = TestResources.OpenResource(oskFile)) + using (var storage = new ZipArchiveReader(stream)) + { + var skin = new TestSkin(new SkinInfo(), null, storage); + + foreach (var target in skin.LayoutInfos) + { + foreach (var info in target.Value.AllDrawables) + instantiatedTypes.Add(info.Type); + } + } + } + + var editableTypes = SerialisedDrawableInfo.GetAllAvailableDrawables().Where(t => (Activator.CreateInstance(t) as ISerialisableDrawable)?.IsEditable == true); + + Assert.That(instantiatedTypes, Is.EquivalentTo(editableTypes)); + } + + [Test] + public void TestDeserialiseModifiedDefault() + { + using (var stream = TestResources.OpenResource("Archives/modified-default-20220723.osk")) + using (var storage = new ZipArchiveReader(stream)) + { + var skin = new TestSkin(new SkinInfo(), null, storage); + + Assert.That(skin.LayoutInfos, Has.Count.EqualTo(2)); + Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.ToArray(), Has.Length.EqualTo(9)); + } + } + + [Test] + public void TestDeserialiseModifiedClassic() + { + using (var stream = TestResources.OpenResource("Archives/modified-classic-20220723.osk")) + using (var storage = new ZipArchiveReader(stream)) + { + var skin = new TestSkin(new SkinInfo(), null, storage); + + Assert.That(skin.LayoutInfos, Has.Count.EqualTo(2)); + Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.ToArray(), Has.Length.EqualTo(6)); + Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.SongSelect].AllDrawables.ToArray(), Has.Length.EqualTo(1)); + + var skinnableInfo = skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.SongSelect].AllDrawables.First(); + + Assert.That(skinnableInfo.Type, Is.EqualTo(typeof(SkinnableSprite))); + Assert.That(skinnableInfo.Settings.First().Key, Is.EqualTo("sprite_name")); + Assert.That(skinnableInfo.Settings.First().Value, Is.EqualTo("ppy_logo-2.png")); + } + + using (var stream = TestResources.OpenResource("Archives/modified-classic-20220801.osk")) + using (var storage = new ZipArchiveReader(stream)) + { + var skin = new TestSkin(new SkinInfo(), null, storage); + Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.ToArray(), Has.Length.EqualTo(8)); + Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(UnstableRateCounter))); + Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(ColourHitErrorMeter))); + Assert.That(skin.LayoutInfos[SkinComponentsContainerLookup.TargetArea.MainHUDComponents].AllDrawables.Select(i => i.Type), Contains.Item(typeof(LegacySongProgress))); + } + } + + private class TestSkin : Skin + { + public TestSkin(SkinInfo skin, IStorageResourceProvider? resources, IResourceStore? storage = null, string configurationFilename = "skin.ini") + : base(skin, resources, storage, configurationFilename) + { + } + + public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); + + public override IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); + + public override ISample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + } + } +} diff --git a/osu.Game.Tests/Skins/TestSceneBeatmapSkinLookupDisables.cs b/osu.Game.Tests/Skins/TestSceneBeatmapSkinLookupDisables.cs index 0c1981b35d..0bbf4406fb 100644 --- a/osu.Game.Tests/Skins/TestSceneBeatmapSkinLookupDisables.cs +++ b/osu.Game.Tests/Skins/TestSceneBeatmapSkinLookupDisables.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Framework.Allocation; @@ -8,7 +10,6 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.Testing; using osu.Game.Audio; @@ -22,7 +23,7 @@ namespace osu.Game.Tests.Skins { [TestFixture] [HeadlessTest] - public class TestSceneBeatmapSkinLookupDisables : OsuTestScene + public partial class TestSceneBeatmapSkinLookupDisables : OsuTestScene { private UserSkinSource userSource; private BeatmapSkinSource beatmapSource; @@ -47,7 +48,7 @@ namespace osu.Game.Tests.Skins string expected = allowBeatmapLookups ? "beatmap" : "user"; - AddAssert($"Check lookup is from {expected}", () => requester.GetDrawableComponent(new TestSkinComponent())?.Name == expected); + AddAssert($"Check lookup is from {expected}", () => requester.GetDrawableComponent(new TestSkinComponentLookup())?.Name == expected); } [TestCase(false)] @@ -56,9 +57,9 @@ namespace osu.Game.Tests.Skins { AddStep($"Set beatmap skin enabled to {allowBeatmapLookups}", () => config.SetValue(OsuSetting.BeatmapSkins, allowBeatmapLookups)); - ISkin expected() => allowBeatmapLookups ? (ISkin)beatmapSource : userSource; + ISkin expected() => allowBeatmapLookups ? beatmapSource : userSource; - AddAssert("Check lookup is from correct source", () => requester.FindProvider(s => s.GetDrawableComponent(new TestSkinComponent()) != null) == expected()); + AddAssert("Check lookup is from correct source", () => requester.FindProvider(s => s.GetDrawableComponent(new TestSkinComponentLookup()) != null) == expected()); } public class UserSkinSource : LegacySkin @@ -68,7 +69,7 @@ namespace osu.Game.Tests.Skins { } - public override Drawable GetDrawableComponent(ISkinComponent component) + public override Drawable GetDrawableComponent(ISkinComponentLookup lookup) { return new Container { Name = "user" }; } @@ -81,13 +82,13 @@ namespace osu.Game.Tests.Skins { } - public override Drawable GetDrawableComponent(ISkinComponent component) + public override Drawable GetDrawableComponent(ISkinComponentLookup lookup) { return new Container { Name = "beatmap" }; } } - public class SkinRequester : Drawable, ISkin + public partial class SkinRequester : Drawable, ISkin { private ISkinSource skin; @@ -97,7 +98,7 @@ namespace osu.Game.Tests.Skins this.skin = skin; } - public Drawable GetDrawableComponent(ISkinComponent component) => skin.GetDrawableComponent(component); + public Drawable GetDrawableComponent(ISkinComponentLookup lookup) => skin.GetDrawableComponent(lookup); public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => skin.GetTexture(componentName, wrapModeS, wrapModeT); @@ -108,7 +109,7 @@ namespace osu.Game.Tests.Skins public ISkin FindProvider(Func lookupFunction) => skin.FindProvider(lookupFunction); } - private class TestSkinComponent : ISkinComponent + private class TestSkinComponentLookup : ISkinComponentLookup { public string LookupName => string.Empty; } diff --git a/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs b/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs index fe0423dcfc..d9212386c3 100644 --- a/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs +++ b/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs @@ -5,39 +5,50 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio.Track; using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Testing; using osu.Game.Audio; using osu.Game.Beatmaps; -using osu.Game.IO.Archives; +using osu.Game.Database; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual; namespace osu.Game.Tests.Skins { [HeadlessTest] - public class TestSceneBeatmapSkinResources : OsuTestScene + public partial class TestSceneBeatmapSkinResources : OsuTestScene { [Resolved] - private BeatmapManager beatmaps { get; set; } + private BeatmapManager beatmaps { get; set; } = null!; - private IWorkingBeatmap beatmap; - - [BackgroundDependencyLoader] - private void load() + [Test] + public void TestRetrieveOggAudio() { - var imported = beatmaps.Import(new ZipArchiveReader(TestResources.OpenResource("Archives/ogg-beatmap.osz"))).GetResultSafely(); + IWorkingBeatmap beatmap = null!; - imported?.PerformRead(s => + AddStep("import beatmap", () => beatmap = importBeatmapFromArchives(@"ogg-beatmap.osz")); + AddAssert("sample is non-null", () => beatmap.Skin.GetSample(new SampleInfo(@"sample")) != null); + AddAssert("track is non-null", () => { - beatmap = beatmaps.GetWorkingBeatmap(s.Beatmaps[0]); - beatmap.LoadTrack(); + using (var track = beatmap.LoadTrack()) + return track is not TrackVirtual; }); } [Test] - public void TestRetrieveOggSample() => AddAssert("sample is non-null", () => beatmap.Skin.GetSample(new SampleInfo("sample")) != null); + public void TestRetrievalWithConflictingFilenames() + { + IWorkingBeatmap beatmap = null!; - [Test] - public void TestRetrieveOggTrack() => AddAssert("track is non-null", () => !(beatmap.Track is TrackVirtual)); + AddStep("import beatmap", () => beatmap = importBeatmapFromArchives(@"conflicting-filenames-beatmap.osz")); + AddAssert("texture is non-null", () => beatmap.Skin.GetTexture(@"spinner-osu") != null); + AddAssert("sample is non-null", () => beatmap.Skin.GetSample(new SampleInfo(@"spinner-osu")) != null); + } + + private IWorkingBeatmap importBeatmapFromArchives(string filename) + { + var imported = beatmaps.Import(new ImportTask(TestResources.OpenResource($@"Archives/{filename}"), filename)).GetResultSafely(); + return imported.AsNonNull().PerformRead(s => beatmaps.GetWorkingBeatmap(s.Beatmaps[0])); + } } } diff --git a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs index d68398236a..816834989c 100644 --- a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs +++ b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.IO; @@ -11,7 +13,6 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.Testing; using osu.Game.Audio; @@ -26,7 +27,7 @@ namespace osu.Game.Tests.Skins { [TestFixture] [HeadlessTest] - public class TestSceneSkinConfigurationLookup : OsuTestScene + public partial class TestSceneSkinConfigurationLookup : OsuTestScene { private UserSkinSource userSource; private BeatmapSkinSource beatmapSource; @@ -145,7 +146,8 @@ namespace osu.Game.Tests.Skins AddStep("Disallow default colours fallback in beatmap skin", () => beatmapSource.Configuration.AllowDefaultComboColoursFallback = false); AddAssert("Check retrieved combo colours from user skin", () => - requester.GetConfig>(GlobalSkinColours.ComboColours)?.Value?.SequenceEqual(userSource.Configuration.ComboColours) ?? false); + userSource.Configuration.ComboColours != null && + (requester.GetConfig>(GlobalSkinColours.ComboColours)?.Value?.SequenceEqual(userSource.Configuration.ComboColours) ?? false)); } [Test] @@ -209,7 +211,7 @@ namespace osu.Game.Tests.Skins } } - public class SkinRequester : Drawable, ISkin + public partial class SkinRequester : Drawable, ISkin { private ISkinSource skin; @@ -219,7 +221,7 @@ namespace osu.Game.Tests.Skins this.skin = skin; } - public Drawable GetDrawableComponent(ISkinComponent component) => skin.GetDrawableComponent(component); + public Drawable GetDrawableComponent(ISkinComponentLookup lookup) => skin.GetDrawableComponent(lookup); public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => skin.GetTexture(componentName, wrapModeS, wrapModeT); diff --git a/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs b/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs index ffb3d41d18..df314b56a9 100644 --- a/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs +++ b/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs @@ -1,13 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Textures; using osu.Framework.Testing; using osu.Game.Audio; @@ -17,8 +20,11 @@ using osu.Game.Tests.Visual; namespace osu.Game.Tests.Skins { [HeadlessTest] - public class TestSceneSkinProvidingContainer : OsuTestScene + public partial class TestSceneSkinProvidingContainer : OsuTestScene { + [Resolved] + private IRenderer renderer { get; set; } + /// /// Ensures that the first inserted skin after resetting (via source change) /// is always prioritised over others when providing the same resource. @@ -33,7 +39,7 @@ namespace osu.Game.Tests.Skins { var sources = new List(); for (int i = 0; i < 10; i++) - sources.Add(new TestSkin()); + sources.Add(new TestSkin(renderer)); mostPrioritisedSource = sources.First(); @@ -53,7 +59,7 @@ namespace osu.Game.Tests.Skins }); } - private class TestSkinProvidingContainer : SkinProvidingContainer + private partial class TestSkinProvidingContainer : SkinProvidingContainer { private readonly IEnumerable sources; @@ -74,12 +80,19 @@ namespace osu.Game.Tests.Skins { public const string TEXTURE_NAME = "virtual-texture"; - public Drawable GetDrawableComponent(ISkinComponent component) => throw new System.NotImplementedException(); + private readonly IRenderer renderer; + + public TestSkin(IRenderer renderer) + { + this.renderer = renderer; + } + + public Drawable GetDrawableComponent(ISkinComponentLookup lookup) => throw new System.NotImplementedException(); public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) { if (componentName == TEXTURE_NAME) - return Texture.WhitePixel; + return renderer.WhitePixel; return null; } diff --git a/osu.Game.Tests/Skins/TestSceneSkinResources.cs b/osu.Game.Tests/Skins/TestSceneSkinResources.cs index 0271198049..aaec319b57 100644 --- a/osu.Game.Tests/Skins/TestSceneSkinResources.cs +++ b/osu.Game.Tests/Skins/TestSceneSkinResources.cs @@ -1,12 +1,24 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Moq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics.Rendering.Dummy; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; using osu.Framework.Testing; using osu.Game.Audio; -using osu.Game.IO.Archives; +using osu.Game.Database; +using osu.Game.IO; using osu.Game.Skinning; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual; @@ -14,21 +26,85 @@ using osu.Game.Tests.Visual; namespace osu.Game.Tests.Skins { [HeadlessTest] - public class TestSceneSkinResources : OsuTestScene + public partial class TestSceneSkinResources : OsuTestScene { [Resolved] - private SkinManager skins { get; set; } + private SkinManager skins { get; set; } = null!; - private ISkin skin; - - [BackgroundDependencyLoader] - private void load() + [Test] + public void TestRetrieveOggSample() { - var imported = skins.Import(new ZipArchiveReader(TestResources.OpenResource("Archives/ogg-skin.osk"))).GetResultSafely(); - skin = imported.PerformRead(skinInfo => skins.GetSkin(skinInfo)); + ISkin skin = null!; + + AddStep("import skin", () => skin = importSkinFromArchives(@"ogg-skin.osk")); + AddAssert("sample is non-null", () => skin.GetSample(new SampleInfo(@"sample")) != null); } [Test] - public void TestRetrieveOggSample() => AddAssert("sample is non-null", () => skin.GetSample(new SampleInfo("sample")) != null); + public void TestRetrievalWithConflictingFilenames() + { + ISkin skin = null!; + + AddStep("import skin", () => skin = importSkinFromArchives(@"conflicting-filenames-skin.osk")); + AddAssert("texture is non-null", () => skin.GetTexture(@"spinner-osu") != null); + AddAssert("sample is non-null", () => skin.GetSample(new SampleInfo(@"spinner-osu")) != null); + } + + [Test] + public void TestSampleRetrievalOrder() + { + Mock mockResourceProvider = null!; + Mock> mockResourceStore = null!; + List lookedUpFileNames = null!; + + AddStep("setup mock providers provider", () => + { + lookedUpFileNames = new List(); + mockResourceProvider = new Mock(); + mockResourceProvider.Setup(m => m.AudioManager).Returns(Audio); + mockResourceProvider.Setup(m => m.Renderer).Returns(new DummyRenderer()); + mockResourceStore = new Mock>(); + mockResourceStore.Setup(r => r.Get(It.IsAny())) + .Callback(n => lookedUpFileNames.Add(n)) + .Returns(null); + }); + + AddStep("query sample", () => + { + TestSkin testSkin = new TestSkin(new SkinInfo(), mockResourceProvider.Object, new ResourceStore(mockResourceStore.Object)); + testSkin.GetSample(new SampleInfo()); + }); + + AddAssert("sample lookups were in correct order", () => + { + string[] lookups = lookedUpFileNames.Where(f => f.StartsWith(TestSkin.SAMPLE_NAME, StringComparison.Ordinal)).ToArray(); + return Path.GetExtension(lookups[0]) == string.Empty + && Path.GetExtension(lookups[1]) == ".wav" + && Path.GetExtension(lookups[2]) == ".mp3" + && Path.GetExtension(lookups[3]) == ".ogg"; + }); + } + + private Skin importSkinFromArchives(string filename) + { + var imported = skins.Import(new ImportTask(TestResources.OpenResource($@"Archives/{filename}"), filename)).GetResultSafely(); + return imported.PerformRead(skinInfo => skins.GetSkin(skinInfo)); + } + + private class TestSkin : Skin + { + public const string SAMPLE_NAME = "test-sample"; + + public TestSkin(SkinInfo skin, IStorageResourceProvider? resources, IResourceStore? storage = null, string configurationFilename = "skin.ini") + : base(skin, resources, storage, configurationFilename) + { + } + + public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); + + public override IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); + + public override ISample GetSample(ISampleInfo sampleInfo) => Samples.AsNonNull().Get(SAMPLE_NAME); + } } } diff --git a/osu.Game.Tests/Testing/TestSceneRulesetDependencies.cs b/osu.Game.Tests/Testing/TestSceneRulesetDependencies.cs index 330d3dd2ae..585a3f95e7 100644 --- a/osu.Game.Tests/Testing/TestSceneRulesetDependencies.cs +++ b/osu.Game.Tests/Testing/TestSceneRulesetDependencies.cs @@ -28,7 +28,7 @@ namespace osu.Game.Tests.Testing /// provided ruleset below are cached at the base implementation. /// [HeadlessTest] - public class TestSceneRulesetDependencies : OsuTestScene + public partial class TestSceneRulesetDependencies : OsuTestScene { protected override Ruleset CreateRuleset() => new TestRuleset(); @@ -49,9 +49,11 @@ 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"); + }); } [Test] @@ -74,12 +76,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/Utils/NamingUtilsTest.cs b/osu.Game.Tests/Utils/NamingUtilsTest.cs index 62e688db90..1f7e06f996 100644 --- a/osu.Game.Tests/Utils/NamingUtilsTest.cs +++ b/osu.Game.Tests/Utils/NamingUtilsTest.cs @@ -11,7 +11,7 @@ namespace osu.Game.Tests.Utils public class NamingUtilsTest { [Test] - public void TestEmptySet() + public void TestNextBestNameEmptySet() { string nextBestName = NamingUtils.GetNextBestName(Enumerable.Empty(), "New Difficulty"); @@ -19,7 +19,7 @@ namespace osu.Game.Tests.Utils } [Test] - public void TestNotTaken() + public void TestNextBestNameNotTaken() { string[] existingNames = { @@ -34,7 +34,7 @@ namespace osu.Game.Tests.Utils } [Test] - public void TestNotTakenButClose() + public void TestNextBestNameNotTakenButClose() { string[] existingNames = { @@ -49,7 +49,7 @@ namespace osu.Game.Tests.Utils } [Test] - public void TestAlreadyTaken() + public void TestNextBestNameAlreadyTaken() { string[] existingNames = { @@ -62,7 +62,7 @@ namespace osu.Game.Tests.Utils } [Test] - public void TestAlreadyTakenWithDifferentCase() + public void TestNextBestNameAlreadyTakenWithDifferentCase() { string[] existingNames = { @@ -75,7 +75,7 @@ namespace osu.Game.Tests.Utils } [Test] - public void TestAlreadyTakenWithBrackets() + public void TestNextBestNameAlreadyTakenWithBrackets() { string[] existingNames = { @@ -88,7 +88,7 @@ namespace osu.Game.Tests.Utils } [Test] - public void TestMultipleAlreadyTaken() + public void TestNextBestNameMultipleAlreadyTaken() { string[] existingNames = { @@ -104,7 +104,7 @@ namespace osu.Game.Tests.Utils } [Test] - public void TestEvenMoreAlreadyTaken() + public void TestNextBestNameEvenMoreAlreadyTaken() { string[] existingNames = Enumerable.Range(1, 30).Select(i => $"New Difficulty ({i})").Append("New Difficulty").ToArray(); @@ -114,7 +114,7 @@ namespace osu.Game.Tests.Utils } [Test] - public void TestMultipleAlreadyTakenWithGaps() + public void TestNextBestNameMultipleAlreadyTakenWithGaps() { string[] existingNames = { @@ -128,5 +128,153 @@ namespace osu.Game.Tests.Utils Assert.AreEqual("New Difficulty (2)", nextBestName); } + + [Test] + public void TestNextBestFilenameEmptySet() + { + string nextBestFilename = NamingUtils.GetNextBestFilename(Enumerable.Empty(), "test_file.osr"); + + Assert.AreEqual("test_file.osr", nextBestFilename); + } + + [Test] + public void TestNextBestFilenameNotTaken() + { + string[] existingFiles = + { + "this file exists.zip", + "that file exists.too", + "three.4", + }; + + string nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "test_file.osr"); + + Assert.AreEqual("test_file.osr", nextBestFilename); + } + + [Test] + public void TestNextBestFilenameNotTakenButClose() + { + string[] existingFiles = + { + "replay_file(1).osr", + "replay_file (not a number).zip", + "replay_file (1 <- now THAT is a number right here).lol", + }; + + string nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "replay_file.osr"); + + Assert.AreEqual("replay_file.osr", nextBestFilename); + } + + [Test] + public void TestNextBestFilenameAlreadyTaken() + { + string[] existingFiles = + { + "replay_file.osr", + }; + + string nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "replay_file.osr"); + + Assert.AreEqual("replay_file (1).osr", nextBestFilename); + } + + [Test] + public void TestNextBestFilenameAlreadyTakenDifferentCase() + { + string[] existingFiles = + { + "replay_file.osr", + "RePlAy_FiLe (1).OsR", + "REPLAY_FILE (2).OSR", + }; + + string nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "replay_file.osr"); + Assert.AreEqual("replay_file (3).osr", nextBestFilename); + } + + [Test] + public void TestNextBestFilenameAlreadyTakenWithBrackets() + { + string[] existingFiles = + { + "replay_file.osr", + "replay_file (copy).osr", + }; + + string nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "replay_file.osr"); + Assert.AreEqual("replay_file (1).osr", nextBestFilename); + + nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "replay_file (copy).osr"); + Assert.AreEqual("replay_file (copy) (1).osr", nextBestFilename); + } + + [Test] + public void TestNextBestFilenameMultipleAlreadyTaken() + { + string[] existingFiles = + { + "replay_file.osr", + "replay_file (1).osr", + "replay_file (2).osr", + "replay_file (3).osr", + }; + + string nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "replay_file.osr"); + + Assert.AreEqual("replay_file (4).osr", nextBestFilename); + } + + [Test] + public void TestNextBestFilenameMultipleAlreadyTakenWithGaps() + { + string[] existingFiles = + { + "replay_file.osr", + "replay_file (1).osr", + "replay_file (2).osr", + "replay_file (4).osr", + "replay_file (5).osr", + }; + + string nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "replay_file.osr"); + + Assert.AreEqual("replay_file (3).osr", nextBestFilename); + } + + [Test] + public void TestNextBestFilenameNoExtensions() + { + string[] existingFiles = + { + "those", + "are definitely", + "files", + }; + + string nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "surely"); + Assert.AreEqual("surely", nextBestFilename); + + nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "those"); + Assert.AreEqual("those (1)", nextBestFilename); + } + + [Test] + public void TestNextBestFilenameDifferentExtensions() + { + string[] existingFiles = + { + "replay_file.osr", + "replay_file (1).osr", + "replay_file.txt", + }; + + string nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "replay_file.osr"); + Assert.AreEqual("replay_file (2).osr", nextBestFilename); + + nextBestFilename = NamingUtils.GetNextBestFilename(existingFiles, "replay_file.txt"); + Assert.AreEqual("replay_file (1).txt", nextBestFilename); + } } } diff --git a/osu.Game.Tests/Visual/Audio/TestSceneAudioFilter.cs b/osu.Game.Tests/Visual/Audio/TestSceneAudioFilter.cs index 99be72e958..d96c19a13e 100644 --- a/osu.Game.Tests/Visual/Audio/TestSceneAudioFilter.cs +++ b/osu.Game.Tests/Visual/Audio/TestSceneAudioFilter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using ManagedBass.Fx; using NUnit.Framework; using osu.Framework.Allocation; @@ -17,7 +19,7 @@ using osu.Game.Graphics.UserInterface; namespace osu.Game.Tests.Visual.Audio { - public class TestSceneAudioFilter : OsuTestScene + public partial class TestSceneAudioFilter : OsuTestScene { private OsuSpriteText lowPassText; private AudioFilter lowPassFilter; @@ -29,8 +31,8 @@ namespace osu.Game.Tests.Visual.Audio private WaveformTestBeatmap beatmap; - private OsuSliderBar lowPassSlider; - private OsuSliderBar highPassSlider; + private RoundedSliderBar lowPassSlider; + private RoundedSliderBar highPassSlider; [BackgroundDependencyLoader] private void load(AudioManager audio) @@ -50,7 +52,7 @@ namespace osu.Game.Tests.Visual.Audio Text = $"Low Pass: {lowPassFilter.Cutoff}hz", Font = new FontUsage(size: 40) }, - lowPassSlider = new OsuSliderBar + lowPassSlider = new RoundedSliderBar { Width = 500, Height = 50, @@ -67,7 +69,7 @@ namespace osu.Game.Tests.Visual.Audio Text = $"High Pass: {highPassFilter.Cutoff}hz", Font = new FontUsage(size: 40) }, - highPassSlider = new OsuSliderBar + highPassSlider = new RoundedSliderBar { Width = 500, Height = 50, diff --git a/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs b/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs index 884e74346b..fbdaad1cd8 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -8,6 +10,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Graphics; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Textures; using osu.Framework.Screens; using osu.Framework.Testing; @@ -29,7 +32,7 @@ using osu.Game.Tests.Beatmaps; namespace osu.Game.Tests.Visual.Background { [TestFixture] - public class TestSceneBackgroundScreenDefault : OsuTestScene + public partial class TestSceneBackgroundScreenDefault : OsuTestScene { private BackgroundScreenStack stack; private TestBackgroundScreenDefault screen; @@ -41,6 +44,9 @@ namespace osu.Game.Tests.Visual.Background [Resolved] private OsuConfigManager config { get; set; } + [Resolved] + private IRenderer renderer { get; set; } + [SetUpSteps] public void SetUpSteps() { @@ -243,10 +249,10 @@ namespace osu.Game.Tests.Visual.Background Id = API.LocalUser.Value.Id + 1, }); - private WorkingBeatmap createTestWorkingBeatmapWithUniqueBackground() => new UniqueBackgroundTestWorkingBeatmap(Audio); + private WorkingBeatmap createTestWorkingBeatmapWithUniqueBackground() => new UniqueBackgroundTestWorkingBeatmap(renderer, Audio); private WorkingBeatmap createTestWorkingBeatmapWithStoryboard() => new TestWorkingBeatmapWithStoryboard(Audio); - private class TestBackgroundScreenDefault : BackgroundScreenDefault + private partial class TestBackgroundScreenDefault : BackgroundScreenDefault { private bool? lastLoadTriggerCausedChange; @@ -272,15 +278,18 @@ namespace osu.Game.Tests.Visual.Background private class UniqueBackgroundTestWorkingBeatmap : TestWorkingBeatmap { - public UniqueBackgroundTestWorkingBeatmap(AudioManager audioManager) + private readonly IRenderer renderer; + + public UniqueBackgroundTestWorkingBeatmap(IRenderer renderer, AudioManager audioManager) : base(new Beatmap(), null, audioManager) { + this.renderer = renderer; } - protected override Texture GetBackground() => new Texture(1, 1); + protected override Texture GetBackground() => renderer.CreateTexture(1, 1); } - private class TestWorkingBeatmapWithStoryboard : TestWorkingBeatmap + private partial class TestWorkingBeatmapWithStoryboard : TestWorkingBeatmap { public TestWorkingBeatmapWithStoryboard(AudioManager audioManager) : base(new Beatmap(), createStoryboard(), audioManager) @@ -306,7 +315,7 @@ namespace osu.Game.Tests.Visual.Background public Drawable CreateDrawable() => new DrawableTestStoryboardElement(); } - private class DrawableTestStoryboardElement : OsuSpriteText + private partial class DrawableTestStoryboardElement : OsuSpriteText { public override bool RemoveWhenNotAlive => false; diff --git a/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs b/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs index 0bd1263076..54a722cee0 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -8,7 +10,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Textures; using osu.Game.Configuration; using osu.Game.Graphics.Backgrounds; @@ -18,7 +20,7 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Tests.Visual.Background { - public class TestSceneSeasonalBackgroundLoader : ScreenTestScene + public partial class TestSceneSeasonalBackgroundLoader : ScreenTestScene { [Resolved] private OsuConfigManager config { get; set; } @@ -26,11 +28,9 @@ namespace osu.Game.Tests.Visual.Background [Resolved] private SessionStatics statics { get; set; } - [Cached(typeof(LargeTextureStore))] - private LookupLoggingTextureStore textureStore = new LookupLoggingTextureStore(); - private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + private LookupLoggingTextureStore textureStore; private SeasonalBackgroundLoader backgroundLoader; private Container backgroundContainer; @@ -43,15 +43,32 @@ namespace osu.Game.Tests.Visual.Background "Backgrounds/bg3" }; + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + textureStore = new LookupLoggingTextureStore(dependencies.Get()); + dependencies.CacheAs(typeof(LargeTextureStore), textureStore); + + return dependencies; + } + [BackgroundDependencyLoader] private void load(LargeTextureStore wrappedStore) { textureStore.AddStore(wrappedStore); - Add(backgroundContainer = new Container + Child = new DependencyProvidingContainer { - RelativeSizeAxes = Axes.Both - }); + CachedDependencies = new (Type, object)[] + { + (typeof(LargeTextureStore), textureStore) + }, + Child = backgroundContainer = new Container + { + RelativeSizeAxes = Axes.Both + } + }; } [SetUp] @@ -154,7 +171,7 @@ namespace osu.Game.Tests.Visual.Background => AddStep("create loader", () => { if (backgroundLoader != null) - Remove(backgroundLoader); + Remove(backgroundLoader, true); Add(backgroundLoader = new SeasonalBackgroundLoader()); }); @@ -191,6 +208,11 @@ namespace osu.Game.Tests.Visual.Background { public List PerformedLookups { get; } = new List(); + public LookupLoggingTextureStore(IRenderer renderer) + : base(renderer) + { + } + public override Texture Get(string name, WrapMode wrapModeS, WrapMode wrapModeT) { PerformedLookups.Add(name); diff --git a/osu.Game.Tests/Visual/Background/TestSceneTriangleBorderShader.cs b/osu.Game.Tests/Visual/Background/TestSceneTriangleBorderShader.cs new file mode 100644 index 0000000000..711d9ab5ea --- /dev/null +++ b/osu.Game.Tests/Visual/Background/TestSceneTriangleBorderShader.cs @@ -0,0 +1,127 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics.Shaders; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics; +using osuTK; +using osuTK.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Rendering; +using osu.Game.Graphics.Backgrounds; + +namespace osu.Game.Tests.Visual.Background +{ + public partial class TestSceneTriangleBorderShader : OsuTestScene + { + private readonly TestTriangle triangle; + + public TestSceneTriangleBorderShader() + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.DarkGreen + }, + triangle = new TestTriangle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(200) + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AddSliderStep("Thickness", 0f, 1f, 0.15f, t => triangle.Thickness = t); + AddSliderStep("Texel size", 0f, 0.1f, 0f, t => triangle.TexelSize = t); + } + + private partial class TestTriangle : Sprite + { + private float thickness = 0.15f; + + public float Thickness + { + get => thickness; + set + { + thickness = value; + Invalidate(Invalidation.DrawNode); + } + } + + private float texelSize; + + public float TexelSize + { + get => texelSize; + set + { + texelSize = value; + Invalidate(Invalidation.DrawNode); + } + } + + [BackgroundDependencyLoader] + private void load(ShaderManager shaders, IRenderer renderer) + { + TextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, "TriangleBorder"); + Texture = renderer.WhitePixel; + } + + protected override DrawNode CreateDrawNode() => new TriangleDrawNode(this); + + private class TriangleDrawNode : SpriteDrawNode + { + public new TestTriangle Source => (TestTriangle)base.Source; + + public TriangleDrawNode(TestTriangle source) + : base(source) + { + } + + private float thickness; + private float texelSize; + + public override void ApplyState() + { + base.ApplyState(); + + thickness = Source.thickness; + texelSize = Source.texelSize; + } + + private IUniformBuffer? borderDataBuffer; + + protected override void BindUniformResources(IShader shader, IRenderer renderer) + { + base.BindUniformResources(shader, renderer); + + borderDataBuffer ??= renderer.CreateUniformBuffer(); + borderDataBuffer.Data = borderDataBuffer.Data with + { + Thickness = thickness, + TexelSize = texelSize + }; + + shader.BindUniformBlock("m_BorderData", borderDataBuffer); + } + + protected override bool CanDrawOpaqueInterior => false; + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + borderDataBuffer?.Dispose(); + } + } + } + } +} diff --git a/osu.Game.Tests/Visual/Background/TestSceneTrianglesBackground.cs b/osu.Game.Tests/Visual/Background/TestSceneTrianglesBackground.cs new file mode 100644 index 0000000000..378dd99664 --- /dev/null +++ b/osu.Game.Tests/Visual/Background/TestSceneTrianglesBackground.cs @@ -0,0 +1,46 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Graphics.Backgrounds; +using osu.Framework.Graphics; +using osuTK.Graphics; +using osu.Framework.Graphics.Shapes; +using osuTK; + +namespace osu.Game.Tests.Visual.Background +{ + public partial class TestSceneTrianglesBackground : OsuTestScene + { + private readonly Triangles triangles; + + public TestSceneTrianglesBackground() + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black + }, + triangles = new Triangles + { + RelativeSizeAxes = Axes.Both, + ColourLight = Color4.White, + ColourDark = Color4.Gray, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(0.9f) + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AddSliderStep("Triangle scale", 0f, 10f, 1f, s => triangles.TriangleScale = s); + AddSliderStep("Seed", 0, 1000, 0, s => triangles.Reset(s)); + AddToggleStep("Masking", m => triangles.Masking = m); + } + } +} diff --git a/osu.Game.Tests/Visual/Background/TestSceneTrianglesV2Background.cs b/osu.Game.Tests/Visual/Background/TestSceneTrianglesV2Background.cs new file mode 100644 index 0000000000..01a2464b8e --- /dev/null +++ b/osu.Game.Tests/Visual/Background/TestSceneTrianglesV2Background.cs @@ -0,0 +1,134 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osuTK; +using osuTK.Graphics; +using osu.Game.Graphics.Backgrounds; +using osu.Framework.Graphics.Colour; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Tests.Visual.Background +{ + public partial class TestSceneTrianglesV2Background : OsuTestScene + { + private readonly TrianglesV2 triangles; + private readonly TrianglesV2 maskedTriangles; + private readonly Box box; + + public TestSceneTrianglesV2Background() + { + AddRange(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Gray + }, + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Masked" + }, + new Container + { + Size = new Vector2(500, 100), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Masking = true, + CornerRadius = 40, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Red + }, + triangles = new TrianglesV2 + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both + } + } + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Non-masked" + }, + new Container + { + Size = new Vector2(500, 100), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Red + }, + maskedTriangles = new TrianglesV2 + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both + } + } + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Gradient comparison box" + }, + new Container + { + Size = new Vector2(500, 100), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Masking = true, + CornerRadius = 40, + Child = box = new Box + { + RelativeSizeAxes = Axes.Both + } + } + } + } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AddSliderStep("Spawn ratio", 0f, 10f, 1f, s => + { + triangles.SpawnRatio = maskedTriangles.SpawnRatio = s; + triangles.Reset(1234); + maskedTriangles.Reset(1234); + }); + AddSliderStep("Thickness", 0f, 1f, 0.02f, t => triangles.Thickness = maskedTriangles.Thickness = t); + + AddStep("White colour", () => box.Colour = triangles.Colour = maskedTriangles.Colour = Color4.White); + AddStep("Vertical gradient", () => box.Colour = triangles.Colour = maskedTriangles.Colour = ColourInfo.GradientVertical(Color4.White, Color4.Red)); + AddStep("Horizontal gradient", () => box.Colour = triangles.Colour = maskedTriangles.Colour = ColourInfo.GradientHorizontal(Color4.White, Color4.Red)); + AddToggleStep("Masking", m => maskedTriangles.Masking = m); + } + } +} diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs index ef115ab66b..5df5337a96 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Threading; using NUnit.Framework; using osu.Framework.Allocation; @@ -35,7 +37,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Background { [TestFixture] - public class TestSceneUserDimBackgrounds : ScreenTestScene + public partial class TestSceneUserDimBackgrounds : ScreenTestScene { private DummySongSelect songSelect; private TestPlayerLoader playerLoader; @@ -47,7 +49,7 @@ namespace osu.Game.Tests.Visual.Background private void load(GameHost host, AudioManager audio) { Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(new OsuConfigManager(LocalStorage)); Dependencies.Cache(Realm); @@ -242,7 +244,10 @@ namespace osu.Game.Tests.Visual.Background public void TestResumeFromPlayer() { performFullSetup(); - AddStep("Move mouse to Visual Settings", () => InputManager.MoveMouseTo(playerLoader.VisualSettingsPos)); + AddStep("Move mouse to Visual Settings location", () => InputManager.MoveMouseTo(playerLoader.ScreenSpaceDrawQuad.TopRight + + new Vector2(-playerLoader.VisualSettingsPos.ScreenSpaceDrawQuad.Width, + playerLoader.VisualSettingsPos.ScreenSpaceDrawQuad.Height / 2 + ))); AddStep("Resume PlayerLoader", () => player.Restart()); AddUntilStep("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); AddStep("Move mouse to center of screen", () => InputManager.MoveMouseTo(playerLoader.ScreenPos)); @@ -294,7 +299,7 @@ namespace osu.Game.Tests.Visual.Background rulesets?.Dispose(); } - private class DummySongSelect : PlaySongSelect + private partial class DummySongSelect : PlaySongSelect { private FadeAccessibleBackground background; @@ -341,7 +346,7 @@ namespace osu.Game.Tests.Visual.Background public bool IsBackgroundCurrent() => background?.IsCurrentScreen() == true; } - private class FadeAccessibleResults : ResultsScreen + private partial class FadeAccessibleResults : ResultsScreen { public FadeAccessibleResults(ScoreInfo score) : base(score, true) @@ -353,7 +358,7 @@ namespace osu.Game.Tests.Visual.Background public Vector2 ExpectedBackgroundBlur => new Vector2(BACKGROUND_BLUR); } - private class LoadBlockingTestPlayer : TestPlayer + private partial class LoadBlockingTestPlayer : TestPlayer { protected override BackgroundScreen CreateBackground() => new FadeAccessibleBackground(Beatmap.Value); @@ -395,7 +400,7 @@ namespace osu.Game.Tests.Visual.Background } } - private class TestPlayerLoader : PlayerLoader + private partial class TestPlayerLoader : PlayerLoader { private FadeAccessibleBackground background; @@ -414,7 +419,7 @@ namespace osu.Game.Tests.Visual.Background protected override BackgroundScreen CreateBackground() => background = new FadeAccessibleBackground(Beatmap.Value); } - private class FadeAccessibleBackground : BackgroundScreenBeatmap + private partial class FadeAccessibleBackground : BackgroundScreenBeatmap { protected override DimmableBackground CreateFadeContainer() => dimmable = new TestDimmableBackground { RelativeSizeAxes = Axes.Both }; @@ -434,7 +439,7 @@ namespace osu.Game.Tests.Visual.Background } } - private class TestDimmableBackground : BackgroundScreenBeatmap.DimmableBackground + private partial class TestDimmableBackground : BackgroundScreenBeatmap.DimmableBackground { public Color4 CurrentColour => Content.Colour; public float CurrentAlpha => Content.Alpha; diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimContainer.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimContainer.cs index fede99f450..ff35f5e8a9 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneUserDimContainer.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -13,7 +15,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Background { - public class TestSceneUserDimContainer : OsuTestScene + public partial class TestSceneUserDimContainer : OsuTestScene { private TestUserDimContainer userDimContainer; @@ -102,7 +104,7 @@ namespace osu.Game.Tests.Visual.Background AddAssert("no dim", () => userDimContainer.DimEqual(0)); } - private class TestUserDimContainer : UserDimContainer + private partial class TestUserDimContainer : UserDimContainer { public bool DimEqual(float expectedDimLevel) => Content.Colour == OsuColour.Gray(1f - expectedDimLevel); diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs index 6cb171974a..d4018be7fc 100644 --- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -13,16 +15,18 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Beatmaps.Drawables.Cards; +using osu.Game.Beatmaps.Drawables.Cards.Buttons; using osu.Game.Graphics.Containers; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osuTK; +using osuTK.Input; namespace osu.Game.Tests.Visual.Beatmaps { - public class TestSceneBeatmapCard : OsuManualInputManagerTestScene + public partial class TestSceneBeatmapCard : OsuManualInputManagerTestScene { /// /// All cards on this scene use a common online ID to ensure that map download, preview tracks, etc. can be tested manually with online sources. @@ -293,5 +297,22 @@ namespace osu.Game.Tests.Visual.Beatmaps BeatmapCardNormal firstCard() => this.ChildrenOfType().First(); } + + [Test] + public void TestPlayButtonByTouchInput() + { + AddStep("create cards", () => Child = createContent(OverlayColourScheme.Blue, beatmapSetInfo => new BeatmapCardNormal(beatmapSetInfo))); + + // mimics touch input + AddStep("touch play button area on first card", () => + { + InputManager.MoveMouseTo(firstCard().ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("first card is playing", () => firstCard().ChildrenOfType().Single().Playing.Value); + + BeatmapCardNormal firstCard() => this.ChildrenOfType().First(); + } } } diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardDifficultyList.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardDifficultyList.cs index e6fb4372ff..9f3e36ad76 100644 --- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardDifficultyList.cs +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardDifficultyList.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -11,7 +13,7 @@ using osu.Game.Overlays; namespace osu.Game.Tests.Visual.Beatmaps { - public class TestSceneBeatmapCardDifficultyList : OsuTestScene + public partial class TestSceneBeatmapCardDifficultyList : OsuTestScene { [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardDownloadButton.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardDownloadButton.cs index 068d2bdcbd..ed80def57d 100644 --- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardDownloadButton.cs +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardDownloadButton.cs @@ -1,12 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables.Cards.Buttons; using osu.Game.Configuration; +using osu.Game.Online; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Resources.Localisation.Web; @@ -15,7 +18,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Beatmaps { - public class TestSceneBeatmapCardDownloadButton : OsuTestScene + public partial class TestSceneBeatmapCardDownloadButton : OsuTestScene { private DownloadButton downloadButton; @@ -56,7 +59,9 @@ namespace osu.Game.Tests.Visual.Beatmaps { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Scale = new Vector2(2) + Size = new Vector2(25f, 50f), + Scale = new Vector2(2f), + State = { Value = DownloadState.NotDownloaded }, }; }); } diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardFavouriteButton.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardFavouriteButton.cs index 77c9debef6..c33033624a 100644 --- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardFavouriteButton.cs +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardFavouriteButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -17,7 +19,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Beatmaps { - public class TestSceneBeatmapCardFavouriteButton : OsuManualInputManagerTestScene + public partial class TestSceneBeatmapCardFavouriteButton : OsuManualInputManagerTestScene { private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; @@ -35,7 +37,11 @@ namespace osu.Game.Tests.Visual.Beatmaps beatmapSetInfo = CreateAPIBeatmapSet(Ruleset.Value); beatmapSetInfo.HasFavourited = favourited; }); - AddStep("create button", () => Child = button = new FavouriteButton(beatmapSetInfo) { Scale = new Vector2(2) }); + AddStep("create button", () => Child = button = new FavouriteButton(beatmapSetInfo) + { + Size = new Vector2(25f, 50f), + Scale = new Vector2(2f), + }); assertCorrectIcon(favourited); AddAssert("correct tooltip text", () => button.TooltipText == (favourited ? BeatmapsetsStrings.ShowDetailsUnfavourite : BeatmapsetsStrings.ShowDetailsFavourite)); @@ -49,7 +55,11 @@ namespace osu.Game.Tests.Visual.Beatmaps BeatmapFavouriteAction? lastRequestAction = null; AddStep("create beatmap set", () => beatmapSetInfo = CreateAPIBeatmapSet(Ruleset.Value)); - AddStep("create button", () => Child = button = new FavouriteButton(beatmapSetInfo) { Scale = new Vector2(2) }); + AddStep("create button", () => Child = button = new FavouriteButton(beatmapSetInfo) + { + Size = new Vector2(25f, 50f), + Scale = new Vector2(2f), + }); assertCorrectIcon(false); diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardThumbnail.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardThumbnail.cs index a5b52f75f6..f44fe2b90c 100644 --- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardThumbnail.cs +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardThumbnail.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -15,7 +17,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Beatmaps { - public class TestSceneBeatmapCardThumbnail : OsuManualInputManagerTestScene + public partial class TestSceneBeatmapCardThumbnail : OsuManualInputManagerTestScene { private PlayButton playButton => this.ChildrenOfType().Single(); @@ -40,7 +42,7 @@ namespace osu.Game.Tests.Visual.Beatmaps }; }); AddStep("enable dim", () => thumbnail.Dimmed.Value = true); - AddUntilStep("button visible", () => playButton.IsPresent); + AddUntilStep("button visible", () => playButton.Alpha == 1); AddStep("click button", () => { @@ -68,7 +70,7 @@ namespace osu.Game.Tests.Visual.Beatmaps AddStep("disable dim", () => thumbnail.Dimmed.Value = false); AddWaitStep("wait some", 3); - AddAssert("button still visible", () => playButton.IsPresent); + AddAssert("button still visible", () => playButton.Alpha == 1); // The track plays in real-time, so we need to check for progress in increments to avoid timeout. AddUntilStep("progress > 0.25", () => thumbnail.ChildrenOfType().Single().Progress.Value > 0.25); @@ -76,7 +78,7 @@ namespace osu.Game.Tests.Visual.Beatmaps AddUntilStep("progress > 0.75", () => thumbnail.ChildrenOfType().Single().Progress.Value > 0.75); AddUntilStep("wait for track to end", () => !playButton.Playing.Value); - AddUntilStep("button hidden", () => !playButton.IsPresent); + AddUntilStep("button hidden", () => playButton.Alpha == 0); } private void iconIs(IconUsage usage) => AddUntilStep("icon is correct", () => playButton.ChildrenOfType().Any(icon => icon.Icon.Equals(usage))); diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapSetOnlineStatusPill.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapSetOnlineStatusPill.cs index 8132fe6ab8..8a11d60875 100644 --- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapSetOnlineStatusPill.cs +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapSetOnlineStatusPill.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -17,7 +19,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Beatmaps { - public class TestSceneBeatmapSetOnlineStatusPill : ThemeComparisonTestScene + public partial class TestSceneBeatmapSetOnlineStatusPill : ThemeComparisonTestScene { protected override Drawable CreateContent() => new FillFlowContainer { diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneDifficultySpectrumDisplay.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneDifficultySpectrumDisplay.cs index 4063fa1252..11fa6ed92d 100644 --- a/osu.Game.Tests/Visual/Beatmaps/TestSceneDifficultySpectrumDisplay.cs +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneDifficultySpectrumDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Beatmaps { - public class TestSceneDifficultySpectrumDisplay : OsuTestScene + public partial class TestSceneDifficultySpectrumDisplay : OsuTestScene { private DifficultySpectrumDisplay display; diff --git a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs index 888002eb36..1e9982f8d4 100644 --- a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs +++ b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs @@ -21,42 +21,36 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Collections { - public class TestSceneManageCollectionsDialog : OsuManualInputManagerTestScene + public partial class TestSceneManageCollectionsDialog : OsuManualInputManagerTestScene { protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; - private DialogOverlay dialogOverlay; - private CollectionManager manager; - - private RulesetStore rulesets; - private BeatmapManager beatmapManager; - - private ManageCollectionsDialog dialog; + private DialogOverlay dialogOverlay = null!; + private BeatmapManager beatmapManager = null!; + private ManageCollectionsDialog dialog = null!; [BackgroundDependencyLoader] private void load(GameHost host) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, rulesets, null, Audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, Audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); base.Content.AddRange(new Drawable[] { - manager = new CollectionManager(LocalStorage), Content, dialogOverlay = new DialogOverlay(), }); - Dependencies.Cache(manager); Dependencies.CacheAs(dialogOverlay); } [SetUp] public void SetUp() => Schedule(() => { - manager.Collections.Clear(); + Realm.Write(r => r.RemoveAll()); Child = dialog = new ManageCollectionsDialog(); }); @@ -76,17 +70,17 @@ namespace osu.Game.Tests.Visual.Collections [Test] public void TestLastItemIsPlaceholder() { - AddAssert("last item is placeholder", () => !manager.Collections.Contains(dialog.ChildrenOfType().Last().Model)); + AddAssert("last item is placeholder", () => !dialog.ChildrenOfType().Last().Model.IsManaged); } [Test] public void TestAddCollectionExternal() { - AddStep("add collection", () => manager.Collections.Add(new BeatmapCollection { Name = { Value = "First collection" } })); + AddStep("add collection", () => Realm.Write(r => r.Add(new BeatmapCollection(name: "First collection")))); assertCollectionCount(1); assertCollectionName(0, "First collection"); - AddStep("add another collection", () => manager.Collections.Add(new BeatmapCollection { Name = { Value = "Second collection" } })); + AddStep("add another collection", () => Realm.Write(r => r.Add(new BeatmapCollection(name: "Second collection")))); assertCollectionCount(2); assertCollectionName(1, "Second collection"); } @@ -106,7 +100,7 @@ namespace osu.Game.Tests.Visual.Collections [Test] public void TestAddCollectionViaPlaceholder() { - DrawableCollectionListItem placeholderItem = null; + DrawableCollectionListItem placeholderItem = null!; AddStep("focus placeholder", () => { @@ -114,24 +108,37 @@ namespace osu.Game.Tests.Visual.Collections InputManager.Click(MouseButton.Left); }); - // Done directly via the collection since InputManager methods cannot add text to textbox... - AddStep("change collection name", () => placeholderItem.Model.Name.Value = "a"); - assertCollectionCount(1); - AddAssert("collection now exists", () => manager.Collections.Contains(placeholderItem.Model)); + assertCollectionCount(0); - AddAssert("last item is placeholder", () => !manager.Collections.Contains(dialog.ChildrenOfType().Last().Model)); + AddStep("change collection name", () => + { + placeholderItem.ChildrenOfType().First().Text = "test text"; + InputManager.Key(Key.Enter); + }); + + assertCollectionCount(1); + + AddAssert("last item is placeholder", () => !dialog.ChildrenOfType().Last().Model.IsManaged); } [Test] public void TestRemoveCollectionExternal() { - AddStep("add two collections", () => manager.Collections.AddRange(new[] - { - new BeatmapCollection { Name = { Value = "1" } }, - new BeatmapCollection { Name = { Value = "2" } }, - })); + BeatmapCollection first = null!; - AddStep("remove first collection", () => manager.Collections.RemoveAt(0)); + AddStep("add two collections", () => + { + Realm.Write(r => + { + r.Add(new[] + { + first = new BeatmapCollection(name: "1"), + new BeatmapCollection(name: "2"), + }); + }); + }); + + AddStep("remove first collection", () => Realm.Write(r => r.Remove(first))); assertCollectionCount(1); assertCollectionName(0, "2"); } @@ -141,7 +148,7 @@ namespace osu.Game.Tests.Visual.Collections { AddStep("add dropdown", () => { - Add(new CollectionFilterDropdown + Add(new CollectionDropdown { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, @@ -149,21 +156,27 @@ namespace osu.Game.Tests.Visual.Collections Width = 0.4f, }); }); - AddStep("add two collections with same name", () => manager.Collections.AddRange(new[] + AddStep("add two collections with same name", () => Realm.Write(r => r.Add(new[] { - new BeatmapCollection { Name = { Value = "1" } }, - new BeatmapCollection { Name = { Value = "1" }, Beatmaps = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0] } }, - })); + new BeatmapCollection(name: "1"), + new BeatmapCollection(name: "1") + { + BeatmapMD5Hashes = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0].MD5Hash } + }, + }))); } [Test] public void TestRemoveCollectionViaButton() { - AddStep("add two collections", () => manager.Collections.AddRange(new[] + AddStep("add two collections", () => Realm.Write(r => r.Add(new[] { - new BeatmapCollection { Name = { Value = "1" } }, - new BeatmapCollection { Name = { Value = "2" }, Beatmaps = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0] } }, - })); + new BeatmapCollection(name: "1"), + new BeatmapCollection(name: "2") + { + BeatmapMD5Hashes = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0].MD5Hash } + }, + }))); assertCollectionCount(2); @@ -187,19 +200,24 @@ namespace osu.Game.Tests.Visual.Collections AddStep("click confirmation", () => { InputManager.MoveMouseTo(dialogOverlay.CurrentDialog.ChildrenOfType().First()); - InputManager.Click(MouseButton.Left); + InputManager.PressButton(MouseButton.Left); }); assertCollectionCount(0); + + AddStep("release mouse button", () => InputManager.ReleaseButton(MouseButton.Left)); } [Test] public void TestCollectionNotRemovedWhenDialogCancelled() { - AddStep("add two collections", () => manager.Collections.AddRange(new[] + AddStep("add collection", () => Realm.Write(r => r.Add(new[] { - new BeatmapCollection { Name = { Value = "1" }, Beatmaps = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0] } }, - })); + new BeatmapCollection(name: "1") + { + BeatmapMD5Hashes = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0].MD5Hash } + }, + }))); assertCollectionCount(1); @@ -222,34 +240,67 @@ namespace osu.Game.Tests.Visual.Collections [Test] public void TestCollectionRenamedExternal() { - AddStep("add two collections", () => manager.Collections.AddRange(new[] + BeatmapCollection first = null!; + + AddStep("add two collections", () => { - new BeatmapCollection { Name = { Value = "1" } }, - new BeatmapCollection { Name = { Value = "2" } }, - })); + Realm.Write(r => + { + r.Add(new[] + { + first = new BeatmapCollection(name: "1"), + new BeatmapCollection(name: "2"), + }); + }); + }); - AddStep("change first collection name", () => manager.Collections[0].Name.Value = "First"); + assertCollectionName(0, "1"); + assertCollectionName(1, "2"); - assertCollectionName(0, "First"); + AddStep("change first collection name", () => Realm.Write(_ => first.Name = "First")); + + // Item will have moved due to alphabetical sorting. + assertCollectionName(0, "2"); + assertCollectionName(1, "First"); } [Test] public void TestCollectionRenamedOnTextChange() { - AddStep("add two collections", () => manager.Collections.AddRange(new[] + BeatmapCollection first = null!; + DrawableCollectionListItem firstItem = null!; + + AddStep("add two collections", () => { - new BeatmapCollection { Name = { Value = "1" } }, - new BeatmapCollection { Name = { Value = "2" } }, - })); + Realm.Write(r => + { + r.Add(new[] + { + first = new BeatmapCollection(name: "1"), + new BeatmapCollection(name: "2"), + }); + }); + }); assertCollectionCount(2); - AddStep("change first collection name", () => dialog.ChildrenOfType().First().Text = "First"); - AddAssert("collection has new name", () => manager.Collections[0].Name.Value == "First"); + AddStep("focus first collection", () => + { + InputManager.MoveMouseTo(firstItem = dialog.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + + AddStep("change first collection name", () => + { + firstItem.ChildrenOfType().First().Text = "First"; + InputManager.Key(Key.Enter); + }); + + AddUntilStep("collection has new name", () => first.Name == "First"); } private void assertCollectionCount(int count) - => AddUntilStep($"{count} collections shown", () => dialog.ChildrenOfType().Count(i => i.IsCreated.Value) == count); + => AddUntilStep($"{count} collections shown", () => dialog.ChildrenOfType().Count() == count + 1); // +1 for placeholder private void assertCollectionName(int index, string name) => AddUntilStep($"item {index + 1} has correct name", () => dialog.ChildrenOfType().ElementAt(index).ChildrenOfType().First().Text == name); diff --git a/osu.Game.Tests/Visual/Colours/TestSceneStarDifficultyColours.cs b/osu.Game.Tests/Visual/Colours/TestSceneStarDifficultyColours.cs index c345320e28..55a2efa89d 100644 --- a/osu.Game.Tests/Visual/Colours/TestSceneStarDifficultyColours.cs +++ b/osu.Game.Tests/Visual/Colours/TestSceneStarDifficultyColours.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -14,7 +16,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Colours { - public class TestSceneStarDifficultyColours : OsuTestScene + public partial class TestSceneStarDifficultyColours : OsuTestScene { [Resolved] private OsuColour colours { get; set; } diff --git a/osu.Game.Tests/Visual/Components/TestSceneIdleTracker.cs b/osu.Game.Tests/Visual/Components/TestSceneIdleTracker.cs index 86a9d555a3..aad30ff450 100644 --- a/osu.Game.Tests/Visual/Components/TestSceneIdleTracker.cs +++ b/osu.Game.Tests/Visual/Components/TestSceneIdleTracker.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -12,7 +14,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Components { [TestFixture] - public class TestSceneIdleTracker : OsuManualInputManagerTestScene + public partial class TestSceneIdleTracker : OsuManualInputManagerTestScene { private IdleTrackingBox box1; private IdleTrackingBox box2; @@ -152,7 +154,7 @@ namespace osu.Game.Tests.Visual.Components AddUntilStep("wait for all idle", () => box1.IsIdle && box2.IsIdle && box3.IsIdle && box4.IsIdle); } - private class IdleTrackingBox : CompositeDrawable + private partial class IdleTrackingBox : CompositeDrawable { private readonly IdleTracker idleTracker; diff --git a/osu.Game.Tests/Visual/Components/TestScenePollingComponent.cs b/osu.Game.Tests/Visual/Components/TestScenePollingComponent.cs index cc8503589d..4bab8d0ccc 100644 --- a/osu.Game.Tests/Visual/Components/TestScenePollingComponent.cs +++ b/osu.Game.Tests/Visual/Components/TestScenePollingComponent.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Threading.Tasks; using NUnit.Framework; @@ -17,7 +19,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Components { [HeadlessTest] - public class TestScenePollingComponent : OsuTestScene + public partial class TestScenePollingComponent : OsuTestScene { private Container pollBox; private TestPoller poller; @@ -130,7 +132,7 @@ namespace osu.Game.Tests.Visual.Components protected override double TimePerAction => 500; - public class TestPoller : PollingComponent + public partial class TestPoller : PollingComponent { public event Action OnPoll; @@ -141,7 +143,7 @@ namespace osu.Game.Tests.Visual.Components } } - public class TestSlowPoller : TestPoller + public partial class TestSlowPoller : TestPoller { protected override Task Poll() => Task.Delay((int)(TimeBetweenPolls.Value / 2f / Clock.Rate)).ContinueWith(_ => base.Poll()); } diff --git a/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs b/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs index 82b6710a17..b334616125 100644 --- a/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs +++ b/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -11,7 +13,7 @@ using osu.Game.Beatmaps; namespace osu.Game.Tests.Visual.Components { - public class TestScenePreviewTrackManager : OsuTestScene, IPreviewTrackOwner + public partial class TestScenePreviewTrackManager : OsuTestScene, IPreviewTrackOwner { private readonly IAdjustableAudioComponent gameTrackAudio = new AudioAdjustments(); @@ -188,7 +190,7 @@ namespace osu.Game.Tests.Visual.Components AddAssert("track stopped", () => !track.IsRunning); } - private TestPreviewTrackManager.TestPreviewTrack getTrack() => (TestPreviewTrackManager.TestPreviewTrack)trackManager.Get(null); + private TestPreviewTrackManager.TestPreviewTrack getTrack() => (TestPreviewTrackManager.TestPreviewTrack)trackManager.Get(CreateAPIBeatmapSet()); private TestPreviewTrackManager.TestPreviewTrack getOwnedTrack() { @@ -199,7 +201,7 @@ namespace osu.Game.Tests.Visual.Components return track; } - private class TestTrackOwner : CompositeDrawable, IPreviewTrackOwner + private partial class TestTrackOwner : CompositeDrawable, IPreviewTrackOwner { private readonly PreviewTrack track; private readonly bool registerAsOwner; @@ -225,7 +227,7 @@ namespace osu.Game.Tests.Visual.Components } } - public class TestPreviewTrackManager : PreviewTrackManager + public partial class TestPreviewTrackManager : PreviewTrackManager { public bool AllowUpdate = true; @@ -246,7 +248,7 @@ namespace osu.Game.Tests.Visual.Components return base.UpdateSubTree(); } - public class TestPreviewTrack : TrackManagerPreviewTrack + public partial class TestPreviewTrack : TrackManagerPreviewTrack { private readonly ITrackStore trackManager; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneBeatDivisorControl.cs b/osu.Game.Tests/Visual/Editing/TestSceneBeatDivisorControl.cs index 073a228224..56b16301be 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneBeatDivisorControl.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneBeatDivisorControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using System.Linq; @@ -19,7 +21,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneBeatDivisorControl : OsuManualInputManagerTestScene + public partial class TestSceneBeatDivisorControl : OsuManualInputManagerTestScene { private BeatDivisorControl beatDivisorControl; private BindableBeatDivisor bindableBeatDivisor; @@ -104,6 +106,49 @@ namespace osu.Game.Tests.Visual.Editing assertBeatSnap(16); } + [Test] + public void TestKeyboardNavigation() + { + pressKey(1); + assertBeatSnap(1); + assertPreset(BeatDivisorType.Common); + + pressKey(2); + assertBeatSnap(2); + assertPreset(BeatDivisorType.Common); + + pressKey(3); + assertBeatSnap(3); + assertPreset(BeatDivisorType.Triplets); + + pressKey(4); + assertBeatSnap(4); + assertPreset(BeatDivisorType.Common); + + pressKey(5); + assertBeatSnap(5); + assertPreset(BeatDivisorType.Custom, 5); + + pressKey(6); + assertBeatSnap(6); + assertPreset(BeatDivisorType.Triplets); + + pressKey(7); + assertBeatSnap(7); + assertPreset(BeatDivisorType.Custom, 7); + + pressKey(8); + assertBeatSnap(8); + assertPreset(BeatDivisorType.Common); + + void pressKey(int key) => AddStep($"press shift+{key}", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.Number0 + key); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + } + [Test] public void TestBeatPresetNavigation() { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneBlueprintOrdering.cs b/osu.Game.Tests/Visual/Editing/TestSceneBlueprintOrdering.cs index 5d8a6dabd7..8b598a6a24 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneBlueprintOrdering.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneBlueprintOrdering.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Testing; @@ -22,7 +24,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneBlueprintOrdering : EditorTestScene + public partial class TestSceneBlueprintOrdering : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); @@ -64,7 +66,7 @@ namespace osu.Game.Tests.Visual.Editing AddStep("move mouse to common point", () => { - var pos = blueprintContainer.ChildrenOfType().ElementAt(1).ScreenSpaceDrawQuad.Centre; + var pos = blueprintContainer.ChildrenOfType>().ElementAt(1).ScreenSpaceDrawQuad.Centre; InputManager.MoveMouseTo(pos); }); AddStep("right click", () => InputManager.Click(MouseButton.Right)); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneComposeScreen.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposeScreen.cs index fa15c00cd4..035092ecb7 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneComposeScreen.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneComposeScreen.cs @@ -9,6 +9,7 @@ using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Overlays; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu; @@ -20,9 +21,9 @@ using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Editing { [TestFixture] - public class TestSceneComposeScreen : EditorClockTestScene + public partial class TestSceneComposeScreen : EditorClockTestScene { - private EditorBeatmap editorBeatmap; + private EditorBeatmap editorBeatmap = null!; [Cached] private EditorClipboard clipboard = new EditorClipboard(); @@ -34,9 +35,11 @@ namespace osu.Game.Tests.Visual.Editing { var beatmap = new OsuBeatmap { - BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo } + BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }, }; + beatmap.ControlPointInfo.Add(0, new TimingControlPoint()); + editorBeatmap = new EditorBeatmap(beatmap, new LegacyBeatmapSkin(beatmap.BeatmapInfo, null)); Beatmap.Value = CreateWorkingBeatmap(editorBeatmap.PlayableBeatmap); @@ -50,7 +53,11 @@ namespace osu.Game.Tests.Visual.Editing (typeof(IBeatSnapProvider), editorBeatmap), (typeof(OverlayColourProvider), new OverlayColourProvider(OverlayColourScheme.Green)), }, - Child = new ComposeScreen { State = { Value = Visibility.Visible } }, + Children = new Drawable[] + { + editorBeatmap, + new ComposeScreen { State = { Value = Visibility.Visible } }, + } }; }); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs index 87dbb90138..7a0b3d0c1a 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -13,7 +15,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneComposeSelectBox : OsuManualInputManagerTestScene + public partial class TestSceneComposeSelectBox : OsuManualInputManagerTestScene { private Container selectionArea; private SelectionBox selectionBox; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs index dddd9f07ab..b14025c9d8 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneComposerSelection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -10,6 +12,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; @@ -23,7 +26,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneComposerSelection : EditorTestScene + public partial class TestSceneComposerSelection : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); @@ -61,6 +64,21 @@ namespace osu.Game.Tests.Visual.Editing AddUntilStep("context menu is visible", () => contextMenuContainer.ChildrenOfType().Single().State == MenuState.Open); } + [Test] + public void TestSelectAndShowContextMenuOutsideBounds() + { + var addedObject = new HitCircle { StartTime = 100, Position = OsuPlayfield.BASE_SIZE }; + AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); + + AddStep("descale blueprint container", () => this.ChildrenOfType().Single().Scale = new Vector2(0.5f)); + AddStep("move mouse to bottom-right", () => InputManager.MoveMouseTo(blueprintContainer.ToScreenSpace(blueprintContainer.LayoutRectangle.BottomRight + new Vector2(10)))); + + AddStep("right click", () => InputManager.Click(MouseButton.Right)); + + AddUntilStep("hitobject selected", () => EditorBeatmap.SelectedHitObjects.Single() == addedObject); + AddUntilStep("context menu is visible", () => contextMenuContainer.ChildrenOfType().Single().State == MenuState.Open); + } + [Test] public void TestNudgeSelection() { @@ -268,7 +286,7 @@ namespace osu.Game.Tests.Visual.Editing AddStep("move mouse to controlpoint", () => { - var pos = blueprintContainer.ChildrenOfType().ElementAt(1).ScreenSpaceDrawQuad.Centre; + var pos = blueprintContainer.ChildrenOfType>().ElementAt(1).ScreenSpaceDrawQuad.Centre; InputManager.MoveMouseTo(pos); }); AddStep("hold shift", () => InputManager.PressKey(Key.ShiftLeft)); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs b/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs index 10917df075..9a66e1676d 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneDesignSection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Globalization; using System.Linq; @@ -18,7 +20,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneDesignSection : OsuManualInputManagerTestScene + public partial class TestSceneDesignSection : OsuManualInputManagerTestScene { private TestDesignSection designSection; private EditorBeatmap editorBeatmap { get; set; } @@ -95,7 +97,7 @@ namespace osu.Game.Tests.Visual.Editing AddAssert($"beatmap value is {expectedFinalValue}", () => editorBeatmap.BeatmapInfo.CountdownOffset == expectedFinalValue); } - private class TestDesignSection : DesignSection + private partial class TestDesignSection : DesignSection { public new LabelledSwitchButton EnableCountdown => base.EnableCountdown; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDifficultyDelete.cs b/osu.Game.Tests/Visual/Editing/TestSceneDifficultyDelete.cs new file mode 100644 index 0000000000..280e6de97e --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneDifficultyDelete.cs @@ -0,0 +1,85 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Storyboards; +using osu.Game.Tests.Beatmaps.IO; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Editing +{ + public partial class TestSceneDifficultyDelete : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + protected override bool IsolateSavingFromDatabase => false; + + [Resolved] + private OsuGameBase game { get; set; } = null!; + + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + private BeatmapSetInfo importedBeatmapSet = null!; + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null!) + => beatmaps.GetWorkingBeatmap(importedBeatmapSet.Beatmaps.First()); + + public override void SetUpSteps() + { + AddStep("import test beatmap", () => importedBeatmapSet = BeatmapImportHelper.LoadOszIntoOsu(game, virtualTrack: true).GetResultSafely()); + base.SetUpSteps(); + } + + [Test] + public void TestDeleteDifficulties() + { + Guid deletedDifficultyID = Guid.Empty; + int countBeforeDeletion = 0; + string beatmapSetHashBefore = string.Empty; + + for (int i = 0; i < 12; i++) + { + // Will be reloaded after each deletion. + AddUntilStep("wait for editor to load", () => Editor?.ReadyForUse == true); + + AddStep("store selected difficulty", () => + { + deletedDifficultyID = EditorBeatmap.BeatmapInfo.ID; + countBeforeDeletion = Beatmap.Value.BeatmapSetInfo.Beatmaps.Count; + beatmapSetHashBefore = Beatmap.Value.BeatmapSetInfo.Hash; + }); + + AddStep("click File", () => this.ChildrenOfType().First().TriggerClick()); + + if (i == 11) + { + // last difficulty shouldn't be able to be deleted. + AddAssert("Delete menu item disabled", () => getDeleteMenuItem().Item.Action.Disabled); + } + else + { + AddStep("click delete", () => getDeleteMenuItem().TriggerClick()); + AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog != null); + AddStep("confirm", () => InputManager.Key(Key.Number1)); + + AddAssert($"difficulty {i} is deleted", () => Beatmap.Value.BeatmapSetInfo.Beatmaps.Select(b => b.ID), () => Does.Not.Contain(deletedDifficultyID)); + AddAssert("count decreased by one", () => Beatmap.Value.BeatmapSetInfo.Beatmaps.Count, () => Is.EqualTo(countBeforeDeletion - 1)); + AddAssert("set hash changed", () => Beatmap.Value.BeatmapSetInfo.Hash, () => Is.Not.EqualTo(beatmapSetHashBefore)); + } + } + } + + private DrawableOsuMenuItem getDeleteMenuItem() => this.ChildrenOfType() + .Single(item => item.ChildrenOfType().Any(text => text.Text.ToString().StartsWith("Delete", StringComparison.Ordinal))); + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDifficultySwitching.cs b/osu.Game.Tests/Visual/Editing/TestSceneDifficultySwitching.cs index 81cb286058..69070b0b64 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneDifficultySwitching.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneDifficultySwitching.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -18,7 +20,7 @@ using osu.Game.Tests.Beatmaps.IO; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneDifficultySwitching : EditorTestScene + public partial class TestSceneDifficultySwitching : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs index ef07c3e411..21b925a257 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -18,7 +20,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneDistanceSnapGrid : EditorClockTestScene + public partial class TestSceneDistanceSnapGrid : EditorClockTestScene { private const double beat_length = 100; private const int beat_snap_distance = 10; @@ -106,7 +108,7 @@ namespace osu.Game.Tests.Visual.Editing AddStep("check correct interval count", () => Assert.That((end_time / grid.DistanceBetweenTicks) * multiplier, Is.EqualTo(grid.MaxIntervals))); } - private class TestDistanceSnapGrid : DistanceSnapGrid + private partial class TestDistanceSnapGrid : DistanceSnapGrid { public new float DistanceBetweenTicks => base.DistanceBetweenTicks; @@ -191,7 +193,7 @@ namespace osu.Game.Tests.Visual.Editing IBindable IDistanceSnapProvider.DistanceSpacingMultiplier => DistanceSpacingMultiplier; - public float GetBeatSnapDistanceAt(HitObject referenceObject) => beat_snap_distance; + public float GetBeatSnapDistanceAt(HitObject referenceObject, bool useReferenceSliderVelocity = true) => beat_snap_distance; public float DurationToDistance(HitObject referenceObject, double duration) => (float)duration; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs index b109234fec..5aa2dd2ebf 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs @@ -6,12 +6,14 @@ using System.IO; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Audio.Track; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Collections; using osu.Game.Database; using osu.Game.Overlays.Dialog; using osu.Game.Rulesets; @@ -19,6 +21,8 @@ using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Taiko; +using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Setup; using osu.Game.Storyboards; @@ -30,16 +34,19 @@ using SharpCompress.Archives.Zip; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneEditorBeatmapCreation : EditorTestScene + public partial class TestSceneEditorBeatmapCreation : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); - protected override bool EditorComponentsReady => Editor.ChildrenOfType().SingleOrDefault()?.IsLoaded == true; - protected override bool IsolateSavingFromDatabase => false; [Resolved] - private BeatmapManager beatmapManager { get; set; } + private BeatmapManager beatmapManager { get; set; } = null!; + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + private Guid currentBeatmapSetID => EditorBeatmap.BeatmapInfo.BeatmapSet?.ID ?? Guid.Empty; public override void SetUpSteps() { @@ -50,19 +57,21 @@ namespace osu.Game.Tests.Visual.Editing AddStep("make new beatmap unique", () => EditorBeatmap.Metadata.Title = Guid.NewGuid().ToString()); } - protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) => new DummyWorkingBeatmap(Audio, null); + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null) => new DummyWorkingBeatmap(Audio, null); [Test] public void TestCreateNewBeatmap() { + AddAssert("status is none", () => EditorBeatmap.BeatmapInfo.Status == BeatmapOnlineStatus.None); AddStep("save beatmap", () => Editor.Save()); - AddAssert("new beatmap in database", () => beatmapManager.QueryBeatmapSet(s => s.ID == EditorBeatmap.BeatmapInfo.BeatmapSet.ID)?.Value.DeletePending == false); + AddAssert("new beatmap in database", () => beatmapManager.QueryBeatmapSet(s => s.ID == currentBeatmapSetID)?.Value.DeletePending == false); + AddAssert("status is modified", () => EditorBeatmap.BeatmapInfo.Status == BeatmapOnlineStatus.LocallyModified); } [Test] public void TestExitWithoutSave() { - EditorBeatmap editorBeatmap = null; + EditorBeatmap editorBeatmap = null!; AddStep("store editor beatmap", () => editorBeatmap = EditorBeatmap); @@ -78,12 +87,33 @@ namespace osu.Game.Tests.Visual.Editing AddUntilStep("wait for exit", () => !Editor.IsCurrentScreen()); AddStep("release", () => InputManager.ReleaseButton(MouseButton.Left)); - AddAssert("new beatmap not persisted", () => beatmapManager.QueryBeatmapSet(s => s.ID == editorBeatmap.BeatmapInfo.BeatmapSet.ID)?.Value.DeletePending == true); + AddAssert("new beatmap not persisted", () => beatmapManager.QueryBeatmapSet(s => s.ID == editorBeatmap.BeatmapInfo.BeatmapSet.AsNonNull().ID)?.Value.DeletePending == true); } [Test] + [FlakyTest] + /* + * Fail rate around 1.2%. + * + * Failing with realm refetch occasionally being null. + * My only guess is that the WorkingBeatmap at SetupScreen is dummy instead of the true one. + * If it's something else, we have larger issues with realm, but I don't think that's the case. + * + * at osu.Framework.Logging.ThrowingTraceListener.Fail(String message1, String message2) + * at System.Diagnostics.TraceInternal.Fail(String message, String detailMessage) + * at System.Diagnostics.TraceInternal.TraceProvider.Fail(String message, String detailMessage) + * at System.Diagnostics.Debug.Fail(String message, String detailMessage) + * at osu.Game.Database.ModelManager`1.<>c__DisplayClass8_0.b__0(Realm realm) ModelManager.cs:line 50 + * at osu.Game.Database.RealmExtensions.Write(Realm realm, Action`1 function) RealmExtensions.cs:line 14 + * at osu.Game.Database.ModelManager`1.performFileOperation(TModel item, Action`1 operation) ModelManager.cs:line 47 + * at osu.Game.Database.ModelManager`1.AddFile(TModel item, Stream contents, String filename) ModelManager.cs:line 37 + * at osu.Game.Screens.Edit.Setup.ResourcesSection.ChangeAudioTrack(FileInfo source) ResourcesSection.cs:line 115 + * at osu.Game.Tests.Visual.Editing.TestSceneEditorBeatmapCreation.b__11_0() TestSceneEditorBeatmapCreation.cs:line 101 + */ public void TestAddAudioTrack() { + AddAssert("track is virtual", () => Beatmap.Value.Track is TrackVirtual); + AddAssert("switch track to real track", () => { var setup = Editor.ChildrenOfType().First(); @@ -93,18 +123,41 @@ namespace osu.Game.Tests.Visual.Editing string extractedFolder = $"{temp}_extracted"; Directory.CreateDirectory(extractedFolder); - using (var zip = ZipArchive.Open(temp)) - zip.WriteToDirectory(extractedFolder); + try + { + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); - bool success = setup.ChildrenOfType().First().ChangeAudioTrack(Path.Combine(extractedFolder, "03. Renatus - Soleily 192kbps.mp3")); + bool success = setup.ChildrenOfType().First().ChangeAudioTrack(new FileInfo(Path.Combine(extractedFolder, "03. Renatus - Soleily 192kbps.mp3"))); - File.Delete(temp); - Directory.Delete(extractedFolder, true); + // ensure audio file is copied to beatmap as "audio.mp3" rather than original filename. + Assert.That(Beatmap.Value.Metadata.AudioFile == "audio.mp3"); - return success; + return success; + } + finally + { + File.Delete(temp); + Directory.Delete(extractedFolder, true); + } }); + AddAssert("track is not virtual", () => Beatmap.Value.Track is not TrackVirtual); AddAssert("track length changed", () => Beatmap.Value.Track.Length > 60000); + + AddStep("test play", () => Editor.TestGameplay()); + + AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog != null); + AddStep("confirm save", () => InputManager.Key(Key.Number1)); + + AddUntilStep("wait for return to editor", () => Editor.IsCurrentScreen()); + + AddAssert("track is still not virtual", () => Beatmap.Value.Track is not TrackVirtual); + AddAssert("track length correct", () => Beatmap.Value.Track.Length > 60000); + + AddUntilStep("track not playing", () => !EditorClock.IsRunning); + AddStep("play track", () => InputManager.Key(Key.Space)); + AddUntilStep("wait for track playing", () => EditorClock.IsRunning); } [Test] @@ -133,7 +186,7 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("new beatmap persisted", () => { var beatmap = beatmapManager.QueryBeatmap(b => b.DifficultyName == firstDifficultyName); - var set = beatmapManager.QueryBeatmapSet(s => s.ID == EditorBeatmap.BeatmapInfo.BeatmapSet.ID); + var set = beatmapManager.QueryBeatmapSet(s => s.ID == currentBeatmapSetID); return beatmap != null && beatmap.DifficultyName == firstDifficultyName @@ -152,7 +205,7 @@ namespace osu.Game.Tests.Visual.Editing AddUntilStep("wait for created", () => { - string difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; + string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; return difficultyName != null && difficultyName != firstDifficultyName; }); @@ -163,17 +216,20 @@ namespace osu.Game.Tests.Visual.Editing }); AddAssert("created difficulty has no objects", () => EditorBeatmap.HitObjects.Count == 0); + AddAssert("status is modified", () => EditorBeatmap.BeatmapInfo.Status == BeatmapOnlineStatus.LocallyModified); + AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = secondDifficultyName); AddStep("save beatmap", () => Editor.Save()); AddAssert("new beatmap persisted", () => { var beatmap = beatmapManager.QueryBeatmap(b => b.DifficultyName == secondDifficultyName); - var set = beatmapManager.QueryBeatmapSet(s => s.ID == EditorBeatmap.BeatmapInfo.BeatmapSet.ID); + var set = beatmapManager.QueryBeatmapSet(s => s.ID == currentBeatmapSetID); return beatmap != null && beatmap.DifficultyName == secondDifficultyName && set != null - && set.PerformRead(s => s.Beatmaps.Count == 2 && s.Beatmaps.Any(b => b.DifficultyName == secondDifficultyName)); + && set.PerformRead(s => + s.Beatmaps.Count == 2 && s.Beatmaps.Any(b => b.DifficultyName == secondDifficultyName) && s.Beatmaps.All(b => s.Status == BeatmapOnlineStatus.LocallyModified)); }); } @@ -219,7 +275,7 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("new beatmap persisted", () => { var beatmap = beatmapManager.QueryBeatmap(b => b.DifficultyName == originalDifficultyName); - var set = beatmapManager.QueryBeatmapSet(s => s.ID == EditorBeatmap.BeatmapInfo.BeatmapSet.ID); + var set = beatmapManager.QueryBeatmapSet(s => s.ID == currentBeatmapSetID); return beatmap != null && beatmap.DifficultyName == originalDifficultyName @@ -235,7 +291,7 @@ namespace osu.Game.Tests.Visual.Editing AddUntilStep("wait for created", () => { - string difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; + string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; return difficultyName != null && difficultyName != originalDifficultyName; }); @@ -249,18 +305,18 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("approach rate correctly copied", () => EditorBeatmap.Difficulty.ApproachRate == 4); AddAssert("combo colours correctly copied", () => EditorBeatmap.BeatmapSkin.AsNonNull().ComboColours.Count == 2); - AddAssert("status not copied", () => EditorBeatmap.BeatmapInfo.Status == BeatmapOnlineStatus.None); + AddAssert("status is modified", () => EditorBeatmap.BeatmapInfo.Status == BeatmapOnlineStatus.LocallyModified); AddAssert("online ID not copied", () => EditorBeatmap.BeatmapInfo.OnlineID == -1); AddStep("save beatmap", () => Editor.Save()); - BeatmapInfo refetchedBeatmap = null; - Live refetchedBeatmapSet = null; + BeatmapInfo? refetchedBeatmap = null; + Live? refetchedBeatmapSet = null; AddStep("refetch from database", () => { refetchedBeatmap = beatmapManager.QueryBeatmap(b => b.DifficultyName == copyDifficultyName); - refetchedBeatmapSet = beatmapManager.QueryBeatmapSet(s => s.ID == EditorBeatmap.BeatmapInfo.BeatmapSet.ID); + refetchedBeatmapSet = beatmapManager.QueryBeatmapSet(s => s.ID == currentBeatmapSetID); }); AddAssert("new beatmap persisted", () => @@ -276,6 +332,56 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("old beatmap file not deleted", () => refetchedBeatmapSet.AsNonNull().PerformRead(s => s.Files.Count == 2)); } + [Test] + public void TestCopyDifficultyDoesNotChangeCollections() + { + string originalDifficultyName = Guid.NewGuid().ToString(); + + AddStep("set unique difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName = originalDifficultyName); + AddStep("save beatmap", () => Editor.Save()); + + string originalMd5 = string.Empty; + BeatmapCollection collection = null!; + + AddStep("setup a collection with original beatmap", () => + { + collection = new BeatmapCollection("test copy"); + collection.BeatmapMD5Hashes.Add(originalMd5 = EditorBeatmap.BeatmapInfo.MD5Hash); + + realm.Write(r => + { + r.Add(collection); + }); + }); + + AddAssert("collection contains original beatmap", () => + !string.IsNullOrEmpty(originalMd5) && collection.BeatmapMD5Hashes.Contains(originalMd5)); + + AddStep("create new difficulty", () => Editor.CreateNewDifficulty(new OsuRuleset().RulesetInfo)); + + AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog is CreateNewDifficultyDialog); + AddStep("confirm creation as a copy", () => DialogOverlay.CurrentDialog.Buttons.ElementAt(1).TriggerClick()); + + AddUntilStep("wait for created", () => + { + string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; + return difficultyName != null && difficultyName != originalDifficultyName; + }); + + AddStep("save without changes", () => Editor.Save()); + + AddAssert("collection still points to old beatmap", () => !collection.BeatmapMD5Hashes.Contains(EditorBeatmap.BeatmapInfo.MD5Hash) + && collection.BeatmapMD5Hashes.Contains(originalMd5)); + + AddStep("clean up collection", () => + { + realm.Write(r => + { + r.Remove(collection); + }); + }); + } + [Test] public void TestCreateMultipleNewDifficultiesSucceeds() { @@ -296,7 +402,7 @@ namespace osu.Game.Tests.Visual.Editing AddUntilStep("wait for created", () => { - string difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; + string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; return difficultyName != null && difficultyName != "New Difficulty"; }); AddAssert("new difficulty has correct name", () => EditorBeatmap.BeatmapInfo.DifficultyName == "New Difficulty (1)"); @@ -332,7 +438,7 @@ namespace osu.Game.Tests.Visual.Editing AddUntilStep("wait for created", () => { - string difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; + string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; return difficultyName != null && difficultyName != duplicate_difficulty_name; }); @@ -346,5 +452,52 @@ namespace osu.Game.Tests.Visual.Editing return set != null && set.PerformRead(s => s.Beatmaps.Count == 2 && s.Files.Count == 2); }); } + + [Test] + public void TestCreateNewDifficultyForInconvertibleRuleset() + { + Guid setId = Guid.Empty; + + AddStep("retrieve set ID", () => setId = EditorBeatmap.BeatmapInfo.BeatmapSet!.ID); + AddStep("save beatmap", () => Editor.Save()); + AddStep("try to create new taiko difficulty", () => Editor.CreateNewDifficulty(new TaikoRuleset().RulesetInfo)); + + AddUntilStep("wait for created", () => + { + string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; + return difficultyName != null && difficultyName == "New Difficulty"; + }); + AddAssert("new difficulty persisted", () => + { + var set = beatmapManager.QueryBeatmapSet(s => s.ID == setId); + return set != null && set.PerformRead(s => s.Beatmaps.Count == 2 && s.Files.Count == 2); + }); + + AddStep("add timing point", () => EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 })); + AddStep("add hitobjects", () => EditorBeatmap.AddRange(new[] + { + new Hit + { + StartTime = 0 + }, + new Hit + { + StartTime = 1000 + } + })); + AddStep("save beatmap", () => Editor.Save()); + AddStep("try to create new catch difficulty", () => Editor.CreateNewDifficulty(new CatchRuleset().RulesetInfo)); + + AddUntilStep("wait for created", () => + { + string? difficultyName = Editor.ChildrenOfType().SingleOrDefault()?.BeatmapInfo.DifficultyName; + return difficultyName != null && difficultyName == "New Difficulty (1)"; + }); + AddAssert("new difficulty persisted", () => + { + var set = beatmapManager.QueryBeatmapSet(s => s.ID == setId); + return set != null && set.PerformRead(s => s.Beatmaps.Count == 3 && s.Files.Count == 3); + }); + } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBindings.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBindings.cs new file mode 100644 index 0000000000..61723aab7e --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBindings.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Editing +{ + /// + /// Test editor hotkeys at a high level to ensure they all work well together. + /// + public partial class TestSceneEditorBindings : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + [Test] + public void TestBeatDivisorChangeHotkeys() + { + AddStep("hold shift", () => InputManager.PressKey(Key.LShift)); + + AddStep("press 4", () => InputManager.Key(Key.Number4)); + AddAssert("snap updated to 4", () => EditorBeatmap.BeatmapInfo.BeatDivisor, () => Is.EqualTo(4)); + + AddStep("press 6", () => InputManager.Key(Key.Number6)); + AddAssert("snap updated to 6", () => EditorBeatmap.BeatmapInfo.BeatDivisor, () => Is.EqualTo(6)); + + AddStep("release shift", () => InputManager.ReleaseKey(Key.LShift)); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorChangeStates.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorChangeStates.cs index ab53f4fd93..278b6e9626 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorChangeStates.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorChangeStates.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Rulesets; using osu.Game.Rulesets.Objects; @@ -9,7 +11,7 @@ using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneEditorChangeStates : EditorTestScene + public partial class TestSceneEditorChangeStates : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs index e41f8372b4..d26bb6bb8a 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Testing; @@ -19,7 +21,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneEditorClipboard : EditorTestScene + public partial class TestSceneEditorClipboard : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); @@ -153,6 +155,20 @@ namespace osu.Game.Tests.Visual.Editing AddUntilStep("composer selection box is visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha > 0); } + [Test] + public void TestClone() + { + var addedObject = new HitCircle { StartTime = 1000 }; + AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); + AddStep("select added object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject)); + + AddAssert("is one object", () => EditorBeatmap.HitObjects.Count == 1); + AddStep("clone", () => Editor.Clone()); + AddAssert("is two objects", () => EditorBeatmap.HitObjects.Count == 2); + AddStep("clone", () => Editor.Clone()); + AddAssert("is three objects", () => EditorBeatmap.HitObjects.Count == 3); + } + [Test] public void TestCutNothing() { @@ -173,5 +189,22 @@ namespace osu.Game.Tests.Visual.Editing AddStep("paste hitobject", () => Editor.Paste()); AddAssert("are no objects", () => EditorBeatmap.HitObjects.Count == 0); } + + [Test] + public void TestCloneNothing() + { + // Add arbitrary object and copy to clipboard. + // This is tested to ensure that clone doesn't incorrectly read from the clipboard when no selection is made. + var addedObject = new HitCircle { StartTime = 1000 }; + AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); + AddStep("select added object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject)); + AddStep("copy hitobject", () => Editor.Copy()); + + AddStep("deselect all objects", () => EditorBeatmap.SelectedHitObjects.Clear()); + + AddAssert("is one object", () => EditorBeatmap.HitObjects.Count == 1); + AddStep("clone", () => Editor.Clone()); + AddAssert("still one object", () => EditorBeatmap.HitObjects.Count == 1); + } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorClock.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorClock.cs index 393d3886e7..82d2542190 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorClock.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorClock.cs @@ -14,7 +14,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Editing { [TestFixture] - public class TestSceneEditorClock : EditorClockTestScene + public partial class TestSceneEditorClock : EditorClockTestScene { [Cached] private EditorBeatmap editorBeatmap = new EditorBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo)); @@ -55,51 +55,51 @@ namespace osu.Game.Tests.Visual.Editing [Test] public void TestStopAtTrackEnd() { - AddStep("reset clock", () => Clock.Seek(0)); + AddStep("reset clock", () => EditorClock.Seek(0)); - AddStep("start clock", Clock.Start); - AddAssert("clock running", () => Clock.IsRunning); + AddStep("start clock", () => EditorClock.Start()); + AddAssert("clock running", () => EditorClock.IsRunning); - AddStep("seek near end", () => Clock.Seek(Clock.TrackLength - 250)); - AddUntilStep("clock stops", () => !Clock.IsRunning); + AddStep("seek near end", () => EditorClock.Seek(EditorClock.TrackLength - 250)); + AddUntilStep("clock stops", () => !EditorClock.IsRunning); - AddAssert("clock stopped at end", () => Clock.CurrentTime == Clock.TrackLength); + AddUntilStep("clock stopped at end", () => EditorClock.CurrentTime - EditorClock.TotalAppliedOffset, () => Is.EqualTo(EditorClock.TrackLength)); - AddStep("start clock again", Clock.Start); - AddAssert("clock looped to start", () => Clock.IsRunning && Clock.CurrentTime < 500); + AddStep("start clock again", () => EditorClock.Start()); + AddAssert("clock looped to start", () => EditorClock.IsRunning && EditorClock.CurrentTime < 500); } [Test] public void TestWrapWhenStoppedAtTrackEnd() { - AddStep("reset clock", () => Clock.Seek(0)); + AddStep("reset clock", () => EditorClock.Seek(0)); - AddStep("stop clock", Clock.Stop); - AddAssert("clock stopped", () => !Clock.IsRunning); + AddStep("stop clock", () => EditorClock.Stop()); + AddAssert("clock stopped", () => !EditorClock.IsRunning); - AddStep("seek exactly to end", () => Clock.Seek(Clock.TrackLength)); - AddAssert("clock stopped at end", () => Clock.CurrentTime == Clock.TrackLength); + AddStep("seek exactly to end", () => EditorClock.Seek(EditorClock.TrackLength)); + AddAssert("clock stopped at end", () => EditorClock.CurrentTime, () => Is.EqualTo(EditorClock.TrackLength)); - AddStep("start clock again", Clock.Start); - AddAssert("clock looped to start", () => Clock.IsRunning && Clock.CurrentTime < 500); + AddStep("start clock again", () => EditorClock.Start()); + AddAssert("clock looped to start", () => EditorClock.IsRunning && EditorClock.CurrentTime < 500); } [Test] public void TestClampWhenSeekOutsideBeatmapBounds() { - AddStep("stop clock", Clock.Stop); + AddStep("stop clock", () => EditorClock.Stop()); - AddStep("seek before start time", () => Clock.Seek(-1000)); - AddAssert("time is clamped to 0", () => Clock.CurrentTime == 0); + AddStep("seek before start time", () => EditorClock.Seek(-1000)); + AddAssert("time is clamped to 0", () => EditorClock.CurrentTime, () => Is.EqualTo(0)); - AddStep("seek beyond track length", () => Clock.Seek(Clock.TrackLength + 1000)); - AddAssert("time is clamped to track length", () => Clock.CurrentTime == Clock.TrackLength); + AddStep("seek beyond track length", () => EditorClock.Seek(EditorClock.TrackLength + 1000)); + AddAssert("time is clamped to track length", () => EditorClock.CurrentTime, () => Is.EqualTo(EditorClock.TrackLength)); - AddStep("seek smoothly before start time", () => Clock.SeekSmoothlyTo(-1000)); - AddAssert("time is clamped to 0", () => Clock.CurrentTime == 0); + AddStep("seek smoothly before start time", () => EditorClock.SeekSmoothlyTo(-1000)); + AddUntilStep("time is clamped to 0", () => EditorClock.CurrentTime, () => Is.EqualTo(0)); - AddStep("seek smoothly beyond track length", () => Clock.SeekSmoothlyTo(Clock.TrackLength + 1000)); - AddAssert("time is clamped to track length", () => Clock.CurrentTime == Clock.TrackLength); + AddStep("seek smoothly beyond track length", () => EditorClock.SeekSmoothlyTo(EditorClock.TrackLength + 1000)); + AddUntilStep("time is clamped to track length", () => EditorClock.CurrentTime, () => Is.EqualTo(EditorClock.TrackLength)); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs index 028509ccd4..e11d2e9dbf 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs @@ -1,16 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Game.Overlays; using osu.Game.Screens.Edit.Components.RadioButtons; namespace osu.Game.Tests.Visual.Editing { [TestFixture] - public class TestSceneEditorComposeRadioButtons : OsuTestScene + public partial class TestSceneEditorComposeRadioButtons : OsuTestScene { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + public TestSceneEditorComposeRadioButtons() { EditorRadioButtonCollection collection; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorMenuBar.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorMenuBar.cs index ad6fc55a32..699b99c57f 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorMenuBar.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorMenuBar.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -13,7 +15,7 @@ using osu.Game.Screens.Edit.Components.Menus; namespace osu.Game.Tests.Visual.Editing { [TestFixture] - public class TestSceneEditorMenuBar : OsuTestScene + public partial class TestSceneEditorMenuBar : OsuTestScene { [Cached] private readonly OverlayColourProvider overlayColour = new OverlayColourProvider(OverlayColourScheme.Aquamarine); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorNavigation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorNavigation.cs new file mode 100644 index 0000000000..5914290d40 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorNavigation.cs @@ -0,0 +1,57 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.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/TestSceneEditorSamplePlayback.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSamplePlayback.cs index 2abc8a8dec..8b941d7597 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSamplePlayback.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSamplePlayback.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Testing; @@ -11,7 +13,7 @@ using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneEditorSamplePlayback : EditorTestScene + public partial class TestSceneEditorSamplePlayback : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs index e75c7f25a3..b396b382ff 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs @@ -1,13 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Framework.Utils; +using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Overlays; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose.Components.Timeline; using osu.Game.Screens.Select; @@ -15,12 +20,14 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneEditorSaving : EditorSavingTestScene + public partial class TestSceneEditorSaving : EditorSavingTestScene { [Test] public void TestCantExitWithoutSaving() { + AddUntilStep("Wait for dialog overlay load", () => ((Drawable)Game.Dependencies.Get()).IsLoaded); AddRepeatStep("Exit", () => InputManager.Key(Key.Escape), 10); + AddAssert("Sample playback disabled", () => Editor.SamplePlaybackDisabled.Value); AddAssert("Editor is still active screen", () => Game.ScreenStack.CurrentScreen is Editor); } @@ -37,6 +44,8 @@ namespace osu.Game.Tests.Visual.Editing SaveEditor(); + AddAssert("Hash updated", () => !string.IsNullOrEmpty(EditorBeatmap.BeatmapInfo.BeatmapSet?.Hash)); + AddAssert("Beatmap has correct metadata", () => EditorBeatmap.BeatmapInfo.Metadata.Artist == "artist" && EditorBeatmap.BeatmapInfo.Metadata.Title == "title"); AddAssert("Beatmap has correct author", () => EditorBeatmap.BeatmapInfo.Metadata.Author.Username == "author"); AddAssert("Beatmap has correct difficulty name", () => EditorBeatmap.BeatmapInfo.DifficultyName == "difficulty"); @@ -115,8 +124,8 @@ namespace osu.Game.Tests.Visual.Editing // After placement these must be non-default as defaults are read-only. AddAssert("Placed object has non-default control points", () => - EditorBeatmap.HitObjects[0].SampleControlPoint != SampleControlPoint.DEFAULT && - EditorBeatmap.HitObjects[0].DifficultyControlPoint != DifficultyControlPoint.DEFAULT); + !ReferenceEquals(EditorBeatmap.HitObjects[0].SampleControlPoint, SampleControlPoint.DEFAULT) && + !ReferenceEquals(EditorBeatmap.HitObjects[0].DifficultyControlPoint, DifficultyControlPoint.DEFAULT)); ReloadEditorToSameBeatmap(); @@ -124,8 +133,56 @@ namespace osu.Game.Tests.Visual.Editing // After placement these must be non-default as defaults are read-only. AddAssert("Placed object still has non-default control points", () => - EditorBeatmap.HitObjects[0].SampleControlPoint != SampleControlPoint.DEFAULT && - EditorBeatmap.HitObjects[0].DifficultyControlPoint != DifficultyControlPoint.DEFAULT); + !ReferenceEquals(EditorBeatmap.HitObjects[0].SampleControlPoint, SampleControlPoint.DEFAULT) && + !ReferenceEquals(EditorBeatmap.HitObjects[0].DifficultyControlPoint, DifficultyControlPoint.DEFAULT)); + } + + [Test] + public void TestLengthAndStarRatingUpdated() + { + WorkingBeatmap working = null; + double lastStarRating = 0; + double lastLength = 0; + + AddStep("Add timing point", () => EditorBeatmap.ControlPointInfo.Add(200, new TimingControlPoint { BeatLength = 600 })); + AddStep("Change to placement mode", () => InputManager.Key(Key.Number2)); + AddStep("Move to playfield", () => InputManager.MoveMouseTo(Game.ScreenSpaceDrawQuad.Centre)); + AddStep("Place single hitcircle", () => InputManager.Click(MouseButton.Left)); + AddAssert("One hitobject placed", () => EditorBeatmap.HitObjects.Count == 1); + + SaveEditor(); + AddStep("Get working beatmap", () => working = Game.BeatmapManager.GetWorkingBeatmap(EditorBeatmap.BeatmapInfo, true)); + + AddAssert("Beatmap length is zero", () => working.BeatmapInfo.Length == 0); + checkDifficultyIncreased(); + + AddStep("Move forward", () => InputManager.Key(Key.Right)); + AddStep("Place another hitcircle", () => InputManager.Click(MouseButton.Left)); + AddAssert("Two hitobjects placed", () => EditorBeatmap.HitObjects.Count == 2); + + SaveEditor(); + AddStep("Get working beatmap", () => working = Game.BeatmapManager.GetWorkingBeatmap(EditorBeatmap.BeatmapInfo, true)); + + checkDifficultyIncreased(); + checkLengthIncreased(); + + void checkLengthIncreased() + { + AddStep("Beatmap length increased", () => + { + Assert.That(working.BeatmapInfo.Length, Is.GreaterThan(lastLength)); + lastLength = working.BeatmapInfo.Length; + }); + } + + void checkDifficultyIncreased() + { + AddStep("Beatmap difficulty increased", () => + { + Assert.That(working.BeatmapInfo.StarRating, Is.GreaterThan(lastStarRating)); + lastStarRating = working.BeatmapInfo.StarRating; + }); + } } [Test] diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorScreenModes.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorScreenModes.cs index 2efd125f81..a9d054881b 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorScreenModes.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorScreenModes.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -10,7 +12,7 @@ using osu.Game.Screens.Edit; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneEditorScreenModes : EditorTestScene + public partial class TestSceneEditorScreenModes : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSeekSnapping.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeekSnapping.cs index 863f42520b..9df5df6b39 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSeekSnapping.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeekSnapping.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -15,7 +17,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Editing { [TestFixture] - public class TestSceneEditorSeekSnapping : EditorClockTestScene + public partial class TestSceneEditorSeekSnapping : EditorClockTestScene { public TestSceneEditorSeekSnapping() { @@ -26,6 +28,11 @@ namespace osu.Game.Tests.Visual.Editing { base.LoadComplete(); + Child = new TimingPointVisualiser(Beatmap.Value.Beatmap, 5000) { Clock = EditorClock }; + } + + protected override Beatmap CreateEditorClockBeatmap() + { var testBeatmap = new Beatmap { ControlPointInfo = new ControlPointInfo(), @@ -43,9 +50,7 @@ namespace osu.Game.Tests.Visual.Editing testBeatmap.ControlPointInfo.Add(450, new TimingControlPoint { BeatLength = 100 }); testBeatmap.ControlPointInfo.Add(500, new TimingControlPoint { BeatLength = 307.69230769230802 }); - Beatmap.Value = CreateWorkingBeatmap(testBeatmap); - - Child = new TimingPointVisualiser(testBeatmap, 5000) { Clock = Clock }; + return testBeatmap; } /// @@ -57,18 +62,18 @@ namespace osu.Game.Tests.Visual.Editing reset(); // Forwards - AddStep("Seek(0)", () => Clock.Seek(0)); - AddAssert("Time = 0", () => Clock.CurrentTime == 0); - AddStep("Seek(33)", () => Clock.Seek(33)); - AddAssert("Time = 33", () => Clock.CurrentTime == 33); - AddStep("Seek(89)", () => Clock.Seek(89)); - AddAssert("Time = 89", () => Clock.CurrentTime == 89); + AddStep("Seek(0)", () => EditorClock.Seek(0)); + checkTime(0); + AddStep("Seek(33)", () => EditorClock.Seek(33)); + checkTime(33); + AddStep("Seek(89)", () => EditorClock.Seek(89)); + checkTime(89); // Backwards - AddStep("Seek(25)", () => Clock.Seek(25)); - AddAssert("Time = 25", () => Clock.CurrentTime == 25); - AddStep("Seek(0)", () => Clock.Seek(0)); - AddAssert("Time = 0", () => Clock.CurrentTime == 0); + AddStep("Seek(25)", () => EditorClock.Seek(25)); + checkTime(25); + AddStep("Seek(0)", () => EditorClock.Seek(0)); + checkTime(0); } /// @@ -80,20 +85,20 @@ namespace osu.Game.Tests.Visual.Editing { reset(); - AddStep("Seek(0), Snap", () => Clock.SeekSnapped(0)); - AddAssert("Time = 0", () => Clock.CurrentTime == 0); - AddStep("Seek(50), Snap", () => Clock.SeekSnapped(50)); - AddAssert("Time = 50", () => Clock.CurrentTime == 50); - AddStep("Seek(100), Snap", () => Clock.SeekSnapped(100)); - AddAssert("Time = 100", () => Clock.CurrentTime == 100); - AddStep("Seek(175), Snap", () => Clock.SeekSnapped(175)); - AddAssert("Time = 175", () => Clock.CurrentTime == 175); - AddStep("Seek(350), Snap", () => Clock.SeekSnapped(350)); - AddAssert("Time = 350", () => Clock.CurrentTime == 350); - AddStep("Seek(400), Snap", () => Clock.SeekSnapped(400)); - AddAssert("Time = 400", () => Clock.CurrentTime == 400); - AddStep("Seek(450), Snap", () => Clock.SeekSnapped(450)); - AddAssert("Time = 450", () => Clock.CurrentTime == 450); + AddStep("Seek(0), Snap", () => EditorClock.SeekSnapped(0)); + checkTime(0); + AddStep("Seek(50), Snap", () => EditorClock.SeekSnapped(50)); + checkTime(50); + AddStep("Seek(100), Snap", () => EditorClock.SeekSnapped(100)); + checkTime(100); + AddStep("Seek(175), Snap", () => EditorClock.SeekSnapped(175)); + checkTime(175); + AddStep("Seek(350), Snap", () => EditorClock.SeekSnapped(350)); + checkTime(350); + AddStep("Seek(400), Snap", () => EditorClock.SeekSnapped(400)); + checkTime(400); + AddStep("Seek(450), Snap", () => EditorClock.SeekSnapped(450)); + checkTime(450); } /// @@ -105,18 +110,18 @@ namespace osu.Game.Tests.Visual.Editing { reset(); - AddStep("Seek(24), Snap", () => Clock.SeekSnapped(24)); - AddAssert("Time = 0", () => Clock.CurrentTime == 0); - AddStep("Seek(26), Snap", () => Clock.SeekSnapped(26)); - AddAssert("Time = 50", () => Clock.CurrentTime == 50); - AddStep("Seek(150), Snap", () => Clock.SeekSnapped(150)); - AddAssert("Time = 100", () => Clock.CurrentTime == 100); - AddStep("Seek(170), Snap", () => Clock.SeekSnapped(170)); - AddAssert("Time = 175", () => Clock.CurrentTime == 175); - AddStep("Seek(274), Snap", () => Clock.SeekSnapped(274)); - AddAssert("Time = 175", () => Clock.CurrentTime == 175); - AddStep("Seek(276), Snap", () => Clock.SeekSnapped(276)); - AddAssert("Time = 350", () => Clock.CurrentTime == 350); + AddStep("Seek(24), Snap", () => EditorClock.SeekSnapped(24)); + checkTime(0); + AddStep("Seek(26), Snap", () => EditorClock.SeekSnapped(26)); + checkTime(50); + AddStep("Seek(150), Snap", () => EditorClock.SeekSnapped(150)); + checkTime(100); + AddStep("Seek(170), Snap", () => EditorClock.SeekSnapped(170)); + checkTime(175); + AddStep("Seek(274), Snap", () => EditorClock.SeekSnapped(274)); + checkTime(175); + AddStep("Seek(276), Snap", () => EditorClock.SeekSnapped(276)); + checkTime(350); } /// @@ -127,16 +132,16 @@ namespace osu.Game.Tests.Visual.Editing { reset(); - AddStep("SeekForward", () => Clock.SeekForward()); - AddAssert("Time = 50", () => Clock.CurrentTime == 50); - AddStep("SeekForward", () => Clock.SeekForward()); - AddAssert("Time = 100", () => Clock.CurrentTime == 100); - AddStep("SeekForward", () => Clock.SeekForward()); - AddAssert("Time = 200", () => Clock.CurrentTime == 200); - AddStep("SeekForward", () => Clock.SeekForward()); - AddAssert("Time = 400", () => Clock.CurrentTime == 400); - AddStep("SeekForward", () => Clock.SeekForward()); - AddAssert("Time = 450", () => Clock.CurrentTime == 450); + AddStep("SeekForward", () => EditorClock.SeekForward()); + checkTime(50); + AddStep("SeekForward", () => EditorClock.SeekForward()); + checkTime(100); + AddStep("SeekForward", () => EditorClock.SeekForward()); + checkTime(200); + AddStep("SeekForward", () => EditorClock.SeekForward()); + checkTime(400); + AddStep("SeekForward", () => EditorClock.SeekForward()); + checkTime(450); } /// @@ -147,18 +152,18 @@ namespace osu.Game.Tests.Visual.Editing { reset(); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 50", () => Clock.CurrentTime == 50); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 100", () => Clock.CurrentTime == 100); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 175", () => Clock.CurrentTime == 175); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 350", () => Clock.CurrentTime == 350); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 400", () => Clock.CurrentTime == 400); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 450", () => Clock.CurrentTime == 450); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(50); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(100); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(175); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(350); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(400); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(450); } /// @@ -170,30 +175,31 @@ namespace osu.Game.Tests.Visual.Editing { reset(); - AddStep("Seek(49)", () => Clock.Seek(49)); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 50", () => Clock.CurrentTime == 50); - AddStep("Seek(49.999)", () => Clock.Seek(49.999)); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 100", () => Clock.CurrentTime == 100); - AddStep("Seek(99)", () => Clock.Seek(99)); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 100", () => Clock.CurrentTime == 100); - AddStep("Seek(99.999)", () => Clock.Seek(99.999)); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 100", () => Clock.CurrentTime == 150); - AddStep("Seek(174)", () => Clock.Seek(174)); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 175", () => Clock.CurrentTime == 175); - AddStep("Seek(349)", () => Clock.Seek(349)); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 350", () => Clock.CurrentTime == 350); - AddStep("Seek(399)", () => Clock.Seek(399)); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 400", () => Clock.CurrentTime == 400); - AddStep("Seek(449)", () => Clock.Seek(449)); - AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 450", () => Clock.CurrentTime == 450); + AddStep("Seek(49)", () => EditorClock.Seek(49)); + checkTime(49); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(50); + AddStep("Seek(49.999)", () => EditorClock.Seek(49.999)); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(100); + AddStep("Seek(99)", () => EditorClock.Seek(99)); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(100); + AddStep("Seek(99.999)", () => EditorClock.Seek(99.999)); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(150); + AddStep("Seek(174)", () => EditorClock.Seek(174)); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(175); + AddStep("Seek(349)", () => EditorClock.Seek(349)); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(350); + AddStep("Seek(399)", () => EditorClock.Seek(399)); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(400); + AddStep("Seek(449)", () => EditorClock.Seek(449)); + AddStep("SeekForward, Snap", () => EditorClock.SeekForward(true)); + checkTime(450); } /// @@ -204,17 +210,18 @@ namespace osu.Game.Tests.Visual.Editing { reset(); - AddStep("Seek(450)", () => Clock.Seek(450)); - AddStep("SeekBackward", () => Clock.SeekBackward()); - AddAssert("Time = 400", () => Clock.CurrentTime == 400); - AddStep("SeekBackward", () => Clock.SeekBackward()); - AddAssert("Time = 350", () => Clock.CurrentTime == 350); - AddStep("SeekBackward", () => Clock.SeekBackward()); - AddAssert("Time = 150", () => Clock.CurrentTime == 150); - AddStep("SeekBackward", () => Clock.SeekBackward()); - AddAssert("Time = 50", () => Clock.CurrentTime == 50); - AddStep("SeekBackward", () => Clock.SeekBackward()); - AddAssert("Time = 0", () => Clock.CurrentTime == 0); + AddStep("Seek(450)", () => EditorClock.Seek(450)); + checkTime(450); + AddStep("SeekBackward", () => EditorClock.SeekBackward()); + checkTime(400); + AddStep("SeekBackward", () => EditorClock.SeekBackward()); + checkTime(350); + AddStep("SeekBackward", () => EditorClock.SeekBackward()); + checkTime(150); + AddStep("SeekBackward", () => EditorClock.SeekBackward()); + checkTime(50); + AddStep("SeekBackward", () => EditorClock.SeekBackward()); + checkTime(0); } /// @@ -225,19 +232,20 @@ namespace osu.Game.Tests.Visual.Editing { reset(); - AddStep("Seek(450)", () => Clock.Seek(450)); - AddStep("SeekBackward, Snap", () => Clock.SeekBackward(true)); - AddAssert("Time = 400", () => Clock.CurrentTime == 400); - AddStep("SeekBackward, Snap", () => Clock.SeekBackward(true)); - AddAssert("Time = 350", () => Clock.CurrentTime == 350); - AddStep("SeekBackward, Snap", () => Clock.SeekBackward(true)); - AddAssert("Time = 175", () => Clock.CurrentTime == 175); - AddStep("SeekBackward, Snap", () => Clock.SeekBackward(true)); - AddAssert("Time = 100", () => Clock.CurrentTime == 100); - AddStep("SeekBackward, Snap", () => Clock.SeekBackward(true)); - AddAssert("Time = 50", () => Clock.CurrentTime == 50); - AddStep("SeekBackward, Snap", () => Clock.SeekBackward(true)); - AddAssert("Time = 0", () => Clock.CurrentTime == 0); + AddStep("Seek(450)", () => EditorClock.Seek(450)); + checkTime(450); + AddStep("SeekBackward, Snap", () => EditorClock.SeekBackward(true)); + checkTime(400); + AddStep("SeekBackward, Snap", () => EditorClock.SeekBackward(true)); + checkTime(350); + AddStep("SeekBackward, Snap", () => EditorClock.SeekBackward(true)); + checkTime(175); + AddStep("SeekBackward, Snap", () => EditorClock.SeekBackward(true)); + checkTime(100); + AddStep("SeekBackward, Snap", () => EditorClock.SeekBackward(true)); + checkTime(50); + AddStep("SeekBackward, Snap", () => EditorClock.SeekBackward(true)); + checkTime(0); } /// @@ -249,18 +257,19 @@ namespace osu.Game.Tests.Visual.Editing { reset(); - AddStep("Seek(451)", () => Clock.Seek(451)); - AddStep("SeekBackward, Snap", () => Clock.SeekBackward(true)); - AddAssert("Time = 450", () => Clock.CurrentTime == 450); - AddStep("Seek(450.999)", () => Clock.Seek(450.999)); - AddStep("SeekBackward, Snap", () => Clock.SeekBackward(true)); - AddAssert("Time = 450", () => Clock.CurrentTime == 450); - AddStep("Seek(401)", () => Clock.Seek(401)); - AddStep("SeekBackward, Snap", () => Clock.SeekBackward(true)); - AddAssert("Time = 400", () => Clock.CurrentTime == 400); - AddStep("Seek(401.999)", () => Clock.Seek(401.999)); - AddStep("SeekBackward, Snap", () => Clock.SeekBackward(true)); - AddAssert("Time = 400", () => Clock.CurrentTime == 400); + AddStep("Seek(451)", () => EditorClock.Seek(451)); + checkTime(451); + AddStep("SeekBackward, Snap", () => EditorClock.SeekBackward(true)); + checkTime(450); + AddStep("Seek(450.999)", () => EditorClock.Seek(450.999)); + AddStep("SeekBackward, Snap", () => EditorClock.SeekBackward(true)); + checkTime(450); + AddStep("Seek(401)", () => EditorClock.Seek(401)); + AddStep("SeekBackward, Snap", () => EditorClock.SeekBackward(true)); + checkTime(400); + AddStep("Seek(401.999)", () => EditorClock.Seek(401.999)); + AddStep("SeekBackward, Snap", () => EditorClock.SeekBackward(true)); + checkTime(400); } /// @@ -273,37 +282,40 @@ namespace osu.Game.Tests.Visual.Editing double lastTime = 0; - AddStep("Seek(0)", () => Clock.Seek(0)); + AddStep("Seek(0)", () => EditorClock.Seek(0)); + checkTime(0); for (int i = 0; i < 9; i++) { AddStep("SeekForward, Snap", () => { - lastTime = Clock.CurrentTime; - Clock.SeekForward(true); + lastTime = EditorClock.CurrentTime; + EditorClock.SeekForward(true); }); - AddAssert("Time > lastTime", () => Clock.CurrentTime > lastTime); + AddAssert("Time > lastTime", () => EditorClock.CurrentTime > lastTime); } for (int i = 0; i < 9; i++) { AddStep("SeekBackward, Snap", () => { - lastTime = Clock.CurrentTime; - Clock.SeekBackward(true); + lastTime = EditorClock.CurrentTime; + EditorClock.SeekBackward(true); }); - AddAssert("Time < lastTime", () => Clock.CurrentTime < lastTime); + AddAssert("Time < lastTime", () => EditorClock.CurrentTime < lastTime); } - AddAssert("Time = 0", () => Clock.CurrentTime == 0); + checkTime(0); } + private void checkTime(double expectedTime) => AddUntilStep($"Current time is {expectedTime}", () => EditorClock.CurrentTime, () => Is.EqualTo(expectedTime)); + private void reset() { - AddStep("Reset", () => Clock.Seek(0)); + AddStep("Reset", () => EditorClock.Seek(0)); } - private class TimingPointVisualiser : CompositeDrawable + private partial class TimingPointVisualiser : CompositeDrawable { private readonly double length; @@ -374,7 +386,7 @@ namespace osu.Game.Tests.Visual.Editing tracker.X = (float)(Time.Current / length); } - private class TimingPointTimeline : CompositeDrawable + private partial class TimingPointTimeline : CompositeDrawable { public TimingPointTimeline(TimingControlPoint timingPoint, double endTime, double fullDuration) { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs index da28387c4d..b2b3dd9632 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs @@ -1,8 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; -using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets; @@ -11,7 +12,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneEditorSeeking : EditorTestScene + public partial class TestSceneEditorSeeking : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); @@ -24,6 +25,7 @@ namespace osu.Game.Tests.Visual.Editing beatmap.ControlPointInfo.Clear(); beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 }); beatmap.ControlPointInfo.Add(2000, new TimingControlPoint { BeatLength = 500 }); + beatmap.ControlPointInfo.Add(20000, new TimingControlPoint { BeatLength = 500 }); return beatmap; } @@ -115,10 +117,30 @@ namespace osu.Game.Tests.Visual.Editing pressAndCheckTime(Key.Right, 3000); } + [Test] + public void TestSeekBetweenControlPoints() + { + AddStep("seek to 0", () => EditorClock.Seek(0)); + AddAssert("time is 0", () => EditorClock.CurrentTime == 0); + + // already at first control point, noop + pressAndCheckTime(Key.Up, 0); + + pressAndCheckTime(Key.Down, 2000); + + pressAndCheckTime(Key.Down, 20000); + // at last control point, noop + pressAndCheckTime(Key.Down, 20000); + + pressAndCheckTime(Key.Up, 2000); + pressAndCheckTime(Key.Up, 0); + pressAndCheckTime(Key.Up, 0); + } + private void pressAndCheckTime(Key key, double expectedTime) { AddStep($"press {key}", () => InputManager.Key(key)); - AddUntilStep($"time is {expectedTime}", () => Precision.AlmostEquals(expectedTime, EditorClock.CurrentTime, 1)); + AddUntilStep($"time is {expectedTime}", () => EditorClock.CurrentTime, () => Is.EqualTo(expectedTime).Within(1)); } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs index da0c83bb11..f255dd08a8 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs @@ -1,9 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components.Timelines.Summary; @@ -12,14 +15,20 @@ using osuTK; namespace osu.Game.Tests.Visual.Editing { [TestFixture] - public class TestSceneEditorSummaryTimeline : EditorClockTestScene + public partial class TestSceneEditorSummaryTimeline : EditorClockTestScene { [Cached(typeof(EditorBeatmap))] private readonly EditorBeatmap editorBeatmap; public TestSceneEditorSummaryTimeline() { - editorBeatmap = new EditorBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)); + var beatmap = CreateBeatmap(new OsuRuleset().RulesetInfo); + + beatmap.ControlPointInfo.Add(100000, new TimingControlPoint { BeatLength = 100 }); + beatmap.ControlPointInfo.Add(50000, new DifficultyControlPoint { SliderVelocity = 2 }); + beatmap.BeatmapInfo.Bookmarks = new[] { 75000, 125000 }; + + editorBeatmap = new EditorBeatmap(beatmap); } protected override void LoadComplete() diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs index 79ea866efe..2250868a39 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs @@ -1,14 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; @@ -19,12 +23,11 @@ using osu.Game.Screens.Edit.GameplayTest; using osu.Game.Screens.Play; using osu.Game.Storyboards; using osu.Game.Tests.Beatmaps.IO; -using osuTK.Graphics; using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneEditorTestGameplay : EditorTestScene + public partial class TestSceneEditorTestGameplay : EditorTestScene { protected override bool IsolateSavingFromDatabase => false; @@ -38,6 +41,14 @@ namespace osu.Game.Tests.Visual.Editing private BeatmapSetInfo importedBeatmapSet; + private Bindable editorDim; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + editorDim = config.GetBindable(OsuSetting.EditorDim); + } + public override void SetUpSteps() { AddStep("import test beatmap", () => importedBeatmapSet = BeatmapImportHelper.LoadOszIntoOsu(game).GetResultSafely()); @@ -75,7 +86,7 @@ namespace osu.Game.Tests.Visual.Editing // this test cares about checking the background belonging to the editor specifically, so check that using reference equality // (as `.Equals()` cannot discern between the two, as they technically share the same database GUID). var background = this.ChildrenOfType().Single(b => ReferenceEquals(b.Beatmap.BeatmapInfo, EditorBeatmap.BeatmapInfo)); - return background.Colour == Color4.DarkGray && background.BlurAmount.Value == 0; + return background.DimWhenUserSettingsIgnored.Value == editorDim.Value && background.BlurAmount.Value == 0; }); AddAssert("no mods selected", () => SelectedMods.Value.Count == 0); } @@ -108,7 +119,7 @@ namespace osu.Game.Tests.Visual.Editing // this test cares about checking the background belonging to the editor specifically, so check that using reference equality // (as `.Equals()` cannot discern between the two, as they technically share the same database GUID). var background = this.ChildrenOfType().Single(b => ReferenceEquals(b.Beatmap.BeatmapInfo, EditorBeatmap.BeatmapInfo)); - return background.Colour == Color4.DarkGray && background.BlurAmount.Value == 0; + return background.DimWhenUserSettingsIgnored.Value == editorDim.Value && background.BlurAmount.Value == 0; }); AddStep("start track", () => EditorClock.Start()); @@ -167,7 +178,7 @@ namespace osu.Game.Tests.Visual.Editing } [Test] - public void TestSharedClockState() + public void TestClockTimeTransferIsOneDirectional() { AddStep("seek to 00:01:00", () => EditorClock.Seek(60_000)); AddStep("click test gameplay button", () => @@ -184,15 +195,15 @@ namespace osu.Game.Tests.Visual.Editing GameplayClockContainer gameplayClockContainer = null; AddStep("fetch gameplay clock", () => gameplayClockContainer = editorPlayer.ChildrenOfType().First()); AddUntilStep("gameplay clock running", () => gameplayClockContainer.IsRunning); + // when the gameplay test is entered, the clock is expected to continue from where it was in the main editor... AddAssert("gameplay time past 00:01:00", () => gameplayClockContainer.CurrentTime >= 60_000); - double timeAtPlayerExit = 0; AddWaitStep("wait some", 5); - AddStep("store time before exit", () => timeAtPlayerExit = gameplayClockContainer.CurrentTime); AddStep("exit player", () => editorPlayer.Exit()); AddUntilStep("current screen is editor", () => Stack.CurrentScreen is Editor); - AddAssert("time is past player exit", () => EditorClock.CurrentTime >= timeAtPlayerExit); + // but when exiting from gameplay test back to editor, the expectation is that the editor time should revert to what it was at the point of initiating the gameplay test. + AddAssert("time reverted to 00:01:00", () => EditorClock.CurrentTime, () => Is.EqualTo(60_000)); } public override void TearDownSteps() diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs index c9d44fdab7..7ab0188114 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -27,7 +29,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { [TestFixture] - public class TestSceneHitObjectComposer : EditorClockTestScene + public partial class TestSceneHitObjectComposer : EditorClockTestScene { private OsuHitObjectComposer hitObjectComposer; private EditorBeatmapContainer editorBeatmapContainer; @@ -146,10 +148,6 @@ namespace osu.Game.Tests.Visual.Editing }); AddAssert("no circles placed", () => editorBeatmap.HitObjects.Count == 0); - - AddStep("place circle", () => InputManager.Click(MouseButton.Left)); - - AddAssert("circle placed", () => editorBeatmap.HitObjects.Count == 1); } [Test] @@ -163,13 +161,14 @@ namespace osu.Game.Tests.Visual.Editing AddStep("hold alt", () => InputManager.PressKey(Key.LAlt)); AddStep("scroll mouse 5 steps", () => InputManager.ScrollVerticalBy(5)); - AddAssert("distance spacing increased by 0.5", () => editorBeatmap.BeatmapInfo.DistanceSpacing == originalSpacing + 0.5); AddStep("release alt", () => InputManager.ReleaseKey(Key.LAlt)); AddStep("release ctrl", () => InputManager.ReleaseKey(Key.LControl)); + + AddAssert("distance spacing increased by 0.5", () => editorBeatmap.BeatmapInfo.DistanceSpacing == originalSpacing + 0.5); } - public class EditorBeatmapContainer : Container + public partial class EditorBeatmapContainer : Container { private readonly IWorkingBeatmap working; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs index 7c05abc2cd..ab82678eb9 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using Humanizer; using NUnit.Framework; @@ -21,7 +23,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneHitObjectDifficultyPointAdjustments : EditorTestScene + public partial class TestSceneHitObjectDifficultyPointAdjustments : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSamplePointAdjustments.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSamplePointAdjustments.cs index 4501eea88e..e8dcc6f19b 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSamplePointAdjustments.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSamplePointAdjustments.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using Humanizer; using NUnit.Framework; @@ -21,7 +23,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneHitObjectSamplePointAdjustments : EditorTestScene + public partial class TestSceneHitObjectSamplePointAdjustments : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneLabelledTimeSignature.cs b/osu.Game.Tests/Visual/Editing/TestSceneLabelledTimeSignature.cs index b34974dfc7..e91596b872 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneLabelledTimeSignature.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneLabelledTimeSignature.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osu.Game.Screens.Edit.Timing; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneLabelledTimeSignature : OsuManualInputManagerTestScene + public partial class TestSceneLabelledTimeSignature : OsuManualInputManagerTestScene { private LabelledTimeSignature timeSignature; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneMetadataSection.cs b/osu.Game.Tests/Visual/Editing/TestSceneMetadataSection.cs index 4ecfb0975b..a9f8e19e30 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneMetadataSection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneMetadataSection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Game.Beatmaps; @@ -11,7 +13,7 @@ using osu.Game.Screens.Edit.Setup; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneMetadataSection : OsuTestScene + public partial class TestSceneMetadataSection : OsuTestScene { [Cached] private EditorBeatmap editorBeatmap = new EditorBeatmap(new Beatmap @@ -139,7 +141,7 @@ namespace osu.Game.Tests.Visual.Editing AddAssert($"romanised title is {(editable ? "" : "not ")}editable", () => metadataSection.RomanisedTitleTextBox.ReadOnly == !editable); } - private class TestMetadataSection : MetadataSection + private partial class TestMetadataSection : MetadataSection { public new LabelledTextBox ArtistTextBox => base.ArtistTextBox; public new LabelledTextBox RomanisedArtistTextBox => base.RomanisedArtistTextBox; diff --git a/osu.Game.Tests/Visual/Editing/TestScenePlaybackControl.cs b/osu.Game.Tests/Visual/Editing/TestScenePlaybackControl.cs index bf0a7876a9..6fa52d5bbc 100644 --- a/osu.Game.Tests/Visual/Editing/TestScenePlaybackControl.cs +++ b/osu.Game.Tests/Visual/Editing/TestScenePlaybackControl.cs @@ -4,32 +4,23 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Game.Beatmaps; -using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components; using osuTK; namespace osu.Game.Tests.Visual.Editing { [TestFixture] - public class TestScenePlaybackControl : EditorClockTestScene + public partial class TestScenePlaybackControl : EditorClockTestScene { [BackgroundDependencyLoader] private void load() { - var clock = new EditorClock { IsCoupled = false }; - Dependencies.CacheAs(clock); - - var playback = new PlaybackControl + Child = new PlaybackControl { Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(200, 100) }; - - Beatmap.Value = CreateWorkingBeatmap(new Beatmap()); - - Child = playback; } } } diff --git a/osu.Game.Tests/Visual/Editing/TestScenePreviewTime.cs b/osu.Game.Tests/Visual/Editing/TestScenePreviewTime.cs new file mode 100644 index 0000000000..3319788c8a --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestScenePreviewTime.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; + +namespace osu.Game.Tests.Visual.Editing +{ + public partial class TestScenePreviewTime : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + [Test] + public void TestSceneSetPreviewTimingPoint() + { + AddStep("seek to 1000", () => EditorClock.Seek(1000)); + AddAssert("time is 1000", () => EditorClock.CurrentTime == 1000); + AddStep("set current time as preview point", () => Editor.SetPreviewPointToCurrentTime()); + AddAssert("preview time is 1000", () => EditorBeatmap.PreviewTime.Value == 1000); + } + + [Test] + public void TestScenePreviewTimeline() + { + AddStep("set preview time to -1", () => EditorBeatmap.PreviewTime.Value = -1); + AddAssert("preview time line should not show", () => !Editor.ChildrenOfType().Single().Children.Any()); + AddStep("set preview time to 1000", () => EditorBeatmap.PreviewTime.Value = 1000); + AddAssert("preview time line should show", () => Editor.ChildrenOfType().Single().Children.Single().Alpha == 1); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneRectangularPositionSnapGrid.cs b/osu.Game.Tests/Visual/Editing/TestSceneRectangularPositionSnapGrid.cs index 85a98eca47..e73a45e154 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneRectangularPositionSnapGrid.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneRectangularPositionSnapGrid.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Framework.Allocation; @@ -14,7 +16,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneRectangularPositionSnapGrid : OsuManualInputManagerTestScene + public partial class TestSceneRectangularPositionSnapGrid : OsuManualInputManagerTestScene { private Container content; protected override Container Content => content; @@ -62,7 +64,7 @@ namespace osu.Game.Tests.Visual.Editing })); } - private class SnappingCursorContainer : CompositeDrawable + private partial class SnappingCursorContainer : CompositeDrawable { public Func GetSnapPosition; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneSelectionBlueprintDeselection.cs b/osu.Game.Tests/Visual/Editing/TestSceneSelectionBlueprintDeselection.cs new file mode 100644 index 0000000000..24f50d36a2 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneSelectionBlueprintDeselection.cs @@ -0,0 +1,75 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Tests.Beatmaps; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Editing +{ + public partial class TestSceneSelectionBlueprintDeselection : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + [Test] + public void TestSingleDeleteAtSameTime() + { + HitCircle? circle1 = null; + + AddStep("add two circles at the same time", () => + { + EditorClock.Seek(0); + circle1 = new HitCircle(); + var circle2 = new HitCircle(); + + EditorBeatmap.Add(circle1); + EditorBeatmap.Add(circle2); + + EditorBeatmap.SelectedHitObjects.Add(circle1); + EditorBeatmap.SelectedHitObjects.Add(circle2); + }); + + AddStep("delete the first circle", () => EditorBeatmap.Remove(circle1)); + AddAssert("one hitobject remains", () => EditorBeatmap.HitObjects.Count == 1); + AddAssert("one hitobject selected", () => EditorBeatmap.SelectedHitObjects.Count == 1); + } + + [Test] + public void TestBigStackDeleteAtSameTime() + { + AddStep("add 20 circles at the same time", () => + { + EditorClock.Seek(0); + + for (int i = 0; i < 20; i++) + { + EditorBeatmap.Add(new HitCircle()); + } + }); + + AddStep("select half of the circles", () => + { + foreach (var hitObject in EditorBeatmap.HitObjects.SkipLast(10).Reverse()) + { + EditorBeatmap.SelectedHitObjects.Add(hitObject); + } + }); + + AddStep("delete all selected circles", () => + { + InputManager.PressKey(Key.Delete); + InputManager.ReleaseKey(Key.Delete); + }); + + AddAssert("10 hitobjects remain", () => EditorBeatmap.HitObjects.Count == 10); + AddAssert("no hitobjects selected", () => EditorBeatmap.SelectedHitObjects.Count == 0); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneSetupScreen.cs b/osu.Game.Tests/Visual/Editing/TestSceneSetupScreen.cs index 2f6cf46b21..c0e681b8b4 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneSetupScreen.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneSetupScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; @@ -18,7 +20,7 @@ using osu.Game.Screens.Edit.Setup; namespace osu.Game.Tests.Visual.Editing { [TestFixture] - public class TestSceneSetupScreen : EditorClockTestScene + public partial class TestSceneSetupScreen : EditorClockTestScene { [Cached(typeof(EditorBeatmap))] [Cached(typeof(IBeatSnapProvider))] diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTapButton.cs b/osu.Game.Tests/Visual/Editing/TestSceneTapButton.cs index d8141619ab..8562fb746a 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTapButton.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTapButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneTapButton : OsuManualInputManagerTestScene + public partial class TestSceneTapButton : OsuManualInputManagerTestScene { private TapButton tapButton; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTapTimingControl.cs b/osu.Game.Tests/Visual/Editing/TestSceneTapTimingControl.cs index a1218aa3e7..f38df89e82 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTapTimingControl.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTapTimingControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -16,11 +18,12 @@ using osu.Game.Overlays; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Timing; using osuTK; +using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { [TestFixture] - public class TestSceneTapTimingControl : EditorClockTestScene + public partial class TestSceneTapTimingControl : EditorClockTestScene { private EditorBeatmap editorBeatmap => editorBeatmapContainer?.EditorBeatmap; @@ -111,7 +114,7 @@ namespace osu.Game.Tests.Visual.Editing .TriggerClick(); }); - AddUntilStep("wait for track playing", () => Clock.IsRunning); + AddUntilStep("wait for track playing", () => EditorClock.IsRunning); AddStep("click reset button", () => { @@ -120,7 +123,42 @@ namespace osu.Game.Tests.Visual.Editing .TriggerClick(); }); - AddUntilStep("wait for track stopped", () => !Clock.IsRunning); + AddUntilStep("wait for track stopped", () => !EditorClock.IsRunning); + } + + [Test] + public void TestNoCrashesWhenNoGroupSelected() + { + AddStep("unset selected group", () => selectedGroup.Value = null); + AddStep("press T to tap", () => InputManager.Key(Key.T)); + + AddStep("click tap button", () => + { + control.ChildrenOfType() + .Last() + .TriggerClick(); + }); + + AddStep("click reset button", () => + { + control.ChildrenOfType() + .First() + .TriggerClick(); + }); + + AddStep("adjust offset", () => + { + var adjustOffsetButton = control.ChildrenOfType().First(); + InputManager.MoveMouseTo(adjustOffsetButton); + InputManager.Click(MouseButton.Left); + }); + + AddStep("adjust BPM", () => + { + var adjustBPMButton = control.ChildrenOfType().Last(); + InputManager.MoveMouseTo(adjustBPMButton); + InputManager.Click(MouseButton.Left); + }); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineBlueprintContainer.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineBlueprintContainer.cs index 6b54bcb4f0..b63296a48d 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTimelineBlueprintContainer.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineBlueprintContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Screens.Edit.Compose.Components.Timeline; @@ -8,14 +10,14 @@ using osu.Game.Screens.Edit.Compose.Components.Timeline; namespace osu.Game.Tests.Visual.Editing { [TestFixture] - public class TestSceneTimelineBlueprintContainer : TimelineTestScene + public partial class TestSceneTimelineBlueprintContainer : TimelineTestScene { public override Drawable CreateTestComponent() => new TimelineBlueprintContainer(Composer); protected override void LoadComplete() { base.LoadComplete(); - Clock.Seek(10000); + EditorClock.Seek(10000); } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineHitObjectBlueprint.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineHitObjectBlueprint.cs index d55852ec43..709d796e97 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTimelineHitObjectBlueprint.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineHitObjectBlueprint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -16,7 +18,7 @@ using static osu.Game.Screens.Edit.Compose.Components.Timeline.TimelineHitObject namespace osu.Game.Tests.Visual.Editing { - public class TestSceneTimelineHitObjectBlueprint : TimelineTestScene + public partial class TestSceneTimelineHitObjectBlueprint : TimelineTestScene { public override Drawable CreateTestComponent() => new TimelineBlueprintContainer(Composer); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs index 81ab4712ab..50eeb9a54b 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineSelection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -18,7 +20,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneTimelineSelection : EditorTestScene + public partial class TestSceneTimelineSelection : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); @@ -27,16 +29,18 @@ namespace osu.Game.Tests.Visual.Editing private TimelineBlueprintContainer blueprintContainer => Editor.ChildrenOfType().First(); + private Vector2 getPosition(HitObject hitObject) => + blueprintContainer.SelectionBlueprints.First(s => s.Item == hitObject).ScreenSpaceDrawQuad.Centre; + + private Vector2 getMiddlePosition(HitObject hitObject1, HitObject hitObject2) => + (getPosition(hitObject1) + getPosition(hitObject2)) / 2; + private void moveMouseToObject(Func targetFunc) { AddStep("move mouse to object", () => { - var pos = blueprintContainer.SelectionBlueprints - .First(s => s.Item == targetFunc()) - .ChildrenOfType() - .First().ScreenSpaceDrawQuad.Centre; - - InputManager.MoveMouseTo(pos); + var hitObject = targetFunc(); + InputManager.MoveMouseTo(getPosition(hitObject)); }); } @@ -260,6 +264,56 @@ namespace osu.Game.Tests.Visual.Editing AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); } + [Test] + public void TestBasicDragSelection() + { + var addedObjects = new[] + { + new HitCircle { StartTime = 0 }, + new HitCircle { StartTime = 500, Position = new Vector2(100) }, + new HitCircle { StartTime = 1000, Position = new Vector2(200) }, + new HitCircle { StartTime = 1500, Position = new Vector2(300) }, + }; + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects)); + + AddStep("move mouse", () => InputManager.MoveMouseTo(getMiddlePosition(addedObjects[0], addedObjects[1]))); + AddStep("mouse down", () => InputManager.PressButton(MouseButton.Left)); + + AddStep("drag to select", () => InputManager.MoveMouseTo(getMiddlePosition(addedObjects[2], addedObjects[3]))); + assertSelectionIs(new[] { addedObjects[1], addedObjects[2] }); + + AddStep("drag to deselect", () => InputManager.MoveMouseTo(getMiddlePosition(addedObjects[1], addedObjects[2]))); + assertSelectionIs(new[] { addedObjects[1] }); + + AddStep("mouse up", () => InputManager.ReleaseButton(MouseButton.Left)); + assertSelectionIs(new[] { addedObjects[1] }); + } + + [Test] + public void TestFastDragSelection() + { + var addedObjects = new[] + { + new HitCircle { StartTime = 0 }, + new HitCircle { StartTime = 500 }, + new HitCircle { StartTime = 20000, Position = new Vector2(100) }, + new HitCircle { StartTime = 31000, Position = new Vector2(200) }, + new HitCircle { StartTime = 60000, Position = new Vector2(300) }, + }; + + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects)); + + AddStep("move mouse", () => InputManager.MoveMouseTo(getMiddlePosition(addedObjects[0], addedObjects[1]))); + AddStep("mouse down", () => InputManager.PressButton(MouseButton.Left)); + AddStep("start drag", () => InputManager.MoveMouseTo(getPosition(addedObjects[1]))); + + AddStep("jump editor clock", () => EditorClock.Seek(30000)); + AddStep("jump editor clock", () => EditorClock.Seek(60000)); + AddStep("end drag", () => InputManager.ReleaseButton(MouseButton.Left)); + assertSelectionIs(addedObjects.Skip(1)); + AddAssert("all blueprints are present", () => blueprintContainer.SelectionBlueprints.Count == EditorBeatmap.SelectedHitObjects.Count); + } + private void assertSelectionIs(IEnumerable hitObjects) => AddAssert("correct hitobjects selected", () => EditorBeatmap.SelectedHitObjects.OrderBy(h => h.StartTime).SequenceEqual(hitObjects)); } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineTickDisplay.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineTickDisplay.cs index b78512e469..41fb3ed8b9 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTimelineTickDisplay.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineTickDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Editing { [TestFixture] - public class TestSceneTimelineTickDisplay : TimelineTestScene + public partial class TestSceneTimelineTickDisplay : TimelineTestScene { public override Drawable CreateTestComponent() => Empty(); // tick display is implicitly inside the timeline. diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineZoom.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineZoom.cs index d726bd004e..19f4678c15 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTimelineZoom.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineZoom.cs @@ -1,13 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; -using osu.Framework.Utils; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneTimelineZoom : TimelineTestScene + public partial class TestSceneTimelineZoom : TimelineTestScene { public override Drawable CreateTestComponent() => Empty(); @@ -20,12 +21,12 @@ namespace osu.Game.Tests.Visual.Editing AddStep("get initial range", () => initialVisibleRange = TimelineArea.Timeline.VisibleRange); AddStep("scale zoom", () => TimelineArea.Timeline.Zoom = 200); - AddAssert("range halved", () => Precision.AlmostEquals(TimelineArea.Timeline.VisibleRange, initialVisibleRange / 2, 1)); + AddStep("range halved", () => Assert.That(TimelineArea.Timeline.VisibleRange, Is.EqualTo(initialVisibleRange / 2).Within(1))); AddStep("descale zoom", () => TimelineArea.Timeline.Zoom = 50); - AddAssert("range doubled", () => Precision.AlmostEquals(TimelineArea.Timeline.VisibleRange, initialVisibleRange * 2, 1)); + AddStep("range doubled", () => Assert.That(TimelineArea.Timeline.VisibleRange, Is.EqualTo(initialVisibleRange * 2).Within(1))); AddStep("restore zoom", () => TimelineArea.Timeline.Zoom = 100); - AddAssert("range restored", () => Precision.AlmostEquals(TimelineArea.Timeline.VisibleRange, initialVisibleRange, 1)); + AddStep("range restored", () => Assert.That(TimelineArea.Timeline.VisibleRange, Is.EqualTo(initialVisibleRange).Within(1))); } [Test] diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs index a358166477..216c35de65 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs @@ -1,14 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; using osu.Game.Overlays; using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Osu; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Timing; using osu.Game.Screens.Edit.Timing.RowAttributes; @@ -17,43 +22,51 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { [TestFixture] - public class TestSceneTimingScreen : EditorClockTestScene + public partial class TestSceneTimingScreen : EditorClockTestScene { - [Cached(typeof(EditorBeatmap))] - [Cached(typeof(IBeatSnapProvider))] - private readonly EditorBeatmap editorBeatmap; - [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); private TimingScreen timingScreen; + private EditorBeatmap editorBeatmap; protected override bool ScrollUsingMouseWheel => false; - public TestSceneTimingScreen() - { - editorBeatmap = new EditorBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)); - } - protected override void LoadComplete() { base.LoadComplete(); - Beatmap.Value = CreateWorkingBeatmap(editorBeatmap.PlayableBeatmap); + Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value); Beatmap.Disabled = true; + } - Child = timingScreen = new TimingScreen + private void reloadEditorBeatmap() + { + editorBeatmap = new EditorBeatmap(Beatmap.Value.GetPlayableBeatmap(Ruleset.Value)); + + Child = new DependencyProvidingContainer { - State = { Value = Visibility.Visible }, + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] + { + (typeof(EditorBeatmap), editorBeatmap), + (typeof(IBeatSnapProvider), editorBeatmap) + }, + Child = timingScreen = new TimingScreen + { + State = { Value = Visibility.Visible }, + }, }; } [SetUpSteps] public void SetUpSteps() { - AddStep("Stop clock", () => Clock.Stop()); + AddStep("Stop clock", () => EditorClock.Stop()); - AddUntilStep("wait for rows to load", () => Child.ChildrenOfType().Any()); + AddStep("Reload Editor Beatmap", reloadEditorBeatmap); + + AddUntilStep("Wait for rows to load", () => Child.ChildrenOfType().Any()); } [Test] @@ -66,10 +79,10 @@ namespace osu.Game.Tests.Visual.Editing }); AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 54670); - AddUntilStep("Ensure seeked to correct time", () => Clock.CurrentTimeAccurate == 54670); + AddUntilStep("Ensure seeked to correct time", () => EditorClock.CurrentTimeAccurate == 54670); - AddStep("Seek to just before next point", () => Clock.Seek(69000)); - AddStep("Start clock", () => Clock.Start()); + AddStep("Seek to just before next point", () => EditorClock.Seek(69000)); + AddStep("Start clock", () => EditorClock.Start()); AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 69670); } @@ -84,12 +97,43 @@ namespace osu.Game.Tests.Visual.Editing }); AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 54670); - AddUntilStep("Ensure seeked to correct time", () => Clock.CurrentTimeAccurate == 54670); + AddUntilStep("Ensure seeked to correct time", () => EditorClock.CurrentTimeAccurate == 54670); - AddStep("Seek to later", () => Clock.Seek(80000)); + AddStep("Seek to later", () => EditorClock.Seek(80000)); AddUntilStep("Selection changed", () => timingScreen.SelectedGroup.Value.Time == 69670); } + [Test] + public void TestScrollControlGroupIntoView() + { + AddStep("Add many control points", () => + { + editorBeatmap.ControlPointInfo.Clear(); + + editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint()); + + for (int i = 0; i < 100; i++) + { + editorBeatmap.ControlPointInfo.Add((i + 1) * 1000, new EffectControlPoint + { + KiaiMode = Convert.ToBoolean(i % 2), + }); + } + }); + + AddStep("Select first effect point", () => + { + InputManager.MoveMouseTo(Child.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + + AddStep("Seek to beginning", () => EditorClock.Seek(0)); + + AddStep("Seek to last point", () => EditorClock.Seek(101 * 1000)); + + AddUntilStep("Scrolled to end", () => timingScreen.ChildrenOfType().First().IsScrolledToEnd()); + } + protected override void Dispose(bool isDisposing) { Beatmap.Disabled = false; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneWaveform.cs b/osu.Game.Tests/Visual/Editing/TestSceneWaveform.cs index eb7e90f0a7..4c2b77bb1d 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneWaveform.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneWaveform.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Threading; using NUnit.Framework; using osu.Framework.Allocation; @@ -18,7 +20,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Editing { [TestFixture] - public class TestSceneWaveform : OsuTestScene + public partial class TestSceneWaveform : OsuTestScene { private IWorkingBeatmap waveformBeatmap; @@ -102,7 +104,7 @@ namespace osu.Game.Tests.Visual.Editing AddUntilStep("wait for load", () => graph.Loaded.IsSet); } - public class TestWaveformGraph : WaveformGraph + public partial class TestWaveformGraph : WaveformGraph { public readonly ManualResetEventSlim Loaded = new ManualResetEventSlim(); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs b/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs index 2d056bafdd..a141e4d431 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs @@ -1,6 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -18,9 +21,9 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneZoomableScrollContainer : OsuManualInputManagerTestScene + public partial class TestSceneZoomableScrollContainer : OsuManualInputManagerTestScene { - private ZoomableScrollContainer scrollContainer; + private TestZoomableScrollContainer scrollContainer; private Drawable innerBox; [SetUpSteps] @@ -44,7 +47,7 @@ namespace osu.Game.Tests.Visual.Editing RelativeSizeAxes = Axes.Both, Colour = OsuColour.Gray(30) }, - scrollContainer = new ZoomableScrollContainer + scrollContainer = new TestZoomableScrollContainer(1, 60, 1) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -52,7 +55,7 @@ namespace osu.Game.Tests.Visual.Editing } } }, - new MenuCursor() + new MenuCursorContainer() }; scrollContainer.Add(innerBox = new Box @@ -64,6 +67,18 @@ namespace osu.Game.Tests.Visual.Editing AddUntilStep("Scroll container is loaded", () => scrollContainer.LoadState >= LoadState.Loaded); } + [Test] + public void TestInitialZoomOutOfRange() + { + AddStep("Invalid ZoomableScrollContainer throws ArgumentException", () => + { + Assert.Throws(() => + { + _ = new ZoomableScrollContainer(1, 60, 0); + }); + }); + } + [Test] public void TestWidthInitialization() { @@ -78,6 +93,14 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("Inner container width matches scroll container", () => innerBox.DrawWidth == scrollContainer.DrawWidth); } + [Test] + public void TestWidthUpdatesOnSecondZoomSetup() + { + AddAssert("Inner container width = 1x", () => innerBox.DrawWidth == scrollContainer.DrawWidth); + AddStep("reload zoom", () => scrollContainer.SetupZoom(10, 10, 60)); + AddAssert("Inner container width = 10x", () => innerBox.DrawWidth == scrollContainer.DrawWidth * 10); + } + [Test] public void TestZoom0() { @@ -175,5 +198,15 @@ namespace osu.Game.Tests.Visual.Editing private Quad scrollQuad => scrollContainer.ScreenSpaceDrawQuad; private Quad boxQuad => innerBox.ScreenSpaceDrawQuad; + + private partial class TestZoomableScrollContainer : ZoomableScrollContainer + { + public TestZoomableScrollContainer(int minimum, float maximum, float initial) + : base(minimum, maximum, initial) + { + } + + public new void SetupZoom(float initial, float minimum, float maximum) => base.SetupZoom(initial, minimum, maximum); + } } } diff --git a/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs b/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs index 93bfb288d2..cb45ad5a07 100644 --- a/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs +++ b/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs @@ -1,24 +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.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose.Components.Timeline; +using osu.Game.Storyboards; using osuTK; using osuTK.Graphics; namespace osu.Game.Tests.Visual.Editing { - public abstract class TimelineTestScene : EditorClockTestScene + public abstract partial class TimelineTestScene : EditorClockTestScene { protected TimelineArea TimelineArea { get; private set; } @@ -26,10 +31,14 @@ namespace osu.Game.Tests.Visual.Editing protected EditorBeatmap EditorBeatmap { get; private set; } - [BackgroundDependencyLoader] - private void load(AudioManager audio) + [Resolved] + private AudioManager audio { get; set; } + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) => new WaveformTestBeatmap(audio); + + protected override void LoadComplete() { - Beatmap.Value = new WaveformTestBeatmap(audio); + base.LoadComplete(); var playable = Beatmap.Value.GetPlayableBeatmap(Beatmap.Value.BeatmapInfo.Ruleset); EditorBeatmap = new EditorBeatmap(playable); @@ -37,7 +46,10 @@ namespace osu.Game.Tests.Visual.Editing Dependencies.Cache(EditorBeatmap); Dependencies.CacheAs(EditorBeatmap); - Composer = playable.BeatmapInfo.Ruleset.CreateInstance().CreateHitObjectComposer().With(d => d.Alpha = 0); + Composer = playable.BeatmapInfo.Ruleset.CreateInstance().CreateHitObjectComposer(); + Debug.Assert(Composer != null); + + Composer.Alpha = 0; Add(new OsuContextMenuContainer { @@ -66,16 +78,16 @@ namespace osu.Game.Tests.Visual.Editing }); } - protected override void LoadComplete() + [SetUpSteps] + public void SetUpSteps() { - base.LoadComplete(); - - Clock.Seek(2500); + AddUntilStep("wait for track loaded", () => MusicController.TrackLoaded); + AddStep("seek forward", () => EditorClock.Seek(2500)); } public abstract Drawable CreateTestComponent(); - private class AudioVisualiser : CompositeDrawable + private partial class AudioVisualiser : CompositeDrawable { private readonly Drawable marker; @@ -114,7 +126,7 @@ namespace osu.Game.Tests.Visual.Editing } } - private class StartStopButton : OsuButton + private partial class StartStopButton : OsuButton { [Resolved] private EditorClock editorClock { get; set; } diff --git a/osu.Game.Tests/Visual/Gameplay/OsuPlayerTestScene.cs b/osu.Game.Tests/Visual/Gameplay/OsuPlayerTestScene.cs index cbf8515567..7ff059ff77 100644 --- a/osu.Game.Tests/Visual/Gameplay/OsuPlayerTestScene.cs +++ b/osu.Game.Tests/Visual/Gameplay/OsuPlayerTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; @@ -9,7 +11,7 @@ namespace osu.Game.Tests.Visual.Gameplay /// /// A with an arbitrary ruleset value to test with. /// - public abstract class OsuPlayerTestScene : PlayerTestScene + public abstract partial class OsuPlayerTestScene : PlayerTestScene { protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); } diff --git a/osu.Game.Tests/Visual/Gameplay/SkinnableHUDComponentTestScene.cs b/osu.Game.Tests/Visual/Gameplay/SkinnableHUDComponentTestScene.cs index 6de9d7c478..f54f50795e 100644 --- a/osu.Game.Tests/Visual/Gameplay/SkinnableHUDComponentTestScene.cs +++ b/osu.Game.Tests/Visual/Gameplay/SkinnableHUDComponentTestScene.cs @@ -5,10 +5,11 @@ using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; +using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Gameplay { - public abstract class SkinnableHUDComponentTestScene : SkinnableTestScene + public abstract partial class SkinnableHUDComponentTestScene : SkinnableTestScene { protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); @@ -17,9 +18,11 @@ namespace osu.Game.Tests.Visual.Gameplay { SetContents(skin => { - var implementation = skin != null + var implementation = skin is LegacySkin ? CreateLegacyImplementation() - : CreateDefaultImplementation(); + : skin is ArgonSkin + ? CreateArgonImplementation() + : CreateDefaultImplementation(); implementation.Anchor = Anchor.Centre; implementation.Origin = Anchor.Centre; @@ -28,6 +31,7 @@ namespace osu.Game.Tests.Visual.Gameplay }); protected abstract Drawable CreateDefaultImplementation(); + protected virtual Drawable CreateArgonImplementation() => CreateDefaultImplementation(); protected abstract Drawable CreateLegacyImplementation(); } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs index e10ef57a25..e86302bbd1 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Game.Configuration; @@ -18,7 +20,7 @@ namespace osu.Game.Tests.Visual.Gameplay /// A base class which runs test for all available rulesets. /// Steps to be run for each ruleset should be added via . /// - public abstract class TestSceneAllRulesetPlayers : RateAdjustedBeatmapTestScene + public abstract partial class TestSceneAllRulesetPlayers : RateAdjustedBeatmapTestScene { protected Player Player { get; private set; } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs index 346a88a2d5..f3f942b74b 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; using System.Linq; using osu.Framework.Testing; -using osu.Game.Beatmaps.Timing; using osu.Game.Graphics.Containers; using osu.Game.Rulesets; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; using osu.Game.Screens.Play.Break; @@ -17,7 +19,7 @@ using osu.Game.Users.Drawables; namespace osu.Game.Tests.Visual.Gameplay { [Description("Player instantiated with an autoplay mod.")] - public class TestSceneAutoplay : TestSceneAllRulesetPlayers + public partial class TestSceneAutoplay : TestSceneAllRulesetPlayers { protected new TestReplayPlayer Player => (TestReplayPlayer)base.Player; @@ -29,19 +31,20 @@ namespace osu.Game.Tests.Visual.Gameplay protected override void AddCheckSteps() { + // we only want this beatmap for time reference. + 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)); - seekToBreak(0); - AddAssert("keys not counting", () => !Player.HUDOverlay.KeyCounter.IsCounting); + 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.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)); - - seekToBreak(0); - seekToBreak(1); - - AddStep("seek to completion", () => Player.GameplayClockContainer.Seek(Player.DrawableRuleset.Objects.Last().GetEndTime())); + 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); AddAssert("score has combo", () => getResultsScreen().Score.Combo > 100); @@ -56,12 +59,18 @@ namespace osu.Game.Tests.Visual.Gameplay ResultsScreen getResultsScreen() => Stack.CurrentScreen as ResultsScreen; } - private void seekToBreak(int breakIndex) + private void seekTo(double time) { - AddStep($"seek to break {breakIndex}", () => Player.GameplayClockContainer.Seek(destBreak().StartTime)); - AddUntilStep("wait for seek to complete", () => Player.DrawableRuleset.FrameStableClock.CurrentTime >= destBreak().StartTime); + AddStep($"seek to {time}", () => Player.GameplayClockContainer.Seek(time)); - BreakPeriod destBreak() => Beatmap.Value.Beatmap.Breaks.ElementAt(breakIndex); + // Prevent test timeouts by seeking in 10 second increments. + for (double t = 0; t < time; t += 10000) + { + double expectedTime = t; + AddUntilStep($"current time >= {t}", () => Player.DrawableRuleset.FrameStableClock.CurrentTime >= expectedTime); + } + + AddUntilStep("wait for seek to complete", () => Player.DrawableRuleset.FrameStableClock.CurrentTime >= time); } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs index 8ca49837da..6eae795630 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osu.Game.Tests.Visual.Ranking; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneBeatmapOffsetControl : OsuTestScene + public partial class TestSceneBeatmapOffsetControl : OsuTestScene { private BeatmapOffsetControl offsetControl; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs index e9aa85f4ce..514a2d7e84 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs @@ -6,39 +6,38 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; +using osu.Framework.Graphics.Containers; using osu.Framework.Lists; using osu.Framework.Testing; using osu.Framework.Timing; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Database; -using osu.Game.Extensions; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Skinning.Legacy; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; -using osu.Game.Screens.Play.HUD; using osu.Game.Skinning; using osu.Game.Storyboards; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneBeatmapSkinFallbacks : OsuPlayerTestScene + public partial class TestSceneBeatmapSkinFallbacks : OsuPlayerTestScene { - private ISkin currentBeatmapSkin; + private ISkin currentBeatmapSkin = null!; [Resolved] - private SkinManager skinManager { get; set; } + private SkinManager skinManager { get; set; } = null!; protected override bool HasCustomSteps => true; [Test] public void TestEmptyLegacyBeatmapSkinFallsBack() { - CreateSkinTest(DefaultSkin.CreateInfo(), () => new LegacyBeatmapSkin(new BeatmapInfo(), null)); - AddUntilStep("wait for hud load", () => Player.ChildrenOfType().All(c => c.ComponentsLoaded)); - AddAssert("hud from default skin", () => AssertComponentsFromExpectedSource(SkinnableTarget.MainHUDComponents, skinManager.CurrentSkin.Value)); + CreateSkinTest(TrianglesSkin.CreateInfo(), () => new LegacyBeatmapSkin(new BeatmapInfo(), null)); + AddUntilStep("wait for hud load", () => Player.ChildrenOfType().All(c => c.ComponentsLoaded)); + AddAssert("hud from default skin", () => AssertComponentsFromExpectedSource(SkinComponentsContainerLookup.TargetArea.MainHUDComponents, skinManager.CurrentSkin.Value)); } protected void CreateSkinTest(SkinInfo gameCurrentSkin, Func getBeatmapSkin) @@ -53,17 +52,17 @@ namespace osu.Game.Tests.Visual.Gameplay }); } - protected bool AssertComponentsFromExpectedSource(SkinnableTarget target, ISkin expectedSource) + protected bool AssertComponentsFromExpectedSource(SkinComponentsContainerLookup.TargetArea target, ISkin expectedSource) { - var actualComponentsContainer = Player.ChildrenOfType().First(s => s.Target == target) - .ChildrenOfType().SingleOrDefault(); + var targetContainer = Player.ChildrenOfType().First(s => s.Lookup.Target == target); + var actualComponentsContainer = targetContainer.ChildrenOfType().SingleOrDefault(c => c.Parent == targetContainer); if (actualComponentsContainer == null) return false; - var actualInfo = actualComponentsContainer.CreateSkinnableInfo(); + var actualInfo = actualComponentsContainer.CreateSerialisedInfo(); - var expectedComponentsContainer = (SkinnableTargetComponentsContainer)expectedSource.GetDrawableComponent(new SkinnableTargetComponent(target)); + var expectedComponentsContainer = expectedSource.GetDrawableComponent(new SkinComponentsContainerLookup(target)) as Container; if (expectedComponentsContainer == null) return false; @@ -78,29 +77,29 @@ namespace osu.Game.Tests.Visual.Gameplay (typeof(ScoreProcessor), actualComponentsContainer.Dependencies.Get()), (typeof(HealthProcessor), actualComponentsContainer.Dependencies.Get()), (typeof(GameplayState), actualComponentsContainer.Dependencies.Get()), - (typeof(GameplayClock), actualComponentsContainer.Dependencies.Get()) + (typeof(IGameplayClock), actualComponentsContainer.Dependencies.Get()) }, }; Add(expectedComponentsAdjustmentContainer); expectedComponentsAdjustmentContainer.UpdateSubTree(); - var expectedInfo = expectedComponentsContainer.CreateSkinnableInfo(); - Remove(expectedComponentsAdjustmentContainer); + var expectedInfo = expectedComponentsContainer.CreateSerialisedInfo(); + Remove(expectedComponentsAdjustmentContainer, true); return almostEqual(actualInfo, expectedInfo); } - private static bool almostEqual(SkinnableInfo info, SkinnableInfo other) => + private static bool almostEqual(SerialisedDrawableInfo drawableInfo, SerialisedDrawableInfo? other) => other != null - && info.Type == other.Type - && info.Anchor == other.Anchor - && info.Origin == other.Origin - && Precision.AlmostEquals(info.Position, other.Position, 1) - && Precision.AlmostEquals(info.Scale, other.Scale) - && Precision.AlmostEquals(info.Rotation, other.Rotation) - && info.Children.SequenceEqual(other.Children, new FuncEqualityComparer(almostEqual)); + && drawableInfo.Type == other.Type + && drawableInfo.Anchor == other.Anchor + && drawableInfo.Origin == other.Origin + && Precision.AlmostEquals(drawableInfo.Position, other.Position, 1) + && Precision.AlmostEquals(drawableInfo.Scale, other.Scale) + && Precision.AlmostEquals(drawableInfo.Rotation, other.Rotation) + && drawableInfo.Children.SequenceEqual(other.Children, new FuncEqualityComparer(almostEqual)); - protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null) => new CustomSkinWorkingBeatmap(beatmap, storyboard, Clock, Audio, currentBeatmapSkin); protected override Ruleset CreatePlayerRuleset() => new TestOsuRuleset(); @@ -109,7 +108,7 @@ namespace osu.Game.Tests.Visual.Gameplay { private readonly ISkin beatmapSkin; - public CustomSkinWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard, IFrameBasedClock referenceClock, AudioManager audio, ISkin beatmapSkin) + public CustomSkinWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard, IFrameBasedClock referenceClock, AudioManager audio, ISkin beatmapSkin) : base(beatmap, storyboard, referenceClock, audio) { this.beatmapSkin = beatmapSkin; @@ -120,7 +119,7 @@ namespace osu.Game.Tests.Visual.Gameplay private class TestOsuRuleset : OsuRuleset { - public override ISkin CreateLegacySkinProvider(ISkin skin, IBeatmap beatmap) => new TestOsuLegacySkinTransformer(skin); + public override ISkin CreateSkinTransformer(ISkin skin, IBeatmap beatmap) => new TestOsuLegacySkinTransformer(skin); private class TestOsuLegacySkinTransformer : OsuLegacySkinTransformer { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBezierConverter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBezierConverter.cs new file mode 100644 index 0000000000..a40eab5948 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBezierConverter.cs @@ -0,0 +1,190 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Lines; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osuTK; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public partial class TestSceneBezierConverter : OsuTestScene + { + private readonly SmoothPath drawablePath; + private readonly SmoothPath controlPointDrawablePath; + private readonly SmoothPath convertedDrawablePath; + private readonly SmoothPath convertedControlPointDrawablePath; + + private SliderPath path = null!; + private SliderPath convertedPath = null!; + + public TestSceneBezierConverter() + { + Children = new Drawable[] + { + new Container + { + Children = + new Drawable[] + { + drawablePath = new SmoothPath(), + controlPointDrawablePath = new SmoothPath + { + Colour = Colour4.Magenta, + PathRadius = 1f + } + }, + Position = new Vector2(100) + }, + new Container + { + Children = + new Drawable[] + { + convertedDrawablePath = new SmoothPath(), + convertedControlPointDrawablePath = new SmoothPath + { + Colour = Colour4.Magenta, + PathRadius = 1f + } + }, + Position = new Vector2(100, 300) + } + }; + + resetPath(); + } + + [SetUp] + public void Setup() => Schedule(resetPath); + + private void resetPath() + { + path = new SliderPath(); + convertedPath = new SliderPath(); + + path.Version.ValueChanged += getConvertedControlPoints; + } + + private void getConvertedControlPoints(ValueChangedEvent obj) + { + convertedPath.ControlPoints.Clear(); + convertedPath.ControlPoints.AddRange(BezierConverter.ConvertToModernBezier(path.ControlPoints)); + } + + protected override void Update() + { + base.Update(); + + List vertices = new List(); + + path.GetPathToProgress(vertices, 0, 1); + + drawablePath.Vertices = vertices; + controlPointDrawablePath.Vertices = path.ControlPoints.Select(o => o.Position).ToList(); + + if (controlPointDrawablePath.Vertices.Count > 0) + { + controlPointDrawablePath.Position = + drawablePath.PositionInBoundingBox(drawablePath.Vertices[0]) - controlPointDrawablePath.PositionInBoundingBox(controlPointDrawablePath.Vertices[0]); + } + + vertices.Clear(); + + convertedPath.GetPathToProgress(vertices, 0, 1); + + convertedDrawablePath.Vertices = vertices; + convertedControlPointDrawablePath.Vertices = convertedPath.ControlPoints.Select(o => o.Position).ToList(); + + if (convertedControlPointDrawablePath.Vertices.Count > 0) + { + convertedControlPointDrawablePath.Position = convertedDrawablePath.PositionInBoundingBox(convertedDrawablePath.Vertices[0]) + - convertedControlPointDrawablePath.PositionInBoundingBox(convertedControlPointDrawablePath.Vertices[0]); + } + } + + [Test] + public void TestEmptyPath() + { + } + + [TestCase(PathType.Linear)] + [TestCase(PathType.Bezier)] + [TestCase(PathType.Catmull)] + [TestCase(PathType.PerfectCurve)] + public void TestSingleSegment(PathType type) + => AddStep("create path", () => path.ControlPoints.AddRange(createSegment(type, Vector2.Zero, new Vector2(0, 100), new Vector2(100)))); + + [TestCase(PathType.Linear)] + [TestCase(PathType.Bezier)] + [TestCase(PathType.Catmull)] + [TestCase(PathType.PerfectCurve)] + public void TestMultipleSegment(PathType type) + { + AddStep("create path", () => + { + path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero)); + path.ControlPoints.AddRange(createSegment(type, new Vector2(0, 100), new Vector2(100), Vector2.Zero)); + }); + } + + [Test] + public void TestComplex() + { + AddStep("create path", () => + { + path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(100, 0))); + path.ControlPoints.AddRange(createSegment(PathType.Bezier, new Vector2(100, 0), new Vector2(150, 30), new Vector2(100, 100))); + path.ControlPoints.AddRange(createSegment(PathType.PerfectCurve, new Vector2(100, 100), new Vector2(25, 50), Vector2.Zero)); + }); + } + + [TestCase(0, 100)] + [TestCase(1, 100)] + [TestCase(5, 100)] + [TestCase(10, 100)] + [TestCase(30, 100)] + [TestCase(50, 100)] + [TestCase(100, 100)] + [TestCase(100, 1)] + public void TestPerfectCurveAngles(float height, float width) + { + AddStep("create path", () => + { + path.ControlPoints.AddRange(createSegment(PathType.PerfectCurve, Vector2.Zero, new Vector2(width / 2, height), new Vector2(width, 0))); + }); + } + + [TestCase(2)] + [TestCase(4)] + public void TestPerfectCurveFallbackScenarios(int points) + { + AddStep("create path", () => + { + switch (points) + { + case 2: + path.ControlPoints.AddRange(createSegment(PathType.PerfectCurve, Vector2.Zero, new Vector2(0, 100))); + break; + + case 4: + path.ControlPoints.AddRange(createSegment(PathType.PerfectCurve, Vector2.Zero, new Vector2(0, 100), new Vector2(100), new Vector2(100, 0))); + break; + } + }); + } + + private List createSegment(PathType type, params Vector2[] controlPoints) + { + var points = controlPoints.Select(p => new PathControlPoint { Position = p }).ToList(); + points[0].Type = type; + return points; + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs index be17721b88..c010b2c809 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -12,7 +14,7 @@ using osu.Game.Screens.Play; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] - public class TestSceneBreakTracker : OsuTestScene + public partial class TestSceneBreakTracker : OsuTestScene { private readonly BreakOverlay breakOverlay; @@ -159,7 +161,7 @@ namespace osu.Game.Tests.Visual.Gameplay }); } - private class TestBreakTracker : BreakTracker + private partial class TestBreakTracker : BreakTracker { public readonly FramedClock FramedManualClock; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs new file mode 100644 index 0000000000..6b8e0e1088 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs @@ -0,0 +1,130 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD.ClicksPerSecond; +using osuTK; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public partial class TestSceneClicksPerSecondCalculator : OsuTestScene + { + private ClicksPerSecondCalculator calculator = null!; + + private TestGameplayClock manualGameplayClock = null!; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create components", () => + { + manualGameplayClock = new TestGameplayClock(); + + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] { (typeof(IGameplayClock), manualGameplayClock) }, + Children = new Drawable[] + { + calculator = new ClicksPerSecondCalculator(), + new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] { (typeof(ClicksPerSecondCalculator), calculator) }, + Child = new ClicksPerSecondCounter + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(5), + } + } + }, + }; + }); + } + + [Test] + public void TestBasicConsistency() + { + seek(1000); + AddStep("add inputs in past", () => addInputs(new double[] { 0, 100, 200, 300, 400, 500, 600, 700, 800, 900 })); + checkClicksPerSecondValue(10); + } + + [Test] + public void TestRateAdjustConsistency() + { + seek(1000); + AddStep("add inputs in past", () => addInputs(new double[] { 0, 100, 200, 300, 400, 500, 600, 700, 800, 900 })); + checkClicksPerSecondValue(10); + AddStep("set rate 0.5x", () => manualGameplayClock.TrueGameplayRate = 0.5); + checkClicksPerSecondValue(5); + } + + [Test] + public void TestInputsDiscardedOnRewind() + { + seek(1000); + AddStep("add inputs in past", () => addInputs(new double[] { 0, 100, 200, 300, 400, 500, 600, 700, 800, 900 })); + checkClicksPerSecondValue(10); + seek(500); + checkClicksPerSecondValue(6); + seek(1000); + checkClicksPerSecondValue(6); + } + + private void checkClicksPerSecondValue(int i) => AddAssert("clicks/s is correct", () => calculator.Value, () => Is.EqualTo(i)); + + private void seekClockImmediately(double time) => manualGameplayClock.CurrentTime = time; + + private void seek(double time) => AddStep($"Seek to {time}ms", () => seekClockImmediately(time)); + + private void addInputs(IEnumerable inputs) + { + double baseTime = manualGameplayClock.CurrentTime; + + foreach (double timestamp in inputs) + { + seekClockImmediately(timestamp); + calculator.AddInputTimestamp(); + } + + seekClockImmediately(baseTime); + } + + private class TestGameplayClock : IGameplayClock + { + public double CurrentTime { get; set; } + + public double Rate => 1; + + public bool IsRunning => true; + + public double TrueGameplayRate { set => adjustableAudioComponent.Tempo.Value = value; } + + private readonly AudioAdjustments adjustableAudioComponent = new AudioAdjustments(); + + public void ProcessFrame() + { + } + + public double ElapsedFrameTime => throw new NotImplementedException(); + public double FramesPerSecond => throw new NotImplementedException(); + public FrameTimeInfo TimeInfo => throw new NotImplementedException(); + public double StartTime => throw new NotImplementedException(); + + public IAdjustableAudioComponent AdjustmentsFromMods => adjustableAudioComponent; + + public IEnumerable NonGameplayAdjustments => throw new NotImplementedException(); + public IBindable IsPaused => throw new NotImplementedException(); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneColourHitErrorMeter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneColourHitErrorMeter.cs new file mode 100644 index 0000000000..0784aac298 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneColourHitErrorMeter.cs @@ -0,0 +1,117 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD.HitErrorMeters; +using osuTK; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public partial class TestSceneColourHitErrorMeter : OsuTestScene + { + private DependencyProvidingContainer dependencyContainer = null!; + + private readonly Bindable lastJudgementResult = new Bindable(); + private ScoreProcessor scoreProcessor = null!; + + private int iteration; + + private ColourHitErrorMeter colourHitErrorMeter = null!; + + public TestSceneColourHitErrorMeter() + { + AddSliderStep("Judgement spacing", 0, 10, 2, spacing => + { + if (colourHitErrorMeter.IsNotNull()) + colourHitErrorMeter.JudgementSpacing.Value = spacing; + }); + + AddSliderStep("Judgement count", 1, 50, 5, spacing => + { + if (colourHitErrorMeter.IsNotNull()) + colourHitErrorMeter.JudgementCount.Value = spacing; + }); + } + + [SetUpSteps] + public void SetupSteps() => AddStep("Create components", () => + { + var ruleset = CreateRuleset(); + + Debug.Assert(ruleset != null); + + scoreProcessor = new ScoreProcessor(ruleset); + Child = dependencyContainer = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] + { + (typeof(ScoreProcessor), scoreProcessor) + } + }; + dependencyContainer.Child = colourHitErrorMeter = new ColourHitErrorMeter + { + Margin = new MarginPadding + { + Top = 100 + }, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Scale = new Vector2(2), + }; + }); + + protected override Ruleset CreateRuleset() => new OsuRuleset(); + + [Test] + public void TestSpacingChange() + { + AddRepeatStep("Add judgement", applyOneJudgement, 5); + AddStep("Change spacing", () => colourHitErrorMeter.JudgementSpacing.Value = 10); + AddRepeatStep("Add judgement", applyOneJudgement, 5); + } + + [Test] + public void TestJudgementAmountChange() + { + AddRepeatStep("Add judgement", applyOneJudgement, 10); + AddStep("Judgement count change to 4", () => colourHitErrorMeter.JudgementCount.Value = 4); + AddRepeatStep("Add judgement", applyOneJudgement, 8); + } + + [Test] + public void TestHitErrorShapeChange() + { + AddRepeatStep("Add judgement", applyOneJudgement, 8); + AddStep("Change shape square", () => colourHitErrorMeter.JudgementShape.Value = ColourHitErrorMeter.ShapeStyle.Square); + AddRepeatStep("Add judgement", applyOneJudgement, 10); + AddStep("Change shape circle", () => colourHitErrorMeter.JudgementShape.Value = ColourHitErrorMeter.ShapeStyle.Circle); + } + + private void applyOneJudgement() + { + lastJudgementResult.Value = new OsuJudgementResult(new HitObject + { + StartTime = iteration * 10000, + }, new OsuJudgement()) + { + Type = HitResult.Great, + }; + scoreProcessor.ApplyResult(lastJudgementResult.Value); + + iteration++; + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneCompletionCancellation.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneCompletionCancellation.cs index 11bd701e19..434d853992 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneCompletionCancellation.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneCompletionCancellation.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -17,7 +19,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneCompletionCancellation : OsuPlayerTestScene + public partial class TestSceneCompletionCancellation : OsuPlayerTestScene { [Resolved] private AudioManager audio { get; set; } @@ -59,6 +61,16 @@ namespace osu.Game.Tests.Visual.Gameplay /// Tests whether can still pause after cancelling completion by reverting back to true. /// [Test] + [FlakyTest] + /* + * Fail rate around 0.45% + * + * TearDown : System.TimeoutException : "completion set by processor" timed out + * --TearDown + * at osu.Framework.Testing.Drawables.Steps.UntilStepButton.<>c__DisplayClass11_0.<.ctor>b__0() + * at osu.Framework.Testing.Drawables.Steps.StepButton.PerformStep(Boolean userTriggered) + * at osu.Framework.Testing.TestScene.runNextStep(Action onCompletion, Action`1 onError, Func`2 stopCondition) + */ public void TestCanPauseAfterCancellation() { complete(); @@ -110,7 +122,7 @@ namespace osu.Game.Tests.Visual.Gameplay protected override TestPlayer CreatePlayer(Ruleset ruleset) => new FakeRankingPushPlayer(); - public class FakeRankingPushPlayer : TestPlayer + public partial class FakeRankingPushPlayer : TestPlayer { public bool ResultsCreated { get; private set; } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneDefaultSongProgressGraph.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneDefaultSongProgressGraph.cs new file mode 100644 index 0000000000..66671a506f --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneDefaultSongProgressGraph.cs @@ -0,0 +1,73 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using System.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Rulesets.Objects; +using osu.Game.Screens.Play.HUD; + +namespace osu.Game.Tests.Visual.Gameplay +{ + [TestFixture] + public partial class TestSceneDefaultSongProgressGraph : OsuTestScene + { + private TestSongProgressGraph graph; + + [SetUpSteps] + public void SetupSteps() + { + AddStep("add new big graph", () => + { + if (graph != null) + { + graph.Expire(); + graph = null; + } + + Add(graph = new TestSongProgressGraph + { + RelativeSizeAxes = Axes.X, + Height = 200, + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + }); + }); + } + + [Test] + public void TestGraphRecreation() + { + AddAssert("ensure not created", () => graph.CreationCount == 0); + AddStep("display values", displayRandomValues); + AddUntilStep("wait for creation count", () => graph.CreationCount == 1); + AddRepeatStep("new values", displayRandomValues, 5); + AddWaitStep("wait some", 5); + AddAssert("ensure recreation debounced", () => graph.CreationCount == 2); + } + + private void displayRandomValues() + { + var objects = new List(); + for (double i = 0; i < 5000; i += RNG.NextDouble() * 10 + i / 1000) + objects.Add(new HitObject { StartTime = i }); + + graph.Objects = objects; + } + + private partial class TestSongProgressGraph : DefaultSongProgressGraph + { + public int CreationCount { get; private set; } + + protected override void RecreateGraph() + { + base.RecreateGraph(); + CreationCount++; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs index e1755b8fc3..287b7d43b4 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -31,7 +33,7 @@ using JetBrains.Annotations; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneDrawableScrollingRuleset : OsuTestScene + public partial class TestSceneDrawableScrollingRuleset : OsuTestScene { /// /// The amount of time visible by the "view window" of the playfield. @@ -261,27 +263,30 @@ namespace osu.Game.Tests.Visual.Gameplay return beatmap; } - private void createTest(IBeatmap beatmap, Action overrideAction = null) => AddStep("create test", () => + private void createTest(IBeatmap beatmap, Action overrideAction = null) { - var ruleset = new TestScrollingRuleset(); - - drawableRuleset = (TestDrawableScrollingRuleset)ruleset.CreateDrawableRulesetWith(CreateWorkingBeatmap(beatmap).GetPlayableBeatmap(ruleset.RulesetInfo)); - drawableRuleset.FrameStablePlayback = false; - - overrideAction?.Invoke(drawableRuleset); - - Child = new Container + AddStep("create test", () => { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Y, - Height = 0.75f, - Width = 400, - Masking = true, - Clock = new FramedClock(testClock), - Child = drawableRuleset - }; - }); + var ruleset = new TestScrollingRuleset(); + + drawableRuleset = (TestDrawableScrollingRuleset)ruleset.CreateDrawableRulesetWith(CreateWorkingBeatmap(beatmap).GetPlayableBeatmap(ruleset.RulesetInfo)); + drawableRuleset.FrameStablePlayback = false; + + overrideAction?.Invoke(drawableRuleset); + + Child = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Height = 0.75f, + Width = 400, + Masking = true, + Clock = new FramedClock(testClock), + Child = drawableRuleset + }; + }); + } #region Ruleset @@ -300,7 +305,7 @@ namespace osu.Game.Tests.Visual.Gameplay public override string ShortName { get; } = string.Empty; } - private class TestDrawableScrollingRuleset : DrawableScrollingRuleset + private partial class TestDrawableScrollingRuleset : DrawableScrollingRuleset { public bool RelativeScaleBeatLengthsOverride { get; set; } @@ -320,8 +325,8 @@ namespace osu.Game.Tests.Visual.Gameplay { switch (h) { - case TestPooledHitObject _: - case TestPooledParentHitObject _: + case TestPooledHitObject: + case TestPooledParentHitObject: return null; case TestParentHitObject p: @@ -337,7 +342,7 @@ namespace osu.Game.Tests.Visual.Gameplay protected override Playfield CreatePlayfield() => new TestPlayfield(); } - private class TestPlayfield : ScrollingPlayfield + private partial class TestPlayfield : ScrollingPlayfield { public TestPlayfield() { @@ -422,7 +427,7 @@ namespace osu.Game.Tests.Visual.Gameplay } } - private class DrawableTestHitObject : DrawableHitObject + private partial class DrawableTestHitObject : DrawableHitObject { public DrawableTestHitObject([CanBeNull] TestHitObject hitObject) : base(hitObject) @@ -452,7 +457,7 @@ namespace osu.Game.Tests.Visual.Gameplay protected override void Update() => LifetimeEnd = HitObject.EndTime; } - private class DrawableTestPooledHitObject : DrawableTestHitObject + private partial class DrawableTestPooledHitObject : DrawableTestHitObject { public DrawableTestPooledHitObject() : base(null) @@ -462,7 +467,7 @@ namespace osu.Game.Tests.Visual.Gameplay } } - private class DrawableTestParentHitObject : DrawableTestHitObject + private partial class DrawableTestParentHitObject : DrawableTestHitObject { private readonly Container container; @@ -486,7 +491,7 @@ namespace osu.Game.Tests.Visual.Gameplay protected override void ClearNestedHitObjects() => container.Clear(false); } - private class DrawableTestPooledParentHitObject : DrawableTestParentHitObject + private partial class DrawableTestPooledParentHitObject : DrawableTestParentHitObject { public DrawableTestPooledParentHitObject() : base(null) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs index b2f4fa2738..ec4bb1a86b 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs @@ -17,7 +17,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneDrawableStoryboardSprite : SkinnableTestScene + public partial class TestSceneDrawableStoryboardSprite : SkinnableTestScene { protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); @@ -71,6 +71,18 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("origin flipped", () => sprites.All(s => s.Origin == Anchor.BottomRight)); } + [Test] + public void TestZeroScale() + { + const string lookup_name = "hitcircleoverlay"; + + AddStep("allow skin lookup", () => storyboard.UseSkinSprites = true); + AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero))); + AddAssert("sprites present", () => sprites.All(s => s.IsPresent)); + AddStep("scale sprite", () => sprites.ForEach(s => s.VectorScale = new Vector2(0, 1))); + AddAssert("sprites not present", () => sprites.All(s => !s.IsPresent)); + } + [Test] public void TestNegativeScale() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs index 83d7d769df..6cb1101173 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Framework.Graphics.Containers; @@ -11,7 +13,7 @@ using osu.Game.Screens.Play; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneFailAnimation : TestSceneAllRulesetPlayers + public partial class TestSceneFailAnimation : TestSceneAllRulesetPlayers { protected override Player CreatePlayer(Ruleset ruleset) { @@ -37,7 +39,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("frequency only ever decreased", () => !((FailPlayer)Player).FrequencyIncreased); } - private class FailPlayer : TestPlayer + private partial class FailPlayer : TestPlayer { public new FailOverlay FailOverlay => base.FailOverlay; @@ -51,7 +53,7 @@ namespace osu.Game.Tests.Visual.Gameplay protected override void LoadComplete() { base.LoadComplete(); - HealthProcessor.FailConditions += (_, __) => true; + HealthProcessor.FailConditions += (_, _) => true; } private double lastFrequency = double.MaxValue; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFailJudgement.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFailJudgement.cs index bf491db45a..e779c6c1cb 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFailJudgement.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFailJudgement.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using osu.Game.Rulesets; @@ -11,7 +13,7 @@ using osu.Game.Screens.Play; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneFailJudgement : TestSceneAllRulesetPlayers + public partial class TestSceneFailJudgement : TestSceneAllRulesetPlayers { protected override Player CreatePlayer(Ruleset ruleset) { @@ -35,7 +37,7 @@ namespace osu.Game.Tests.Visual.Gameplay }); } - private class FailPlayer : TestPlayer + private partial class FailPlayer : TestPlayer { public new HealthProcessor HealthProcessor => base.HealthProcessor; @@ -47,7 +49,7 @@ namespace osu.Game.Tests.Visual.Gameplay protected override void LoadComplete() { base.LoadComplete(); - HealthProcessor.FailConditions += (_, __) => true; + HealthProcessor.FailConditions += (_, _) => true; } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs index c335f7c99e..235ada2d63 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -14,7 +16,7 @@ using osu.Game.Screens.Play.HUD; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneFailingLayer : OsuTestScene + public partial class TestSceneFailingLayer : OsuTestScene { private FailingLayer layer; @@ -109,7 +111,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("layer fade is visible", () => layer.IsPresent); } - private class HealthProcessorContainer : Container + private partial class HealthProcessorContainer : Container { [Cached(typeof(HealthProcessor))] private readonly HealthProcessor healthProcessor; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs index ae0decaee1..534348bed3 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,7 +12,7 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneFrameStabilityContainer : OsuTestScene + public partial class TestSceneFrameStabilityContainer : OsuTestScene { private readonly ManualClock manualClock; @@ -135,15 +137,15 @@ namespace osu.Game.Tests.Visual.Gameplay private void seekManualTo(double time) => AddStep($"seek manual clock to {time}", () => manualClock.CurrentTime = time); - private void confirmSeek(double time) => AddUntilStep($"wait for seek to {time}", () => consumer.Clock.CurrentTime == time); + private void confirmSeek(double time) => AddUntilStep($"wait for seek to {time}", () => consumer.Clock.CurrentTime, () => Is.EqualTo(time)); private void checkFrameCount(int frames) => - AddAssert($"elapsed frames is {frames}", () => consumer.ElapsedFrames == frames); + AddAssert($"elapsed frames is {frames}", () => consumer.ElapsedFrames, () => Is.EqualTo(frames)); private void checkRate(double rate) => - AddAssert($"clock rate is {rate}", () => consumer.Clock.Rate == rate); + AddAssert($"clock rate is {rate}", () => consumer.Clock.Rate, () => Is.EqualTo(rate)); - public class ClockConsumingChild : CompositeDrawable + public partial class ClockConsumingChild : CompositeDrawable { private readonly OsuSpriteText text; private readonly OsuSpriteText text2; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs index 74212de210..d4000c07e7 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayLeaderboard.cs @@ -1,10 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Bindables; +using osu.Framework.Extensions.PolygonExtensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Online.API.Requests.Responses; @@ -14,41 +18,66 @@ using osuTK; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] - public class TestSceneGameplayLeaderboard : OsuTestScene + public partial class TestSceneGameplayLeaderboard : OsuTestScene { - private readonly TestGameplayLeaderboard leaderboard; + private TestGameplayLeaderboard leaderboard; - private readonly BindableDouble playerScore = new BindableDouble(); + private readonly BindableLong playerScore = new BindableLong(); public TestSceneGameplayLeaderboard() { - Add(leaderboard = new TestGameplayLeaderboard + AddStep("toggle expanded", () => { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Scale = new Vector2(2), + if (leaderboard != null) + leaderboard.Expanded.Value = !leaderboard.Expanded.Value; }); + + AddSliderStep("set player score", 50, 5000000, 1222333, v => playerScore.Value = v); } - [SetUpSteps] - public void SetUpSteps() + [Test] + public void TestLayoutWithManyScores() { - AddStep("reset leaderboard", () => + createLeaderboard(); + + AddStep("add many scores in one go", () => { - leaderboard.Clear(); - playerScore.Value = 1222333; + for (int i = 0; i < 32; i++) + createRandomScore(new APIUser { Username = $"Player {i + 1}" }); + + // Add player at end to force an animation down the whole list. + playerScore.Value = 0; + createLeaderboardScore(playerScore, new APIUser { Username = "You", Id = 3 }, true); }); - AddStep("add local player", () => createLeaderboardScore(playerScore, new APIUser { Username = "You", Id = 3 }, true)); - AddStep("toggle expanded", () => leaderboard.Expanded.Value = !leaderboard.Expanded.Value); - AddSliderStep("set player score", 50, 5000000, 1222333, v => playerScore.Value = v); + // Gameplay leaderboard has custom scroll logic, which when coupled with LayoutDuration + // has caused layout to not work in the past. + + AddUntilStep("wait for fill flow layout", + () => leaderboard.ChildrenOfType>().First().ScreenSpaceDrawQuad.Intersects(leaderboard.ScreenSpaceDrawQuad)); + + AddUntilStep("wait for some scores not masked away", + () => leaderboard.ChildrenOfType().Any(s => leaderboard.ScreenSpaceDrawQuad.Contains(s.ScreenSpaceDrawQuad.Centre))); + + AddUntilStep("wait for tracked score fully visible", () => leaderboard.ScreenSpaceDrawQuad.Intersects(leaderboard.TrackedScore!.ScreenSpaceDrawQuad)); + + AddStep("change score to middle", () => playerScore.Value = 1000000); + AddWaitStep("wait for movement", 5); + AddUntilStep("wait for tracked score fully visible", () => leaderboard.ScreenSpaceDrawQuad.Intersects(leaderboard.TrackedScore!.ScreenSpaceDrawQuad)); + + AddStep("change score to first", () => playerScore.Value = 5000000); + AddWaitStep("wait for movement", 5); + AddUntilStep("wait for tracked score fully visible", () => leaderboard.ScreenSpaceDrawQuad.Intersects(leaderboard.TrackedScore!.ScreenSpaceDrawQuad)); } [Test] public void TestPlayerScore() { - var player2Score = new BindableDouble(1234567); - var player3Score = new BindableDouble(1111111); + createLeaderboard(); + addLocalPlayer(); + + var player2Score = new BindableLong(1234567); + var player3Score = new BindableLong(1111111); AddStep("add player 2", () => createLeaderboardScore(player2Score, new APIUser { Username = "Player 2" })); AddStep("add player 3", () => createLeaderboardScore(player3Score, new APIUser { Username = "Player 3" })); @@ -71,6 +100,9 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestRandomScores() { + createLeaderboard(); + addLocalPlayer(); + int playerNumber = 1; AddRepeatStep("add player with random score", () => createRandomScore(new APIUser { Username = $"Player {playerNumber++}" }), 10); } @@ -78,6 +110,9 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestExistingUsers() { + createLeaderboard(); + addLocalPlayer(); + AddStep("add peppy", () => createRandomScore(new APIUser { Username = "peppy", Id = 2 })); AddStep("add smoogipoo", () => createRandomScore(new APIUser { Username = "smoogipoo", Id = 1040328 })); AddStep("add flyte", () => createRandomScore(new APIUser { Username = "flyte", Id = 3103765 })); @@ -87,6 +122,9 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestMaxHeight() { + createLeaderboard(); + addLocalPlayer(); + int playerNumber = 1; AddRepeatStep("add 3 other players", () => createRandomScore(new APIUser { Username = $"Player {playerNumber++}" }), 3); checkHeight(4); @@ -101,15 +139,37 @@ namespace osu.Game.Tests.Visual.Gameplay => AddAssert($"leaderboard height is {panelCount} panels high", () => leaderboard.DrawHeight == (GameplayLeaderboardScore.PANEL_HEIGHT + leaderboard.Spacing) * panelCount); } - private void createRandomScore(APIUser user) => createLeaderboardScore(new BindableDouble(RNG.Next(0, 5_000_000)), user); + private void addLocalPlayer() + { + AddStep("add local player", () => + { + playerScore.Value = 1222333; + createLeaderboardScore(playerScore, new APIUser { Username = "You", Id = 3 }, true); + }); + } - private void createLeaderboardScore(BindableDouble score, APIUser user, bool isTracked = false) + private void createLeaderboard() + { + AddStep("create leaderboard", () => + { + Child = leaderboard = new TestGameplayLeaderboard + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(2), + }; + }); + } + + private void createRandomScore(APIUser user) => createLeaderboardScore(new BindableLong(RNG.Next(0, 5_000_000)), user); + + private void createLeaderboardScore(BindableLong score, APIUser user, bool isTracked = false) { var leaderboardScore = leaderboard.Add(user, isTracked); leaderboardScore.TotalScore.BindTo(score); } - private class TestGameplayLeaderboard : GameplayLeaderboard + private partial class TestGameplayLeaderboard : GameplayLeaderboard { public float Spacing => Flow.Spacing.Y; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayMenuOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayMenuOverlay.cs index abd43e7427..aa5e5985c3 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayMenuOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayMenuOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -17,7 +19,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { [Description("player pause/fail screens")] - public class TestSceneGameplayMenuOverlay : OsuManualInputManagerTestScene + public partial class TestSceneGameplayMenuOverlay : OsuManualInputManagerTestScene { private FailOverlay failOverlay; private PauseOverlay pauseOverlay; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs index 814b41cdbc..751aeb4e13 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -15,7 +17,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneGameplayRewinding : OsuPlayerTestScene + public partial class TestSceneGameplayRewinding : OsuPlayerTestScene { [Resolved] private AudioManager audioManager { get; set; } @@ -29,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.All(kc => kc.CountPresses >= 7)); + 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/TestSceneGameplaySamplePlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs index 815cc09448..3d35860fef 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs @@ -14,27 +14,27 @@ using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneGameplaySamplePlayback : PlayerTestScene + public partial class TestSceneGameplaySamplePlayback : PlayerTestScene { [Test] public void TestAllSamplesStopDuringSeek() { - DrawableSlider slider = null; - PoolableSkinnableSample[] samples = null; - ISamplePlaybackDisabler sampleDisabler = null; + DrawableSlider? slider = null; + PoolableSkinnableSample[] samples = null!; + ISamplePlaybackDisabler sampleDisabler = null!; AddUntilStep("get variables", () => { sampleDisabler = Player; slider = Player.ChildrenOfType().MinBy(s => s.HitObject.StartTime); - samples = slider?.ChildrenOfType().ToArray(); + samples = slider.ChildrenOfType().ToArray(); return slider != null; }); AddUntilStep("wait for slider sliding then seek", () => { - if (!slider.Tracking.Value) + if (slider?.Tracking.Value != true) return false; if (!samples.Any(s => s.Playing)) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs index ac39395567..31133f00d9 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs @@ -1,48 +1,67 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Diagnostics; using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Timing; +using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Legacy; using osu.Game.Rulesets; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.UI; +using osu.Game.Storyboards; +using osuTK; using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneGameplaySampleTriggerSource : PlayerTestScene + public partial class TestSceneGameplaySampleTriggerSource : PlayerTestScene { - private TestGameplaySampleTriggerSource sampleTriggerSource; + private TestGameplaySampleTriggerSource sampleTriggerSource = null!; protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); - private Beatmap beatmap; + private Beatmap beatmap = null!; + + [Resolved] + private AudioManager audio { get; set; } = null!; + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null) + => new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audio); protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) { + ControlPointInfo controlPointInfo = new LegacyControlPointInfo(); + beatmap = new Beatmap { BeatmapInfo = new BeatmapInfo { Difficulty = new BeatmapDifficulty { CircleSize = 6, SliderMultiplier = 3 }, Ruleset = ruleset - } + }, + ControlPointInfo = controlPointInfo }; const double start_offset = 8000; const double spacing = 2000; + // intentionally start objects a bit late so we can test the case of no alive objects. double t = start_offset; - beatmap.HitObjects.AddRange(new[] + + beatmap.HitObjects.AddRange(new HitObject[] { new HitCircle { - // intentionally start objects a bit late so we can test the case of no alive objects. StartTime = t += spacing, Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } }, @@ -59,12 +78,24 @@ namespace osu.Game.Tests.Visual.Gameplay }, new HitCircle { - StartTime = t + spacing, + StartTime = t += spacing, + }, + new Slider + { + 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" }, }, }); + // Add a change in volume halfway through final slider. + controlPointInfo.Add(t, new SampleControlPoint + { + SampleBank = "normal", + SampleVolume = 20, + }); + return beatmap; } @@ -78,42 +109,88 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestCorrectHitObject() { - HitObjectLifetimeEntry nextObjectEntry = null; + waitForAliveObjectIndex(null); + checkValidObjectIndex(0); - AddAssert("no alive objects", () => getNextAliveObject() == null); + seekBeforeIndex(0); + waitForAliveObjectIndex(0); + checkValidObjectIndex(0); - AddAssert("check initially correct object", () => sampleTriggerSource.GetMostValidObject() == beatmap.HitObjects[0]); + AddAssert("first object not hit", () => getNextAliveObject()?.Entry?.Result?.HasResult != true); - AddUntilStep("get next object", () => + AddStep("hit first object", () => { - var nextDrawableObject = getNextAliveObject(); + var next = getNextAliveObject(); - if (nextDrawableObject != null) + if (next != null) { - nextObjectEntry = nextDrawableObject.Entry; - InputManager.MoveMouseTo(nextDrawableObject.ScreenSpaceDrawQuad.Centre); - return true; + Debug.Assert(next.Entry?.Result?.HasResult != true); + + InputManager.MoveMouseTo(next.ScreenSpaceDrawQuad.Centre); + InputManager.Click(MouseButton.Left); } - - return false; }); - AddUntilStep("hit first hitobject", () => - { - InputManager.Click(MouseButton.Left); - return nextObjectEntry.Result.HasResult; - }); + AddAssert("first object hit", () => getNextAliveObject()?.Entry?.Result?.HasResult == true); - AddAssert("check correct object after hit", () => sampleTriggerSource.GetMostValidObject() == beatmap.HitObjects[1]); + checkValidObjectIndex(1); - AddUntilStep("check correct object after miss", () => sampleTriggerSource.GetMostValidObject() == beatmap.HitObjects[2]); - AddUntilStep("check correct object after miss", () => sampleTriggerSource.GetMostValidObject() == beatmap.HitObjects[3]); + // Still object 1 as it's not hit yet. + seekBeforeIndex(1); + waitForAliveObjectIndex(1); + checkValidObjectIndex(1); - AddUntilStep("no alive objects", () => getNextAliveObject() == null); - AddAssert("check correct object after none alive", () => sampleTriggerSource.GetMostValidObject() == beatmap.HitObjects[3]); + seekBeforeIndex(2); + waitForAliveObjectIndex(2); + checkValidObjectIndex(2); + + seekBeforeIndex(3); + waitForAliveObjectIndex(3); + checkValidObjectIndex(3); + + seekBeforeIndex(4); + waitForAliveObjectIndex(4); + + // Even before the object, we should prefer the first nested object's sample. + // This is because the (parent) object will only play its sample at the final EndTime. + AddAssert("check valid object is slider's first nested", () => sampleTriggerSource.GetMostValidObject(), () => Is.EqualTo(beatmap.HitObjects[4].NestedHitObjects.First())); + + AddStep("seek to just before slider ends", () => Player.GameplayClockContainer.Seek(beatmap.HitObjects[4].GetEndTime() - 100)); + waitForCatchUp(); + AddUntilStep("wait until valid object is slider's last nested", () => sampleTriggerSource.GetMostValidObject(), () => Is.EqualTo(beatmap.HitObjects[4].NestedHitObjects.Last())); + + // After we get far enough away, the samples of the object itself should be used, not any nested object. + AddStep("seek to further after slider", () => Player.GameplayClockContainer.Seek(beatmap.HitObjects[4].GetEndTime() + 1000)); + waitForCatchUp(); + AddUntilStep("wait until valid object is slider itself", () => sampleTriggerSource.GetMostValidObject(), () => Is.EqualTo(beatmap.HitObjects[4])); + + AddStep("Seek into future", () => Player.GameplayClockContainer.Seek(beatmap.HitObjects.Last().GetEndTime() + 10000)); + waitForCatchUp(); + waitForAliveObjectIndex(null); + checkValidObjectIndex(4); } - private DrawableHitObject getNextAliveObject() => + private void seekBeforeIndex(int index) + { + AddStep($"seek to just before object {index}", () => Player.GameplayClockContainer.Seek(beatmap.HitObjects[index].StartTime - 100)); + waitForCatchUp(); + } + + private void waitForCatchUp() => + AddUntilStep("wait for frame stable clock to catch up", () => Precision.AlmostEquals(Player.GameplayClockContainer.CurrentTime, Player.DrawableRuleset.FrameStableClock.CurrentTime)); + + private void waitForAliveObjectIndex(int? index) + { + if (index == null) + AddUntilStep("wait for no alive objects", getNextAliveObject, () => Is.Null); + else + AddUntilStep($"wait for next alive to be {index}", () => getNextAliveObject()?.HitObject, () => Is.EqualTo(beatmap.HitObjects[index.Value])); + } + + private void checkValidObjectIndex(int index) => + AddAssert($"check valid object is {index}", () => sampleTriggerSource.GetMostValidObject(), () => Is.EqualTo(beatmap.HitObjects[index])); + + private DrawableHitObject? getNextAliveObject() => Player.DrawableRuleset.Playfield.HitObjectContainer.AliveObjects.FirstOrDefault(); [Test] @@ -122,7 +199,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddRepeatStep("trigger sample", () => sampleTriggerSource.Play(), 10); } - public class TestGameplaySampleTriggerSource : GameplaySampleTriggerSource + public partial class TestGameplaySampleTriggerSource : GameplaySampleTriggerSource { public TestGameplaySampleTriggerSource(HitObjectContainer hitObjectContainer) : base(hitObjectContainer) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs index 83c557ee51..ae46dda750 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs @@ -2,29 +2,34 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Framework.Timing; using osu.Game.Configuration; +using osu.Game.Graphics.Containers; 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.Screens.Play.HUD.HitErrorMeters; using osu.Game.Skinning; using osu.Game.Tests.Gameplay; using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneHUDOverlay : OsuManualInputManagerTestScene + public partial class TestSceneHUDOverlay : OsuManualInputManagerTestScene { - private OsuConfigManager localConfig; + private OsuConfigManager localConfig = null!; - private HUDOverlay hudOverlay; + private HUDOverlay hudOverlay = null!; [Cached] private ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); @@ -35,12 +40,12 @@ namespace osu.Game.Tests.Visual.Gameplay [Cached] private GameplayState gameplayState = TestGameplayState.Create(new OsuRuleset()); - [Cached] - private readonly GameplayClock gameplayClock = new GameplayClock(new FramedClock()); + [Cached(typeof(IGameplayClock))] + private readonly IGameplayClock gameplayClock = new GameplayClockContainer(new FramedClock()); // 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() @@ -145,6 +150,102 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("key counters still hidden", () => !keyCounterFlow.IsPresent); } + [Test] + public void TestHoldForMenuDoesWorkWhenHidden() + { + bool activated = false; + + HoldForMenuButton getHoldForMenu() => hudOverlay.ChildrenOfType().Single(); + + createNew(); + + AddStep("bind action", () => + { + activated = false; + + var holdForMenu = getHoldForMenu(); + + holdForMenu.Action += () => activated = true; + }); + + AddStep("set showhud false", () => hudOverlay.ShowHud.Value = false); + AddUntilStep("hidetarget is hidden", () => !hideTarget.IsPresent); + + AddStep("attempt activate", () => + { + InputManager.MoveMouseTo(getHoldForMenu().OfType().Single()); + InputManager.PressButton(MouseButton.Left); + }); + + AddUntilStep("activated", () => activated); + + AddStep("release mouse button", () => + { + InputManager.ReleaseButton(MouseButton.Left); + }); + } + + [Test] + public void TestInputDoesntWorkWhenHUDHidden() + { + ArgonSongProgress? getSongProgress() => hudOverlay.ChildrenOfType().SingleOrDefault(); + + bool seeked = false; + + createNew(); + + AddUntilStep("wait for song progress", () => getSongProgress() != null); + + AddStep("bind seek", () => + { + seeked = false; + + var progress = getSongProgress(); + + Debug.Assert(progress != null); + + progress.Interactive.Value = true; + progress.ChildrenOfType().Single().OnSeek += _ => seeked = true; + }); + + AddStep("set showhud false", () => hudOverlay.ShowHud.Value = false); + AddUntilStep("hidetarget is hidden", () => !hideTarget.IsPresent); + + AddStep("attempt seek", () => + { + InputManager.MoveMouseTo(getSongProgress()); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("seek not performed", () => !seeked); + + AddStep("set showhud true", () => hudOverlay.ShowHud.Value = true); + + AddStep("attempt seek", () => InputManager.Click(MouseButton.Left)); + AddAssert("seek performed", () => seeked); + } + + [Test] + public void TestHiddenHUDDoesntBlockComponentUpdates() + { + int updateCount = 0; + + AddStep("set hud to never show", () => localConfig.SetValue(OsuSetting.HUDVisibilityMode, HUDVisibilityMode.Never)); + + createNew(); + + AddUntilStep("wait for hud load", () => hudOverlay.IsLoaded); + AddUntilStep("wait for components to be hidden", () => hudOverlay.ChildrenOfType().Single().Alpha == 0); + AddUntilStep("wait for hud load", () => hudOverlay.ChildrenOfType().All(c => c.ComponentsLoaded)); + + AddStep("bind on update", () => + { + hudOverlay.ChildrenOfType().First().OnUpdate += _ => updateCount++; + }); + + AddUntilStep("wait for updates", () => updateCount > 0); + } + [Test] public void TestHiddenHUDDoesntBlockSkinnableComponentsLoad() { @@ -153,20 +254,20 @@ namespace osu.Game.Tests.Visual.Gameplay createNew(); AddUntilStep("wait for hud load", () => hudOverlay.IsLoaded); - AddUntilStep("wait for components to be hidden", () => !hudOverlay.ChildrenOfType().Single().IsPresent); + AddUntilStep("wait for components to be hidden", () => hudOverlay.ChildrenOfType().Single().Alpha == 0); - AddStep("reload components", () => hudOverlay.ChildrenOfType().Single().Reload()); - AddUntilStep("skinnable components loaded", () => hudOverlay.ChildrenOfType().Single().ComponentsLoaded); + AddStep("reload components", () => hudOverlay.ChildrenOfType().Single().Reload()); + AddUntilStep("skinnable components loaded", () => hudOverlay.ChildrenOfType().Single().ComponentsLoaded); } - private void createNew(Action action = null) + private void createNew(Action? action = null) { AddStep("create overlay", () => { 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; @@ -178,7 +279,9 @@ namespace osu.Game.Tests.Visual.Gameplay protected override void Dispose(bool isDisposing) { - localConfig?.Dispose(); + if (localConfig.IsNotNull()) + localConfig.Dispose(); + base.Dispose(isDisposing); } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs index 7febb54010..56900a0549 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs @@ -1,12 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Audio; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -29,7 +32,7 @@ using osu.Game.Screens.Play.HUD.HitErrorMeters; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneHitErrorMeter : OsuTestScene + public partial class TestSceneHitErrorMeter : OsuTestScene { [Cached(typeof(ScoreProcessor))] private TestScoreProcessor scoreProcessor = new TestScoreProcessor(); @@ -105,13 +108,13 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("no bars added", () => !this.ChildrenOfType().Any()); AddAssert("circle added", () => this.ChildrenOfType().All( - meter => meter.ChildrenOfType().Count() == 1)); + meter => meter.ChildrenOfType().Count() == 1)); AddStep("miss", () => newJudgement(50, HitResult.Miss)); AddAssert("no bars added", () => !this.ChildrenOfType().Any()); AddAssert("circle added", () => this.ChildrenOfType().All( - meter => meter.ChildrenOfType().Count() == 2)); + meter => meter.ChildrenOfType().Count() == 2)); } [Test] @@ -121,11 +124,11 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("small bonus", () => newJudgement(result: HitResult.SmallBonus)); AddAssert("no bars added", () => !this.ChildrenOfType().Any()); - AddAssert("no circle added", () => !this.ChildrenOfType().Any()); + AddAssert("no circle added", () => !this.ChildrenOfType().Any()); AddStep("large bonus", () => newJudgement(result: HitResult.LargeBonus)); AddAssert("no bars added", () => !this.ChildrenOfType().Any()); - AddAssert("no circle added", () => !this.ChildrenOfType().Any()); + AddAssert("no circle added", () => !this.ChildrenOfType().Any()); } [Test] @@ -135,11 +138,39 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("ignore hit", () => newJudgement(result: HitResult.IgnoreHit)); AddAssert("no bars added", () => !this.ChildrenOfType().Any()); - AddAssert("no circle added", () => !this.ChildrenOfType().Any()); + AddAssert("no circle added", () => !this.ChildrenOfType().Any()); AddStep("ignore miss", () => newJudgement(result: HitResult.IgnoreMiss)); AddAssert("no bars added", () => !this.ChildrenOfType().Any()); - AddAssert("no circle added", () => !this.ChildrenOfType().Any()); + AddAssert("no circle added", () => !this.ChildrenOfType().Any()); + } + + [Test] + public void TestProcessingWhileHidden() + { + const int max_displayed_judgements = 20; + AddStep("OD 1", () => recreateDisplay(new OsuHitWindows(), 1)); + + AddStep("hide displays", () => + { + foreach (var hitErrorMeter in this.ChildrenOfType()) + hitErrorMeter.Hide(); + }); + + AddRepeatStep("hit", () => newJudgement(), max_displayed_judgements * 2); + + AddAssert("bars added", () => this.ChildrenOfType().Any()); + AddAssert("circle added", () => this.ChildrenOfType().Any()); + + AddUntilStep("wait for bars to disappear", () => !this.ChildrenOfType().Any()); + AddUntilStep("ensure max circles not exceeded", () => + this.ChildrenOfType().First().ChildrenOfType().Count(), () => Is.LessThanOrEqualTo(max_displayed_judgements)); + + AddStep("show displays", () => + { + foreach (var hitErrorMeter in this.ChildrenOfType()) + hitErrorMeter.Show(); + }); } [Test] @@ -151,12 +182,12 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("bar added", () => this.ChildrenOfType().All( meter => meter.ChildrenOfType().Count() == 1)); AddAssert("circle added", () => this.ChildrenOfType().All( - meter => meter.ChildrenOfType().Count() == 1)); + meter => meter.ChildrenOfType().Count() == 1)); AddStep("clear", () => this.ChildrenOfType().ForEach(meter => meter.Clear())); AddAssert("bar cleared", () => !this.ChildrenOfType().Any()); - AddAssert("colour cleared", () => !this.ChildrenOfType().Any()); + AddAssert("colour cleared", () => !this.ChildrenOfType().Any()); } private void recreateDisplay(HitWindows hitWindows, float overallDifficulty) @@ -233,7 +264,7 @@ namespace osu.Game.Tests.Visual.Gameplay } [SuppressMessage("ReSharper", "UnassignedGetOnlyAutoProperty")] - private class TestDrawableRuleset : DrawableRuleset + private partial class TestDrawableRuleset : DrawableRuleset { public HitWindows HitWindows; @@ -241,16 +272,17 @@ namespace osu.Game.Tests.Visual.Gameplay public override event Action NewResult { - add => throw new InvalidOperationException(); - remove => throw new InvalidOperationException(); + add => throw new InvalidOperationException($"{nameof(NewResult)} operations not supported in test context"); + remove => throw new InvalidOperationException($"{nameof(NewResult)} operations not supported in test context"); } public override event Action RevertResult { - add => throw new InvalidOperationException(); - remove => throw new InvalidOperationException(); + add => throw new InvalidOperationException($"{nameof(RevertResult)} operations not supported in test context"); + remove => throw new InvalidOperationException($"{nameof(RevertResult)} operations not supported in test context"); } + public override IAdjustableAudioComponent Audio { get; } public override Playfield Playfield { get; } public override Container Overlays { get; } public override Container FrameStableComponents { get; } @@ -275,7 +307,7 @@ namespace osu.Game.Tests.Visual.Gameplay public override void CancelResume() => throw new NotImplementedException(); } - private class TestScoreProcessor : ScoreProcessor + private partial class TestScoreProcessor : ScoreProcessor { public TestScoreProcessor() : base(new OsuRuleset()) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHoldForMenuButton.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHoldForMenuButton.cs index ddb0872541..3c225d60e0 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHoldForMenuButton.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHoldForMenuButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -13,7 +15,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { [Description("'Hold to Quit' UI element")] - public class TestSceneHoldForMenuButton : OsuManualInputManagerTestScene + public partial class TestSceneHoldForMenuButton : OsuManualInputManagerTestScene { private bool exitAction; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneJudgementCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneJudgementCounter.cs new file mode 100644 index 0000000000..5a802e0d36 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneJudgementCounter.cs @@ -0,0 +1,182 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD.JudgementCounter; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public partial class TestSceneJudgementCounter : OsuTestScene + { + private ScoreProcessor scoreProcessor = null!; + private JudgementTally judgementTally = null!; + private TestJudgementCounterDisplay counterDisplay = null!; + + private DependencyProvidingContainer content = null!; + + protected override Container Content => content; + + private readonly Bindable lastJudgementResult = new Bindable(); + + private int iteration; + + [SetUpSteps] + public void SetUpSteps() => AddStep("Create components", () => + { + var ruleset = CreateRuleset(); + + Debug.Assert(ruleset != null); + + scoreProcessor = new ScoreProcessor(ruleset); + base.Content.Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] { (typeof(ScoreProcessor), scoreProcessor), (typeof(Ruleset), ruleset) }, + Children = new Drawable[] + { + judgementTally = new JudgementTally(), + content = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] { (typeof(JudgementTally), judgementTally) }, + } + }, + }; + }); + + protected override Ruleset CreateRuleset() => new ManiaRuleset(); + + private void applyOneJudgement(HitResult result) + { + lastJudgementResult.Value = new OsuJudgementResult(new HitObject + { + StartTime = iteration * 10000 + }, new OsuJudgement()) + { + Type = result, + }; + scoreProcessor.ApplyResult(lastJudgementResult.Value); + + iteration++; + } + + [Test] + public void TestAddJudgementsToCounters() + { + AddStep("create counter", () => Child = counterDisplay = new TestJudgementCounterDisplay()); + + AddRepeatStep("Add judgement", () => applyOneJudgement(HitResult.Great), 2); + AddRepeatStep("Add judgement", () => applyOneJudgement(HitResult.Miss), 2); + AddRepeatStep("Add judgement", () => applyOneJudgement(HitResult.Meh), 2); + } + + [Test] + public void TestAddWhilstHidden() + { + AddStep("create counter", () => Child = counterDisplay = new TestJudgementCounterDisplay()); + + AddRepeatStep("Add judgement", () => applyOneJudgement(HitResult.LargeTickHit), 2); + AddAssert("Check value added whilst hidden", () => hiddenCount() == 2); + AddStep("Show all judgements", () => counterDisplay.Mode.Value = JudgementCounterDisplay.DisplayMode.All); + } + + [Test] + public void TestChangeFlowDirection() + { + AddStep("create counter", () => Child = counterDisplay = new TestJudgementCounterDisplay()); + + AddStep("Set direction vertical", () => counterDisplay.FlowDirection.Value = Direction.Vertical); + AddStep("Set direction horizontal", () => counterDisplay.FlowDirection.Value = Direction.Horizontal); + } + + [Test] + public void TestToggleJudgementNames() + { + AddStep("create counter", () => Child = counterDisplay = new TestJudgementCounterDisplay()); + + AddStep("Hide judgement names", () => counterDisplay.ShowJudgementNames.Value = false); + AddWaitStep("wait some", 2); + AddAssert("Assert hidden", () => counterDisplay.CounterFlow.Children.First().ResultName.Alpha == 0); + AddStep("Hide judgement names", () => counterDisplay.ShowJudgementNames.Value = true); + AddWaitStep("wait some", 2); + AddAssert("Assert shown", () => counterDisplay.CounterFlow.Children.First().ResultName.Alpha == 1); + } + + [Test] + public void TestHideMaxValue() + { + AddStep("create counter", () => Child = counterDisplay = new TestJudgementCounterDisplay()); + + AddStep("Hide max judgement", () => counterDisplay.ShowMaxJudgement.Value = false); + AddWaitStep("wait some", 2); + AddAssert("Check max hidden", () => counterDisplay.CounterFlow.ChildrenOfType().First().Alpha == 0); + AddStep("Show max judgement", () => counterDisplay.ShowMaxJudgement.Value = true); + } + + [Test] + public void TestMaxValueStartsHidden() + { + AddStep("create counter", () => Child = counterDisplay = new TestJudgementCounterDisplay + { + ShowMaxJudgement = { Value = false } + }); + AddAssert("Check max hidden", () => counterDisplay.CounterFlow.ChildrenOfType().First().Alpha == 0); + } + + [Test] + public void TestMaxValueHiddenOnModeChange() + { + AddStep("create counter", () => Child = counterDisplay = new TestJudgementCounterDisplay()); + + AddStep("Set max judgement to hide itself", () => counterDisplay.ShowMaxJudgement.Value = false); + AddStep("Show all judgements", () => counterDisplay.Mode.Value = JudgementCounterDisplay.DisplayMode.All); + AddWaitStep("wait some", 2); + AddAssert("Assert max judgement hidden", () => counterDisplay.CounterFlow.ChildrenOfType().First().Alpha == 0); + } + + [Test] + public void TestCycleDisplayModes() + { + AddStep("create counter", () => Child = counterDisplay = new TestJudgementCounterDisplay()); + + AddStep("Show basic judgements", () => counterDisplay.Mode.Value = JudgementCounterDisplay.DisplayMode.Simple); + AddWaitStep("wait some", 2); + AddAssert("Check only basic", () => counterDisplay.CounterFlow.ChildrenOfType().Last().Alpha == 0); + AddStep("Show normal judgements", () => counterDisplay.Mode.Value = JudgementCounterDisplay.DisplayMode.Normal); + AddStep("Show all judgements", () => counterDisplay.Mode.Value = JudgementCounterDisplay.DisplayMode.All); + AddWaitStep("wait some", 2); + AddAssert("Check all visible", () => counterDisplay.CounterFlow.ChildrenOfType().Last().Alpha == 1); + } + + private int hiddenCount() + { + var num = counterDisplay.CounterFlow.Children.First(child => child.Result.Type == HitResult.LargeTickHit); + return num.Result.ResultCount.Value; + } + + private partial class TestJudgementCounterDisplay : JudgementCounterDisplay + { + public new FillFlowContainer CounterFlow => base.CounterFlow; + + public TestJudgementCounterDisplay() + { + Margin = new MarginPadding { Top = 100 }; + Anchor = Anchor.TopCentre; + Origin = Anchor.TopCentre; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneKeyBindings.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneKeyBindings.cs index 70a43fafb5..53c07304cf 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneKeyBindings.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneKeyBindings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Graphics.Containers; @@ -18,7 +20,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { [HeadlessTest] - public class TestSceneKeyBindings : OsuManualInputManagerTestScene + public partial class TestSceneKeyBindings : OsuManualInputManagerTestScene { private readonly ActionReceiver receiver; @@ -69,7 +71,7 @@ namespace osu.Game.Tests.Visual.Gameplay Down, } - private class TestKeyBindingContainer : DatabasedKeyBindingContainer + private partial class TestKeyBindingContainer : DatabasedKeyBindingContainer { public TestKeyBindingContainer() : base(new TestRuleset().RulesetInfo, 0) @@ -77,7 +79,7 @@ namespace osu.Game.Tests.Visual.Gameplay } } - private class ActionReceiver : CompositeDrawable, IKeyBindingHandler + private partial class ActionReceiver : CompositeDrawable, IKeyBindingHandler { public bool ReceivedAction; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneKeyCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneKeyCounter.cs index 87ab42fe60..22f7111f68 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneKeyCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneKeyCounter.cs @@ -1,57 +1,81 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Utils; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; +using osuTK; using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] - public class TestSceneKeyCounter : OsuManualInputManagerTestScene + public partial class TestSceneKeyCounter : OsuManualInputManagerTestScene { 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/TestSceneLeadIn.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs index b90bd93002..dadf3ca65f 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs @@ -15,11 +15,11 @@ using osuTK; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneLeadIn : RateAdjustedBeatmapTestScene + public partial class TestSceneLeadIn : RateAdjustedBeatmapTestScene { - private LeadInPlayer player; + private LeadInPlayer player = null!; - private const double lenience_ms = 10; + private const double lenience_ms = 100; private const double first_hit_object = 2170; @@ -34,11 +34,7 @@ namespace osu.Game.Tests.Visual.Gameplay BeatmapInfo = { AudioLeadIn = leadIn } }); - AddStep("check first frame time", () => - { - Assert.That(player.FirstFrameClockTime, Is.Not.Null); - Assert.That(player.FirstFrameClockTime.Value, Is.EqualTo(expectedStartTime).Within(lenience_ms)); - }); + checkFirstFrameTime(expectedStartTime); } [TestCase(1000, 0)] @@ -57,11 +53,7 @@ namespace osu.Game.Tests.Visual.Gameplay loadPlayerWithBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo), storyboard); - AddStep("check first frame time", () => - { - Assert.That(player.FirstFrameClockTime, Is.Not.Null); - Assert.That(player.FirstFrameClockTime.Value, Is.EqualTo(expectedStartTime).Within(lenience_ms)); - }); + checkFirstFrameTime(expectedStartTime); } [TestCase(1000, 0, false)] @@ -74,18 +66,20 @@ namespace osu.Game.Tests.Visual.Gameplay [TestCase(-10000, -10000, true)] public void TestStoryboardProducesCorrectStartTimeFadeInAfterOtherEvents(double firstStoryboardEvent, double expectedStartTime, bool addEventToLoop) { + const double loop_start_time = -20000; + var storyboard = new Storyboard(); var sprite = new StoryboardSprite("unknown", Anchor.TopLeft, Vector2.Zero); // these should be ignored as we have an alpha visibility blocker proceeding this command. - sprite.TimelineGroup.Scale.Add(Easing.None, -20000, -18000, 0, 1); - var loopGroup = sprite.AddLoop(-20000, 50); - loopGroup.Scale.Add(Easing.None, -20000, -18000, 0, 1); + sprite.TimelineGroup.Scale.Add(Easing.None, loop_start_time, -18000, 0, 1); + var loopGroup = sprite.AddLoop(loop_start_time, 50); + loopGroup.Scale.Add(Easing.None, loop_start_time, -18000, 0, 1); var target = addEventToLoop ? loopGroup : sprite.TimelineGroup; - double targetTime = addEventToLoop ? 20000 : 0; - target.Alpha.Add(Easing.None, targetTime + firstStoryboardEvent, targetTime + firstStoryboardEvent + 500, 0, 1); + double loopRelativeOffset = addEventToLoop ? -loop_start_time : 0; + target.Alpha.Add(Easing.None, loopRelativeOffset + firstStoryboardEvent, loopRelativeOffset + firstStoryboardEvent + 500, 0, 1); // these should be ignored due to being in the future. sprite.TimelineGroup.Alpha.Add(Easing.None, 18000, 20000, 0, 1); @@ -95,14 +89,13 @@ namespace osu.Game.Tests.Visual.Gameplay loadPlayerWithBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo), storyboard); - AddStep("check first frame time", () => - { - Assert.That(player.FirstFrameClockTime, Is.Not.Null); - Assert.That(player.FirstFrameClockTime.Value, Is.EqualTo(expectedStartTime).Within(lenience_ms)); - }); + checkFirstFrameTime(expectedStartTime); } - private void loadPlayerWithBeatmap(IBeatmap beatmap, Storyboard storyboard = null) + private void checkFirstFrameTime(double expectedStartTime) => + AddAssert("check first frame time", () => player.FirstFrameClockTime, () => Is.EqualTo(expectedStartTime).Within(lenience_ms)); + + private void loadPlayerWithBeatmap(IBeatmap beatmap, Storyboard? storyboard = null) { AddStep("create player", () => { @@ -113,7 +106,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("player loaded", () => player.IsLoaded && player.Alpha == 1); } - private class LeadInPlayer : TestPlayer + private partial class LeadInPlayer : TestPlayer { public LeadInPlayer() : base(false, false) @@ -124,19 +117,15 @@ namespace osu.Game.Tests.Visual.Gameplay public new GameplayClockContainer GameplayClockContainer => base.GameplayClockContainer; - public double GameplayStartTime => DrawableRuleset.GameplayStartTime; - public double FirstHitObjectTime => DrawableRuleset.Objects.First().StartTime; - public double GameplayClockTime => GameplayClockContainer.GameplayClock.CurrentTime; - protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); if (!FirstFrameClockTime.HasValue) { - FirstFrameClockTime = GameplayClockContainer.GameplayClock.CurrentTime; + FirstFrameClockTime = GameplayClockContainer.CurrentTime; AddInternal(new OsuSpriteText { Text = $"GameplayStartTime: {DrawableRuleset.GameplayStartTime} " diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneLetterboxOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneLetterboxOverlay.cs new file mode 100644 index 0000000000..ce93837925 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneLetterboxOverlay.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Screens.Play.Break; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public partial class TestSceneLetterboxOverlay : OsuTestScene + { + public TestSceneLetterboxOverlay() + { + AddRange(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both + }, + new LetterboxOverlay() + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneMedalOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneMedalOverlay.cs index 0ada3cf05f..626406e4d2 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneMedalOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneMedalOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Overlays; using osu.Game.Users; @@ -8,7 +10,7 @@ using osu.Game.Users; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] - public class TestSceneMedalOverlay : OsuTestScene + public partial class TestSceneMedalOverlay : OsuTestScene { public TestSceneMedalOverlay() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneModValidity.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneModValidity.cs new file mode 100644 index 0000000000..b26f48d028 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneModValidity.cs @@ -0,0 +1,50 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Tests.Visual.Gameplay +{ + [HeadlessTest] + public partial class TestSceneModValidity : TestSceneAllRulesetPlayers + { + protected override void AddCheckSteps() + { + AddStep("Check all mod acronyms are unique", () => + { + var mods = Ruleset.Value.CreateInstance().AllMods; + + IEnumerable acronyms = mods.Select(m => m.Acronym); + + Assert.That(acronyms, Is.Unique); + }); + + AddStep("Check all mods are two-way incompatible", () => + { + var mods = Ruleset.Value.CreateInstance().AllMods; + + IEnumerable modInstances = mods.Select(mod => mod.CreateInstance()); + + foreach (var modToCheck in modInstances) + { + var incompatibleMods = modToCheck.IncompatibleMods; + + foreach (var incompatible in incompatibleMods) + { + foreach (var incompatibleMod in modInstances.Where(m => incompatible.IsInstanceOfType(m))) + { + Assert.That( + incompatibleMod.IncompatibleMods.Any(m => m.IsInstanceOfType(modToCheck)), + $"{modToCheck} has {incompatibleMod} in it's incompatible mods, but {incompatibleMod} does not have {modToCheck} in it's incompatible mods." + ); + } + } + } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneNightcoreBeatContainer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneNightcoreBeatContainer.cs index 759e4fa4ec..84334ba0a9 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneNightcoreBeatContainer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneNightcoreBeatContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Beatmaps.Timing; @@ -11,7 +13,7 @@ using osu.Game.Tests.Visual.UserInterface; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneNightcoreBeatContainer : TestSceneBeatSyncedContainer + public partial class TestSceneNightcoreBeatContainer : TestSceneBeatSyncedContainer { protected override void LoadComplete() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs index b308f3d7d8..269d104fa3 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Game.Overlays; @@ -8,7 +10,7 @@ using osu.Game.Rulesets; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneOverlayActivation : OsuPlayerTestScene + public partial class TestSceneOverlayActivation : OsuPlayerTestScene { protected new OverlayTestPlayer Player => base.Player as OverlayTestPlayer; @@ -17,7 +19,7 @@ namespace osu.Game.Tests.Visual.Gameplay base.SetUpSteps(); AddUntilStep("gameplay has started", - () => Player.GameplayClockContainer.GameplayClock.CurrentTime > Player.DrawableRuleset.GameplayStartTime); + () => Player.GameplayClockContainer.CurrentTime > Player.DrawableRuleset.GameplayStartTime); } [Test] @@ -62,7 +64,7 @@ namespace osu.Game.Tests.Visual.Gameplay protected override TestPlayer CreatePlayer(Ruleset ruleset) => new OverlayTestPlayer(); - protected class OverlayTestPlayer : TestPlayer + protected partial class OverlayTestPlayer : TestPlayer { public new OverlayActivation OverlayActivationMode => base.OverlayActivationMode.Value; } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneParticleExplosion.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneParticleExplosion.cs index 82095cb809..2d48ac81e2 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneParticleExplosion.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneParticleExplosion.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] - public class TestSceneParticleExplosion : OsuTestScene + public partial class TestSceneParticleExplosion : OsuTestScene { private ParticleExplosion explosion; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneParticleSpewer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneParticleSpewer.cs index ce5cd629e0..c73d57dc2b 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneParticleSpewer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneParticleSpewer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Framework.Allocation; @@ -16,7 +18,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] - public class TestSceneParticleSpewer : OsuTestScene + public partial class TestSceneParticleSpewer : OsuTestScene { private TestParticleSpewer spewer; @@ -77,7 +79,7 @@ namespace osu.Game.Tests.Visual.Gameplay } private TestParticleSpewer createSpewer() => - new TestParticleSpewer(skinManager.DefaultLegacySkin.GetTexture("star2")) + new TestParticleSpewer(skinManager.DefaultClassicSkin.GetTexture("star2")) { Origin = Anchor.Centre, RelativePositionAxes = Axes.Both, @@ -86,7 +88,7 @@ namespace osu.Game.Tests.Visual.Gameplay Size = new Vector2(0.5f), }; - private class TestParticleSpewer : ParticleSpewer + private partial class TestParticleSpewer : ParticleSpewer { public const int MAX_DURATION = 1500; private const int rate = 250; diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs index ab5d766609..b072ce191e 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs @@ -1,11 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Logging; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Configuration; @@ -19,7 +23,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { - public class TestScenePause : OsuPlayerTestScene + public partial class TestScenePause : OsuPlayerTestScene { protected new PausePlayer Player => (PausePlayer)base.Player; @@ -29,7 +33,13 @@ namespace osu.Game.Tests.Visual.Gameplay public TestScenePause() { - base.Content.Add(content = new MenuCursorContainer { RelativeSizeAxes = Axes.Both }); + base.Content.Add(content = new GlobalCursorDisplay { RelativeSizeAxes = Axes.Both }); + } + + [BackgroundDependencyLoader] + private void load() + { + LocalConfig.SetValue(OsuSetting.UIHoldActivationDelay, 0.0); } [SetUpSteps] @@ -41,6 +51,22 @@ namespace osu.Game.Tests.Visual.Gameplay confirmClockRunning(true); } + [Test] + public void TestTogglePauseViaBackAction() + { + pauseViaBackAction(); + pauseViaBackAction(); + confirmPausedWithNoOverlay(); + } + + [Test] + public void TestTogglePauseViaPauseGameplayAction() + { + pauseViaPauseGameplayAction(); + pauseViaPauseGameplayAction(); + confirmPausedWithNoOverlay(); + } + [Test] public void TestPauseWithLargeOffset() { @@ -64,7 +90,13 @@ namespace osu.Game.Tests.Visual.Gameplay Player.OnUpdate += _ => { double currentTime = Player.GameplayClockContainer.CurrentTime; - alwaysGoingForward &= currentTime >= lastTime; + bool goingForward = currentTime >= lastTime - 500; + + alwaysGoingForward &= goingForward; + + if (!goingForward) + Logger.Log($"Backwards time occurred ({currentTime:N1} -> {lastTime:N1})"); + lastTime = currentTime; }; }); @@ -75,7 +107,7 @@ namespace osu.Game.Tests.Visual.Gameplay resumeAndConfirm(); - AddAssert("time didn't go backwards", () => alwaysGoingForward); + AddAssert("time didn't go too far backwards", () => alwaysGoingForward); AddStep("reset offset", () => LocalConfig.SetValue(OsuSetting.AudioOffset, 0.0)); } @@ -88,6 +120,9 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("player not playing", () => !Player.LocalUserPlaying.Value); resumeAndConfirm(); + + AddAssert("Resumed without seeking forward", () => Player.LastResumeTime, () => Is.LessThanOrEqualTo(Player.LastPauseTime)); + AddUntilStep("player playing", () => Player.LocalUserPlaying.Value); } @@ -139,7 +174,7 @@ namespace osu.Game.Tests.Visual.Gameplay { AddStep("disable pause support", () => Player.Configuration.AllowPause = false); - pauseFromUserExitKey(); + pauseViaBackAction(); confirmExited(); } @@ -151,7 +186,7 @@ namespace osu.Game.Tests.Visual.Gameplay pauseAndConfirm(); resume(); - pauseFromUserExitKey(); + pauseViaBackAction(); confirmResumed(); confirmNotExited(); @@ -165,7 +200,7 @@ namespace osu.Game.Tests.Visual.Gameplay pauseAndConfirm(); resume(); - AddStep("pause via exit key", () => Player.ExitViaQuickExit()); + exitViaQuickExitAction(); confirmResumed(); AddAssert("exited", () => !Player.IsCurrentScreen()); @@ -209,7 +244,7 @@ namespace osu.Game.Tests.Visual.Gameplay confirmClockRunning(false); - AddStep("exit via user pause", () => Player.ExitViaPause()); + pauseViaBackAction(); confirmExited(); } @@ -219,11 +254,11 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for fail", () => Player.GameplayState.HasFailed); // will finish the fail animation and show the fail/pause screen. - AddStep("attempt exit via pause key", () => Player.ExitViaPause()); + pauseViaBackAction(); AddAssert("fail overlay shown", () => Player.FailOverlayVisible); // will actually exit. - AddStep("exit via pause key", () => Player.ExitViaPause()); + pauseViaBackAction(); confirmExited(); } @@ -240,7 +275,7 @@ namespace osu.Game.Tests.Visual.Gameplay public void TestQuickExitFromFailedGameplay() { AddUntilStep("wait for fail", () => Player.GameplayState.HasFailed); - AddStep("quick exit", () => Player.GameplayClockContainer.ChildrenOfType().First().Action?.Invoke()); + exitViaQuickExitAction(); confirmExited(); } @@ -256,7 +291,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestQuickExitFromGameplay() { - AddStep("quick exit", () => Player.GameplayClockContainer.ChildrenOfType().First().Action?.Invoke()); + exitViaQuickExitAction(); confirmExited(); } @@ -311,7 +346,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("pause again", () => { Player.Pause(); - return !Player.GameplayClockContainer.GameplayClock.IsRunning; + return !Player.GameplayClockContainer.IsRunning; }); AddAssert("loop is playing", () => getLoop().IsPlaying); @@ -322,7 +357,7 @@ namespace osu.Game.Tests.Visual.Gameplay private void pauseAndConfirm() { - pauseFromUserExitKey(); + pauseViaBackAction(); confirmPaused(); } @@ -365,38 +400,73 @@ namespace osu.Game.Tests.Visual.Gameplay private void confirmNoTrackAdjustments() { - AddAssert("track has no adjustments", () => Beatmap.Value.Track.AggregateFrequency.Value == 1); + AddUntilStep("track has no adjustments", () => Beatmap.Value.Track.AggregateFrequency.Value, () => Is.EqualTo(1)); } private void restart() => AddStep("restart", () => Player.Restart()); - private void pauseFromUserExitKey() => AddStep("user pause", () => Player.ExitViaPause()); + private void pauseViaBackAction() => AddStep("press escape", () => InputManager.Key(Key.Escape)); + private void pauseViaPauseGameplayAction() => AddStep("press middle mouse", () => InputManager.Click(MouseButton.Middle)); + + private void exitViaQuickExitAction() => AddStep("press ctrl-tilde", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.PressKey(Key.Tilde); + InputManager.ReleaseKey(Key.Tilde); + InputManager.ReleaseKey(Key.ControlLeft); + }); + private void resume() => AddStep("resume", () => Player.Resume()); private void confirmPauseOverlayShown(bool isShown) => AddAssert("pause overlay " + (isShown ? "shown" : "hidden"), () => Player.PauseOverlayVisible == isShown); private void confirmClockRunning(bool isRunning) => - AddUntilStep("clock " + (isRunning ? "running" : "stopped"), () => Player.GameplayClockContainer.GameplayClock.IsRunning == isRunning); + AddUntilStep("clock " + (isRunning ? "running" : "stopped"), () => + { + bool completed = Player.GameplayClockContainer.IsRunning == isRunning; + + if (completed) + { + } + + return completed; + }); protected override bool AllowFail => true; protected override TestPlayer CreatePlayer(Ruleset ruleset) => new PausePlayer(); - protected class PausePlayer : TestPlayer + protected partial class PausePlayer : TestPlayer { + public double LastPauseTime { get; private set; } + public double LastResumeTime { get; private set; } + public bool FailOverlayVisible => FailOverlay.State.Value == Visibility.Visible; public bool PauseOverlayVisible => PauseOverlay.State.Value == Visibility.Visible; - public void ExitViaPause() => PerformExit(true); - - public void ExitViaQuickExit() => PerformExit(false); - public override void OnEntering(ScreenTransitionEvent e) { base.OnEntering(e); GameplayClockContainer.Stop(); } + + private bool? isRunning; + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + if (GameplayClockContainer.IsRunning != isRunning) + { + isRunning = GameplayClockContainer.IsRunning; + + if (isRunning.Value) + LastResumeTime = GameplayClockContainer.CurrentTime; + else + LastPauseTime = GameplayClockContainer.CurrentTime; + } + } } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePauseWhenInactive.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePauseWhenInactive.cs index 49c1163c6c..b770f72f19 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePauseWhenInactive.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePauseWhenInactive.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Allocation; @@ -18,7 +20,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Gameplay { [HeadlessTest] // we alter unsafe properties on the game host to test inactive window state. - public class TestScenePauseWhenInactive : OsuPlayerTestScene + public partial class TestScenePauseWhenInactive : OsuPlayerTestScene { [Resolved] private GameHost host { get; set; } diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePerformancePointsCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePerformancePointsCounter.cs index aefe0db36a..9622caabf5 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePerformancePointsCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePerformancePointsCounter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using NUnit.Framework; @@ -19,7 +21,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Gameplay { - public class TestScenePerformancePointsCounter : OsuTestScene + public partial class TestScenePerformancePointsCounter : OsuTestScene { private DependencyProvidingContainer dependencyContainer; diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs index 950c755cc1..dbd1ce1f6e 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -11,11 +13,11 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Utils; +using osu.Framework.Localisation; using osu.Framework.Screens; using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Configuration; -using osu.Game.Graphics.Containers; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets.Mods; @@ -30,7 +32,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { - public class TestScenePlayerLoader : ScreenTestScene + public partial class TestScenePlayerLoader : ScreenTestScene { private TestPlayerLoader loader; private TestPlayer player; @@ -43,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; @@ -54,6 +59,10 @@ namespace osu.Game.Tests.Visual.Gameplay private readonly ChangelogOverlay changelogOverlay; + private double savedTrackVolume; + private double savedMasterVolume; + private bool savedMutedState; + public TestScenePlayerLoader() { AddRange(new Drawable[] @@ -73,11 +82,21 @@ namespace osu.Game.Tests.Visual.Gameplay } [SetUp] - public void Setup() => Schedule(() => + public void Setup() => Schedule(() => player = null); + + [SetUpSteps] + public override void SetUpSteps() { - player = null; - audioManager.Volume.SetDefault(); - }); + base.SetUpSteps(); + + AddStep("read all notifications", () => + { + notificationOverlay.Show(); + notificationOverlay.Hide(); + }); + + AddUntilStep("wait for no notifications", () => notificationOverlay.UnreadCount.Value, () => Is.EqualTo(0)); + } /// /// Sets the input manager child to a new test player loader container instance. @@ -96,7 +115,13 @@ namespace osu.Game.Tests.Visual.Gameplay private void prepareBeatmap() { var workingBeatmap = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + + // Add intro time to test quick retry skipping (TestQuickRetry). + workingBeatmap.BeatmapInfo.AudioLeadIn = 60000; + + // Turn on epilepsy warning to test warning display (TestEpilepsyWarning). workingBeatmap.BeatmapInfo.EpilepsyWarning = epilepsyWarning; + Beatmap.Value = workingBeatmap; foreach (var mod in SelectedMods.Value.OfType()) @@ -145,6 +170,7 @@ namespace osu.Game.Tests.Visual.Gameplay moveMouse(); return player?.LoadState == LoadState.Ready; }); + AddRepeatStep("move mouse", moveMouse, 20); AddAssert("loader still active", () => loader.IsCurrentScreen()); @@ -152,6 +178,8 @@ namespace osu.Game.Tests.Visual.Gameplay void moveMouse() { + notificationOverlay.State.Value = Visibility.Hidden; + InputManager.MoveMouseTo( loader.VisualSettings.ScreenSpaceDrawQuad.TopLeft + (loader.VisualSettings.ScreenSpaceDrawQuad.BottomRight - loader.VisualSettings.ScreenSpaceDrawQuad.TopLeft) @@ -239,13 +267,13 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestMutedNotificationMasterVolume() { - addVolumeSteps("master volume", () => audioManager.Volume.Value = 0, () => audioManager.Volume.IsDefault); + addVolumeSteps("master volume", () => audioManager.Volume.Value = 0, () => audioManager.Volume.Value == 0.5); } [Test] public void TestMutedNotificationTrackVolume() { - addVolumeSteps("music volume", () => audioManager.VolumeTrack.Value = 0, () => audioManager.VolumeTrack.IsDefault); + addVolumeSteps("music volume", () => audioManager.VolumeTrack.Value = 0, () => audioManager.VolumeTrack.Value == 0.5); } [Test] @@ -272,19 +300,16 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("load player", () => resetPlayer(false, beforeLoad)); AddUntilStep("wait for player", () => player?.LoadState == LoadState.Ready); - AddAssert("check for notification", () => notificationOverlay.UnreadCount.Value == 1); - AddStep("click notification", () => - { - var scrollContainer = (OsuScrollContainer)notificationOverlay.Children.Last(); - var flowContainer = scrollContainer.Children.OfType>().First(); - var notification = flowContainer.First(); + saveVolumes(); - InputManager.MoveMouseTo(notification); - InputManager.Click(MouseButton.Left); - }); + AddAssert("check for notification", () => notificationOverlay.UnreadCount.Value, () => Is.EqualTo(1)); + + clickNotification(); AddAssert("check " + volumeName, assert); + restoreVolumes(); + AddUntilStep("wait for player load", () => player.IsLoaded); } @@ -292,6 +317,10 @@ namespace osu.Game.Tests.Visual.Gameplay [TestCase(false)] public void TestEpilepsyWarning(bool warning) { + saveVolumes(); + setFullVolume(); + + AddStep("enable storyboards", () => config.SetValue(OsuSetting.ShowStoryboard, true)); AddStep("change epilepsy warning", () => epilepsyWarning = warning); AddStep("load dummy beatmap", () => resetPlayer(false)); @@ -304,38 +333,34 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("sound volume decreased", () => Beatmap.Value.Track.AggregateVolume.Value == 0.25); AddUntilStep("sound volume restored", () => Beatmap.Value.Track.AggregateVolume.Value == 1); } + + restoreVolumes(); } - [TestCase(false, 1.0, false)] // not charging, above cutoff --> no warning - [TestCase(true, 0.1, false)] // charging, below cutoff --> no warning - [TestCase(false, 0.25, true)] // not charging, at cutoff --> warning - public void TestLowBatteryNotification(bool isCharging, double chargeLevel, bool shouldWarn) + [Test] + public void TestEpilepsyWarningWithDisabledStoryboard() { - AddStep("reset notification lock", () => sessionStatics.GetBindable(Static.LowBatteryNotificationShownOnce).Value = false); + saveVolumes(); + setFullVolume(); - // set charge status and level - AddStep("load player", () => resetPlayer(false, () => - { - batteryInfo.SetCharging(isCharging); - batteryInfo.SetChargeLevel(chargeLevel); - })); - AddUntilStep("wait for player", () => player?.LoadState == LoadState.Ready); - AddAssert($"notification {(shouldWarn ? "triggered" : "not triggered")}", () => notificationOverlay.UnreadCount.Value == (shouldWarn ? 1 : 0)); - AddStep("click notification", () => - { - var scrollContainer = (OsuScrollContainer)notificationOverlay.Children.Last(); - var flowContainer = scrollContainer.Children.OfType>().First(); - var notification = flowContainer.First(); + AddStep("disable storyboards", () => config.SetValue(OsuSetting.ShowStoryboard, false)); + AddStep("change epilepsy warning", () => epilepsyWarning = true); + AddStep("load dummy beatmap", () => resetPlayer(false)); - InputManager.MoveMouseTo(notification); - InputManager.Click(MouseButton.Left); - }); - AddUntilStep("wait for player load", () => player.IsLoaded); + 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)); @@ -348,11 +373,107 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("warning is hidden", () => getWarning().State.Value == Visibility.Hidden); AddUntilStep("sound volume restored", () => Beatmap.Value.Track.AggregateVolume.Value == 1); + + restoreVolumes(); } - private EpilepsyWarning getWarning() => loader.ChildrenOfType().SingleOrDefault(); + [TestCase(true, 1.0, false)] // on battery, above cutoff --> no warning + [TestCase(false, 0.1, false)] // not on battery, below cutoff --> no warning + [TestCase(true, 0.25, true)] // on battery, at cutoff --> warning + [TestCase(true, null, false)] // on battery, level unknown --> no warning + public void TestLowBatteryNotification(bool onBattery, double? chargeLevel, bool shouldWarn) + { + AddStep("reset notification lock", () => sessionStatics.GetBindable(Static.LowBatteryNotificationShownOnce).Value = false); - private class TestPlayerLoader : PlayerLoader + // set charge status and level + AddStep("load player", () => resetPlayer(false, () => + { + batteryInfo.SetOnBattery(onBattery); + batteryInfo.SetChargeLevel(chargeLevel); + })); + AddUntilStep("wait for player", () => player?.LoadState == LoadState.Ready); + + if (shouldWarn) + clickNotification(); + else + AddAssert("notification not triggered", () => notificationOverlay.UnreadCount.Value == 0); + + AddUntilStep("wait for player load", () => player.IsLoaded); + } + + private void restoreVolumes() + { + AddStep("restore previous volumes", () => + { + audioManager.VolumeTrack.Value = savedTrackVolume; + audioManager.Volume.Value = savedMasterVolume; + volumeOverlay.IsMuted.Value = savedMutedState; + }); + } + + private void setFullVolume() + { + AddStep("set volumes to 100%", () => + { + audioManager.VolumeTrack.Value = 1; + audioManager.Volume.Value = 1; + volumeOverlay.IsMuted.Value = false; + }); + } + + private void saveVolumes() + { + AddStep("save previous volumes", () => + { + savedTrackVolume = audioManager.VolumeTrack.Value; + savedMasterVolume = audioManager.Volume.Value; + savedMutedState = volumeOverlay.IsMuted.Value; + }); + } + + [Test] + public void TestQuickRetry() + { + TestPlayer getCurrentPlayer() => loader.CurrentPlayer as TestPlayer; + bool checkSkipButtonVisible() => player.ChildrenOfType().FirstOrDefault()?.IsButtonVisible == true; + + TestPlayer previousPlayer = null; + + AddStep("load dummy beatmap", () => resetPlayer(false)); + + AddUntilStep("wait for current", () => getCurrentPlayer()?.IsCurrentScreen() == true); + AddStep("store previous player", () => previousPlayer = getCurrentPlayer()); + + AddStep("Restart map normally", () => getCurrentPlayer().Restart()); + AddUntilStep("wait for load", () => getCurrentPlayer()?.LoadedBeatmapSuccessfully == true); + + AddUntilStep("restart completed", () => getCurrentPlayer() != null && getCurrentPlayer() != previousPlayer); + AddStep("store previous player", () => previousPlayer = getCurrentPlayer()); + + AddUntilStep("skip button visible", checkSkipButtonVisible); + + AddStep("press quick retry key", () => InputManager.PressKey(Key.Tilde)); + AddUntilStep("restart completed", () => getCurrentPlayer() != null && getCurrentPlayer() != previousPlayer); + AddStep("release quick retry key", () => InputManager.ReleaseKey(Key.Tilde)); + + AddUntilStep("wait for player", () => getCurrentPlayer()?.LoadState == LoadState.Ready); + + AddUntilStep("time reached zero", () => getCurrentPlayer()?.GameplayClockContainer.CurrentTime > 0); + AddUntilStep("skip button not visible", () => !checkSkipButtonVisible()); + } + + private void clickNotification() + { + Notification notification = null; + + AddUntilStep("wait for notification", () => (notification = notificationOverlay.ChildrenOfType().FirstOrDefault()) != null); + AddStep("open notification overlay", () => notificationOverlay.Show()); + AddStep("click notification", () => notification.TriggerClick()); + } + + private EpilepsyWarning getWarning() => loader.ChildrenOfType().SingleOrDefault(w => w.IsAlive); + + private partial class TestPlayerLoader : PlayerLoader { public new VisualSettings VisualSettings => base.VisualSettings; @@ -371,7 +492,7 @@ namespace osu.Game.Tests.Visual.Gameplay public override string Name => string.Empty; public override string Acronym => string.Empty; public override double ScoreMultiplier => 1; - public override string Description => string.Empty; + public override LocalisableString Description => string.Empty; public bool Applied { get; private set; } @@ -383,7 +504,7 @@ namespace osu.Game.Tests.Visual.Gameplay public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; } - protected class SlowLoadPlayer : TestPlayer + protected partial class SlowLoadPlayer : TestPlayer { public readonly ManualResetEventSlim AllowLoad = new ManualResetEventSlim(false); @@ -406,19 +527,19 @@ namespace osu.Game.Tests.Visual.Gameplay /// private class LocalBatteryInfo : BatteryInfo { - private bool isCharging = true; - private double chargeLevel = 1; + private bool onBattery; + private double? chargeLevel; - public override bool IsCharging => isCharging; + public override bool OnBattery => onBattery; - public override double ChargeLevel => chargeLevel; + public override double? ChargeLevel => chargeLevel; - public void SetCharging(bool value) + public void SetOnBattery(bool value) { - isCharging = value; + onBattery = value; } - public void SetChargeLevel(double value) + public void SetChargeLevel(double? value) { chargeLevel = value; } diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs new file mode 100644 index 0000000000..80c4e4bce9 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs @@ -0,0 +1,180 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics.Containers; +using osu.Framework.Platform; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public partial class TestScenePlayerLocalScoreImport : PlayerTestScene + { + private BeatmapManager beatmaps = null!; + private RulesetStore rulesets = null!; + + private BeatmapSetInfo? importedSet; + + [BackgroundDependencyLoader] + private void load(GameHost host, AudioManager audio) + { + Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new ScoreManager(rulesets, () => beatmaps, LocalStorage, Realm, API)); + Dependencies.Cache(Realm); + } + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("import beatmap", () => + { + beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); + importedSet = beatmaps.GetAllUsableBeatmapSets().First(); + }); + } + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => beatmaps.GetWorkingBeatmap(importedSet?.Beatmaps.First()).Beatmap; + + private Ruleset? customRuleset; + + protected override Ruleset CreatePlayerRuleset() => customRuleset ?? new OsuRuleset(); + + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new TestPlayer(false); + + protected override bool HasCustomSteps => true; + + protected override bool AllowFail => allowFail; + + private bool allowFail; + + [SetUp] + public void SetUp() + { + allowFail = false; + customRuleset = null; + } + + [Test] + public void TestSaveFailedReplay() + { + AddStep("allow fail", () => allowFail = true); + + CreateTest(); + + AddUntilStep("fail screen displayed", () => Player.ChildrenOfType().First().State.Value == Visibility.Visible); + AddUntilStep("wait for button clickable", () => Player.ChildrenOfType().First().ChildrenOfType().First().Enabled.Value); + + AddUntilStep("score not in database", () => Realm.Run(r => r.Find(Player.Score.ScoreInfo.ID) == null)); + AddStep("click save button", () => Player.ChildrenOfType().First().ChildrenOfType().First().TriggerClick()); + AddUntilStep("score in database", () => Realm.Run(r => r.Find(Player.Score.ScoreInfo.ID) != null)); + } + + [Test] + public void TestLastPlayedUpdated() + { + DateTimeOffset? getLastPlayed() => Realm.Run(r => r.Find(Beatmap.Value.BeatmapInfo.ID)?.LastPlayed); + + AddAssert("last played is null", () => getLastPlayed() == null); + + CreateTest(); + + AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); + AddUntilStep("wait for last played to update", () => getLastPlayed() != null); + } + + [Test] + public void TestModReferenceNotRetained() + { + AddStep("allow fail", () => allowFail = false); + + Mod[] originalMods = { new OsuModDaycore { SpeedChange = { Value = 0.8 } } }; + Mod[] playerMods = null!; + + AddStep("load player with mods", () => LoadPlayer(originalMods)); + AddUntilStep("player loaded", () => Player.IsLoaded && Player.Alpha == 1); + + AddStep("get mods at start of gameplay", () => playerMods = Player.Score.ScoreInfo.Mods.ToArray()); + + // Player creates new instance of mods during load. + AddAssert("player score has copied mods", () => playerMods.First(), () => Is.Not.SameAs(originalMods.First())); + AddAssert("player score has matching mods", () => playerMods.First(), () => Is.EqualTo(originalMods.First())); + + AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); + + AddStep("seek to completion", () => Player.GameplayClockContainer.Seek(Player.DrawableRuleset.Objects.Last().GetEndTime())); + + AddUntilStep("results displayed", () => Player.GetChildScreen() is ResultsScreen); + + // Player creates new instance of mods after gameplay to ensure any runtime references to drawables etc. are not retained. + AddAssert("results screen score has copied mods", () => (Player.GetChildScreen() as ResultsScreen)?.Score.Mods.First(), () => Is.Not.SameAs(playerMods.First())); + AddAssert("results screen score has matching", () => (Player.GetChildScreen() as ResultsScreen)?.Score.Mods.First(), () => Is.EqualTo(playerMods.First())); + + AddUntilStep("score in database", () => Realm.Run(r => r.Find(Player.Score.ScoreInfo.ID) != null)); + AddUntilStep("databased score has correct mods", () => Realm.Run(r => r.Find(Player.Score.ScoreInfo.ID)).Mods.First(), () => Is.EqualTo(playerMods.First())); + } + + [Test] + public void TestScoreStoredLocally() + { + CreateTest(); + + AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); + + AddStep("seek to completion", () => Player.GameplayClockContainer.Seek(Player.DrawableRuleset.Objects.Last().GetEndTime())); + + AddUntilStep("results displayed", () => Player.GetChildScreen() is ResultsScreen); + AddUntilStep("score in database", () => Realm.Run(r => r.Find(Player.Score.ScoreInfo.ID) != null)); + } + + [Test] + public void TestScoreStoredLocallyCustomRuleset() + { + Ruleset createCustomRuleset() => new CustomRuleset(); + + AddStep("import custom ruleset", () => Realm.Write(r => r.Add(createCustomRuleset().RulesetInfo))); + AddStep("set custom ruleset", () => customRuleset = createCustomRuleset()); + + CreateTest(); + + AddAssert("score has custom ruleset", () => Player.Score.ScoreInfo.Ruleset.Equals(customRuleset.AsNonNull().RulesetInfo)); + + AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); + + AddStep("seek to completion", () => Player.GameplayClockContainer.Seek(Player.DrawableRuleset.Objects.Last().GetEndTime())); + + AddUntilStep("results displayed", () => Player.GetChildScreen() is ResultsScreen); + AddUntilStep("score in database", () => Realm.Run(r => r.Find(Player.Score.ScoreInfo.ID) != null)); + } + + private class CustomRuleset : OsuRuleset, ILegacyRuleset + { + public override string Description => "custom"; + public override string ShortName => "custom"; + + int ILegacyRuleset.LegacyID => -1; + + public override ScoreProcessor CreateScoreProcessor() => new ScoreProcessor(this); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerReferenceLeaking.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerReferenceLeaking.cs index 7c2f1c5bb2..b1209e3a4f 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerReferenceLeaking.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerReferenceLeaking.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Lists; using osu.Game.Beatmaps; @@ -10,7 +12,7 @@ using osu.Game.Storyboards; namespace osu.Game.Tests.Visual.Gameplay { - public class TestScenePlayerReferenceLeaking : TestSceneAllRulesetPlayers + public partial class TestScenePlayerReferenceLeaking : TestSceneAllRulesetPlayers { private readonly WeakList workingWeakReferences = new WeakList(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs index 58b5df2612..1a7ea20cc0 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using System.Threading; @@ -24,7 +26,7 @@ using osu.Game.Tests.Beatmaps; namespace osu.Game.Tests.Visual.Gameplay { - public class TestScenePlayerScoreSubmission : PlayerTestScene + public partial class TestScenePlayerScoreSubmission : PlayerTestScene { protected override bool AllowFail => allowFail; @@ -179,7 +181,8 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for fail", () => Player.GameplayState.HasFailed); AddStep("exit", () => Player.Exit()); - AddAssert("ensure failing submission", () => Player.SubmittedScore?.ScoreInfo.Passed == false); + AddUntilStep("wait for submission", () => Player.SubmittedScore != null); + AddAssert("ensure failing submission", () => Player.SubmittedScore.ScoreInfo.Passed == false); } [Test] @@ -207,7 +210,9 @@ namespace osu.Game.Tests.Visual.Gameplay addFakeHit(); AddStep("exit", () => Player.Exit()); - AddAssert("ensure failing submission", () => Player.SubmittedScore?.ScoreInfo.Passed == false); + + AddUntilStep("wait for submission", () => Player.SubmittedScore != null); + AddAssert("ensure failing submission", () => Player.SubmittedScore.ScoreInfo.Passed == false); } [Test] @@ -237,7 +242,7 @@ namespace osu.Game.Tests.Visual.Gameplay createPlayerTest(false, r => { var beatmap = createTestBeatmap(r); - beatmap.BeatmapInfo.OnlineID = -1; + beatmap.BeatmapInfo.ResetOnlineInfo(); return beatmap; }); @@ -255,7 +260,7 @@ namespace osu.Game.Tests.Visual.Gameplay { prepareTestAPI(true); - createPlayerTest(false, createRuleset: () => new OsuRuleset + createPlayerTest(createRuleset: () => new OsuRuleset { RulesetInfo = { @@ -343,7 +348,7 @@ namespace osu.Game.Tests.Visual.Gameplay }); } - protected class FakeImportingPlayer : TestPlayer + protected partial class FakeImportingPlayer : TestPlayer { public bool ScoreImportStarted { get; set; } public SemaphoreSlim AllowImportCompletion { get; } @@ -363,21 +368,9 @@ namespace osu.Game.Tests.Visual.Gameplay ImportedScore = score; - // It was discovered that Score members could sometimes be half-populated. - // In particular, the RulesetID property could be set to 0 even on non-osu! maps. - // We want to test that the state of that property is consistent in this test. - // EF makes this impossible. - // - // First off, because of the EF navigational property-explicit foreign key field duality, - // it can happen that - for example - the Ruleset navigational property is correctly initialised to mania, - // but the RulesetID foreign key property is not initialised and remains 0. - // EF silently bypasses this by prioritising the Ruleset navigational property over the RulesetID foreign key one. - // - // Additionally, adding an entity to an EF DbSet CAUSES SIDE EFFECTS with regard to the foreign key property. - // In the above instance, if a ScoreInfo with Ruleset = {mania} and RulesetID = 0 is attached to an EF context, - // RulesetID WILL BE SILENTLY SET TO THE CORRECT VALUE of 3. - // - // For the above reasons, actual importing is disabled in this test. + // Calling base.ImportScore is omitted as it will fail for the test method which uses a custom ruleset. + // This can be resolved by doing something similar to what TestScenePlayerLocalScoreImport is doing, + // but requires a bit of restructuring. } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs index 911fffbe7f..d16f51f36e 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -17,7 +19,6 @@ using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Rulesets.Difficulty; -using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -29,12 +30,14 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Gameplay { - public class TestScenePoolingRuleset : OsuTestScene + public partial class TestScenePoolingRuleset : OsuTestScene { private const double time_between_objects = 1000; private TestDrawablePoolingRuleset drawableRuleset; + private TestPlayfield playfield => (TestPlayfield)drawableRuleset.Playfield; + [Test] public void TestReusedWithHitObjectsSpacedFarApart() { @@ -131,46 +134,99 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("no DHOs shown", () => !this.ChildrenOfType().Any()); } + [Test] + public void TestRevertResult() + { + ManualClock clock = null; + Beatmap beatmap; + + createTest(beatmap = new Beatmap + { + HitObjects = + { + new TestHitObject { StartTime = 0 }, + new TestHitObject { StartTime = 500 }, + new TestHitObject { StartTime = 1000 }, + } + }, 10, () => new FramedClock(clock = new ManualClock())); + + AddStep("fast forward to end", () => clock.CurrentTime = beatmap.HitObjects[^1].GetEndTime() + 100); + AddUntilStep("all judged", () => playfield.JudgedObjects.Count, () => Is.EqualTo(3)); + + AddStep("rewind to middle", () => clock.CurrentTime = beatmap.HitObjects[1].StartTime - 100); + AddUntilStep("some results reverted", () => playfield.JudgedObjects.Count, () => Is.EqualTo(1)); + + AddStep("fast forward to end", () => clock.CurrentTime = beatmap.HitObjects[^1].GetEndTime() + 100); + AddUntilStep("all judged", () => playfield.JudgedObjects.Count, () => Is.EqualTo(3)); + + AddStep("disable frame stability", () => drawableRuleset.FrameStablePlayback = false); + AddStep("instant seek to start", () => clock.CurrentTime = beatmap.HitObjects[0].StartTime - 100); + 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() { ManualClock clock = null; - bool anyJudged = false; - - void onNewResult(JudgementResult _) => anyJudged = true; var beatmap = new Beatmap(); beatmap.HitObjects.Add(new TestKilledHitObject { Duration = 20 }); createTest(beatmap, 10, () => new FramedClock(clock = new ManualClock())); - AddStep("subscribe to new result", () => - { - anyJudged = false; - drawableRuleset.NewResult += onNewResult; - }); AddStep("skip past object", () => clock.CurrentTime = beatmap.HitObjects[0].GetEndTime() + 1000); - AddAssert("object judged", () => anyJudged); - - AddStep("clean up", () => drawableRuleset.NewResult -= onNewResult); + AddAssert("object judged", () => playfield.JudgedObjects.Count == 1); } - private void createTest(IBeatmap beatmap, int poolSize, Func createClock = null) => AddStep("create test", () => + private void createTest(IBeatmap beatmap, int poolSize, Func createClock = null) { - var ruleset = new TestPoolingRuleset(); - - drawableRuleset = (TestDrawablePoolingRuleset)ruleset.CreateDrawableRulesetWith(CreateWorkingBeatmap(beatmap).GetPlayableBeatmap(ruleset.RulesetInfo)); - drawableRuleset.FrameStablePlayback = true; - drawableRuleset.PoolSize = poolSize; - - Child = new Container + AddStep("create test", () => { - RelativeSizeAxes = Axes.Both, - Clock = createClock?.Invoke() ?? new FramedOffsetClock(Clock, false) { Offset = -Clock.CurrentTime }, - Child = drawableRuleset - }; - }); + var ruleset = new TestPoolingRuleset(); + + drawableRuleset = (TestDrawablePoolingRuleset)ruleset.CreateDrawableRulesetWith(CreateWorkingBeatmap(beatmap).GetPlayableBeatmap(ruleset.RulesetInfo)); + drawableRuleset.FrameStablePlayback = true; + drawableRuleset.PoolSize = poolSize; + + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Clock = createClock?.Invoke() ?? new FramedOffsetClock(Clock, false) { Offset = -Clock.CurrentTime }, + Child = drawableRuleset + }; + }); + } #region Ruleset @@ -189,7 +245,7 @@ namespace osu.Game.Tests.Visual.Gameplay public override string ShortName { get; } = string.Empty; } - private class TestDrawablePoolingRuleset : DrawableRuleset + private partial class TestDrawablePoolingRuleset : DrawableRuleset { public int PoolSize; @@ -205,14 +261,26 @@ namespace osu.Game.Tests.Visual.Gameplay protected override Playfield CreatePlayfield() => new TestPlayfield(PoolSize); } - private class TestPlayfield : Playfield + private partial class TestPlayfield : Playfield { + public readonly HashSet JudgedObjects = new HashSet(); + private readonly int poolSize; public TestPlayfield(int poolSize) { this.poolSize = poolSize; AddInternal(HitObjectContainer); + NewResult += (_, r) => + { + Assert.That(JudgedObjects, Has.No.Member(r.HitObject)); + JudgedObjects.Add(r.HitObject); + }; + RevertResult += r => + { + Assert.That(JudgedObjects, Has.Member(r.HitObject)); + JudgedObjects.Remove(r.HitObject); + }; } [BackgroundDependencyLoader] @@ -220,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); @@ -278,7 +348,7 @@ namespace osu.Game.Tests.Visual.Gameplay public double Duration { get; set; } } - private class DrawableTestHitObject : DrawableHitObject + private partial class DrawableTestHitObject : DrawableHitObject { public DrawableTestHitObject() : base(null) @@ -330,7 +400,7 @@ namespace osu.Game.Tests.Visual.Gameplay { } - private class DrawableTestKilledHitObject : DrawableHitObject + private partial class DrawableTestKilledHitObject : DrawableHitObject { public DrawableTestKilledHitObject() : base(null) @@ -350,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 8622fe8f53..bf9b13b320 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.ComponentModel; using System.Linq; @@ -13,7 +15,7 @@ using osu.Game.Screens.Play; namespace osu.Game.Tests.Visual.Gameplay { [Description("Player instantiated with a replay.")] - public class TestSceneReplay : TestSceneAllRulesetPlayers + public partial class TestSceneReplay : TestSceneAllRulesetPlayers { protected override Player CreatePlayer(Ruleset ruleset) { @@ -25,11 +27,11 @@ 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); } - private class ScoreAccessibleReplayPlayer : ReplayPlayer + private partial class ScoreAccessibleReplayPlayer : ReplayPlayer { public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; public new HUDOverlay HUDOverlay => base.HUDOverlay; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs index e89350de1a..6ccf73d8ff 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs @@ -1,11 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Online; -using osu.Game.Online.API.Requests.Responses; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; @@ -13,7 +14,6 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Graphics.UserInterface; -using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Scoring; using osu.Game.Screens.Ranking; @@ -24,13 +24,10 @@ using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] - public class TestSceneReplayDownloadButton : OsuManualInputManagerTestScene + public partial class TestSceneReplayDownloadButton : OsuManualInputManagerTestScene { private const long online_score_id = 2553163309; - [Resolved] - private RulesetStore rulesets { get; set; } - private TestReplayDownloadButton downloadButton; [Resolved] @@ -110,7 +107,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for load", () => downloadButton.IsLoaded); - AddAssert("state is available", () => downloadButton.State.Value == DownloadState.NotDownloaded); + checkState(DownloadState.NotDownloaded); AddStep("click button", () => { @@ -136,7 +133,29 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for load", () => downloadButton.IsLoaded); - AddAssert("state is not downloaded", () => downloadButton.State.Value == DownloadState.NotDownloaded); + checkState(DownloadState.NotDownloaded); + AddAssert("button is not enabled", () => !downloadButton.ChildrenOfType().First().Enabled.Value); + } + + [Test] + public void TestLocallyAvailableWithoutReplay() + { + Live imported = null; + + AddStep("import score", () => imported = scoreManager.Import(getScoreInfo(false, false))); + + AddStep("create button without replay", () => + { + Child = downloadButton = new TestReplayDownloadButton(imported.Value) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + }); + + AddUntilStep("wait for load", () => downloadButton.IsLoaded); + + checkState(DownloadState.NotDownloaded); AddAssert("button is not enabled", () => !downloadButton.ChildrenOfType().First().Enabled.Value); } @@ -155,17 +174,16 @@ namespace osu.Game.Tests.Visual.Gameplay }); AddUntilStep("wait for load", () => downloadButton.IsLoaded); - - AddUntilStep("state is not downloaded", () => downloadButton.State.Value == DownloadState.NotDownloaded); + checkState(DownloadState.NotDownloaded); AddStep("import score", () => imported = scoreManager.Import(getScoreInfo(true))); - AddUntilStep("state is available", () => downloadButton.State.Value == DownloadState.LocallyAvailable); + checkState(DownloadState.LocallyAvailable); AddAssert("button is enabled", () => downloadButton.ChildrenOfType().First().Enabled.Value); AddStep("delete score", () => scoreManager.Delete(imported.Value)); - AddUntilStep("state is not downloaded", () => downloadButton.State.Value == DownloadState.NotDownloaded); + checkState(DownloadState.NotDownloaded); AddAssert("button is not enabled", () => !downloadButton.ChildrenOfType().First().Enabled.Value); } @@ -183,27 +201,27 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for load", () => downloadButton.IsLoaded); - AddAssert("state is not downloaded", () => downloadButton.State.Value == DownloadState.NotDownloaded); + checkState(DownloadState.Unknown); AddAssert("button is not enabled", () => !downloadButton.ChildrenOfType().First().Enabled.Value); } - private ScoreInfo getScoreInfo(bool replayAvailable) - { - return new APIScore - { - OnlineID = online_score_id, - RulesetID = 0, - Beatmap = CreateAPIBeatmapSet(new OsuRuleset().RulesetInfo).Beatmaps.First(), - HasReplay = replayAvailable, - User = new APIUser - { - Id = 39828, - Username = @"WubWoofWolf", - } - }.CreateScoreInfo(rulesets, beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First()); - } + private void checkState(DownloadState expectedState) => + AddUntilStep($"state is {expectedState}", () => downloadButton.State.Value, () => Is.EqualTo(expectedState)); - private class TestReplayDownloadButton : ReplayDownloadButton + private ScoreInfo getScoreInfo(bool replayAvailable, bool hasOnlineId = true) => new ScoreInfo + { + OnlineID = hasOnlineId ? online_score_id : 0, + Ruleset = new OsuRuleset().RulesetInfo, + BeatmapInfo = beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First(), + Hash = replayAvailable ? "online" : string.Empty, + User = new APIUser + { + Id = 39828, + Username = @"WubWoofWolf", + } + }; + + private partial class TestReplayDownloadButton : ReplayDownloadButton { public void SetDownloadState(DownloadState state) => State.Value = state; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs index fcd65eaff3..ae10207de0 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs @@ -1,34 +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 NUnit.Framework; +using osu.Framework.Screens; +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; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneReplayPlayer : RateAdjustedBeatmapTestScene + public partial class TestSceneReplayPlayer : RateAdjustedBeatmapTestScene { - protected TestReplayPlayer Player; - - public override void SetUpSteps() - { - base.SetUpSteps(); - - AddStep("Initialise player", () => Player = CreatePlayer(new OsuRuleset())); - AddStep("Load player", () => LoadScreen(Player)); - AddUntilStep("player loaded", () => Player.IsLoaded); - } + protected TestReplayPlayer Player = null!; [Test] - public void TestPause() + public void TestPauseViaSpace() { + loadPlayerWithBeatmap(); + double? lastTime = null; AddUntilStep("wait for first hit", () => Player.ScoreProcessor.TotalScore.Value > 0); - AddStep("Pause playback", () => InputManager.Key(Key.Space)); + AddStep("Pause playback with space", () => InputManager.Key(Key.Space)); + + AddAssert("player not exited", () => Player.IsCurrentScreen()); + + AddUntilStep("Time stopped progressing", () => + { + double current = Player.GameplayClockContainer.CurrentTime; + bool changed = lastTime != current; + lastTime = current; + + return !changed; + }); + + AddWaitStep("wait some", 10); + + AddAssert("Time still stopped", () => lastTime == Player.GameplayClockContainer.CurrentTime); + } + + [Test] + public void 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() + { + loadPlayerWithBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo) + { + BeatmapInfo = { AudioLeadIn = 60000 } + }); + + AddUntilStep("wait for skip overlay", () => Player.ChildrenOfType().First().IsButtonVisible); + + AddStep("Skip with space", () => InputManager.Key(Key.Space)); + + AddAssert("Player not paused", () => !Player.DrawableRuleset.IsPaused.Value); + + double? lastTime = null; + + AddUntilStep("wait for first hit", () => Player.ScoreProcessor.TotalScore.Value > 0); + + AddStep("Pause playback with space", () => InputManager.Key(Key.Space)); + + AddAssert("player not exited", () => Player.IsCurrentScreen()); + + AddUntilStep("Time stopped progressing", () => + { + double current = Player.GameplayClockContainer.CurrentTime; + bool changed = lastTime != current; + lastTime = current; + + return !changed; + }); + + AddWaitStep("wait some", 10); + + AddAssert("Time still stopped", () => lastTime == Player.GameplayClockContainer.CurrentTime); + } + + [Test] + public void TestPauseViaMiddleMouse() + { + loadPlayerWithBeatmap(); + + double? lastTime = null; + + AddUntilStep("wait for first hit", () => Player.ScoreProcessor.TotalScore.Value > 0); + + AddStep("Pause playback with middle mouse", () => InputManager.Click(MouseButton.Middle)); + + AddAssert("player not exited", () => Player.IsCurrentScreen()); AddUntilStep("Time stopped progressing", () => { @@ -47,6 +124,8 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestSeekBackwards() { + loadPlayerWithBeatmap(); + double? lastTime = null; AddUntilStep("wait for first hit", () => Player.ScoreProcessor.TotalScore.Value > 0); @@ -63,6 +142,8 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestSeekForwards() { + loadPlayerWithBeatmap(); + double? lastTime = null; AddUntilStep("wait for first hit", () => Player.ScoreProcessor.TotalScore.Value > 0); @@ -76,12 +157,26 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("Jumped forwards", () => Player.GameplayClockContainer.CurrentTime - lastTime > 500); } - protected TestReplayPlayer CreatePlayer(Ruleset ruleset) + private void loadPlayerWithBeatmap(IBeatmap? beatmap = null) { - Beatmap.Value = CreateWorkingBeatmap(ruleset.RulesetInfo); + AddStep("create player", () => + { + CreatePlayer(new OsuRuleset(), beatmap); + }); + + AddStep("Load player", () => LoadScreen(Player)); + AddUntilStep("player loaded", () => Player.IsLoaded); + } + + protected void CreatePlayer(Ruleset ruleset, IBeatmap? beatmap = null) + { + Beatmap.Value = beatmap != null + ? CreateWorkingBeatmap(beatmap) + : CreateWorkingBeatmap(ruleset.RulesetInfo); + SelectedMods.Value = new[] { ruleset.GetAutoplayMod() }; - return new TestReplayPlayer(false); + Player = new TestReplayPlayer(false); } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs index 8362739d3b..65b409a6f7 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs @@ -1,10 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -29,7 +31,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneReplayRecorder : OsuManualInputManagerTestScene + public partial class TestSceneReplayRecorder : OsuManualInputManagerTestScene { private TestRulesetInputManager playbackManager; private TestRulesetInputManager recordingManager; @@ -38,8 +40,7 @@ namespace osu.Game.Tests.Visual.Gameplay private TestReplayRecorder recorder; - [Cached] - private GameplayState gameplayState = TestGameplayState.Create(new OsuRuleset()); + private GameplayState gameplayState; [SetUpSteps] public void SetUpSteps() @@ -50,81 +51,15 @@ namespace osu.Game.Tests.Visual.Gameplay { replay = new Replay(); - Add(new GridContainer + gameplayState = TestGameplayState.Create(new OsuRuleset()); + gameplayState.Score.Replay = replay; + + Child = new DependencyProvidingContainer { RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] - { - recordingManager = new TestRulesetInputManager(TestCustomisableModRuleset.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) - { - Recorder = recorder = new TestReplayRecorder(new Score - { - Replay = replay, - ScoreInfo = - { - BeatmapInfo = gameplayState.Beatmap.BeatmapInfo, - Ruleset = new OsuRuleset().RulesetInfo, - } - }) - { - ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos), - }, - Child = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - Colour = Color4.Brown, - RelativeSizeAxes = Axes.Both, - }, - new OsuSpriteText - { - Text = "Recording", - Scale = new Vector2(3), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - new TestInputConsumer() - } - }, - } - }, - new Drawable[] - { - playbackManager = new TestRulesetInputManager(TestCustomisableModRuleset.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) - { - ReplayInputHandler = new TestFramedReplayInputHandler(replay) - { - GamefieldToScreenSpace = pos => playbackManager.ToScreenSpace(pos), - }, - Child = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - Colour = Color4.DarkBlue, - RelativeSizeAxes = Axes.Both, - }, - new OsuSpriteText - { - Text = "Playback", - Scale = new Vector2(3), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - new TestInputConsumer() - } - }, - } - } - } - }); + CachedDependencies = new (Type, object)[] { (typeof(GameplayState), gameplayState) }, + Child = createContent(), + }; }); } @@ -201,6 +136,74 @@ namespace osu.Game.Tests.Visual.Gameplay recorder = null; } + private Drawable createContent() => new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + recordingManager = new TestRulesetInputManager(TestCustomisableModRuleset.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + { + Recorder = recorder = new TestReplayRecorder(gameplayState.Score) + { + ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos), + }, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = Color4.Brown, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Text = "Recording", + Scale = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new TestInputConsumer() + } + }, + } + }, + new Drawable[] + { + playbackManager = new TestRulesetInputManager(TestCustomisableModRuleset.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + { + ReplayInputHandler = new TestFramedReplayInputHandler(replay) + { + GamefieldToScreenSpace = pos => playbackManager.ToScreenSpace(pos), + }, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = Color4.DarkBlue, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Text = "Playback", + Scale = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new TestInputConsumer() + } + }, + } + } + } + }; + public class TestFramedReplayInputHandler : FramedReplayInputHandler { public TestFramedReplayInputHandler(Replay replay) @@ -215,7 +218,7 @@ namespace osu.Game.Tests.Visual.Gameplay } } - public class TestInputConsumer : CompositeDrawable, IKeyBindingHandler + public partial class TestInputConsumer : CompositeDrawable, IKeyBindingHandler { public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parent.ReceivePositionalInputAt(screenSpacePos); @@ -258,7 +261,7 @@ namespace osu.Game.Tests.Visual.Gameplay } } - public class TestRulesetInputManager : RulesetInputManager + public partial class TestRulesetInputManager : RulesetInputManager { public TestRulesetInputManager(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) : base(ruleset, variant, unique) @@ -268,7 +271,7 @@ namespace osu.Game.Tests.Visual.Gameplay protected override KeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) => new TestKeyBindingContainer(); - internal class TestKeyBindingContainer : KeyBindingContainer + internal partial class TestKeyBindingContainer : KeyBindingContainer { public override IEnumerable DefaultKeyBindings => new[] { @@ -296,7 +299,7 @@ namespace osu.Game.Tests.Visual.Gameplay Down, } - internal class TestReplayRecorder : ReplayRecorder + internal partial class TestReplayRecorder : ReplayRecorder { public TestReplayRecorder(Score target) : base(target) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneScoring.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneScoring.cs new file mode 100644 index 0000000000..8fff07e6d8 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneScoring.cs @@ -0,0 +1,498 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; +using osuTK; +using osuTK.Graphics; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public partial class TestSceneScoring : OsuTestScene + { + private GraphContainer graphs = null!; + private SettingsSlider sliderMaxCombo = null!; + + private FillFlowContainer legend = null!; + + [Test] + public void TestBasic() + { + AddStep("setup tests", () => + { + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + graphs = new GraphContainer + { + RelativeSizeAxes = Axes.Both, + }, + }, + new Drawable[] + { + legend = new FillFlowContainer + { + Padding = new MarginPadding(20), + Direction = FillDirection.Full, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + }, + new Drawable[] + { + new FillFlowContainer + { + Padding = new MarginPadding(20), + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Full, + Children = new Drawable[] + { + sliderMaxCombo = new SettingsSlider + { + Width = 0.5f, + TransferValueOnCommit = true, + Current = new BindableInt(1024) + { + MinValue = 96, + MaxValue = 8192, + }, + LabelText = "max combo", + }, + new OsuTextFlowContainer + { + RelativeSizeAxes = Axes.X, + Width = 0.5f, + AutoSizeAxes = Axes.Y, + Text = $"Left click to add miss\nRight click to add OK/{base_ok}" + } + } + }, + }, + } + } + }; + + sliderMaxCombo.Current.BindValueChanged(_ => rerun()); + + graphs.MissLocations.BindCollectionChanged((_, __) => rerun()); + graphs.NonPerfectLocations.BindCollectionChanged((_, __) => rerun()); + + graphs.MaxCombo.BindTo(sliderMaxCombo.Current); + + rerun(); + }); + } + + private const int base_great = 300; + private const int base_ok = 100; + + private void rerun() + { + 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 } }); + + runScoreV1(); + runScoreV2(); + } + + private void runScoreV1() + { + int totalScore = 0; + int currentCombo = 0; + + void applyHitV1(int baseScore) + { + if (baseScore == 0) + { + currentCombo = 0; + return; + } + + const float score_multiplier = 1; + + totalScore += baseScore; + + // combo multiplier + // ReSharper disable once PossibleLossOfFraction + totalScore += (int)(Math.Max(0, currentCombo - 1) * (baseScore / 25 * score_multiplier)); + + currentCombo++; + } + + runForAlgorithm("ScoreV1 (classic)", Color4.Purple, + () => applyHitV1(base_great), + () => applyHitV1(base_ok), + () => applyHitV1(0), + () => + { + // Arbitrary value chosen towards the upper range. + const double score_multiplier = 4; + + return (int)(totalScore * score_multiplier); + }); + } + + private void runScoreV2() + { + int maxCombo = sliderMaxCombo.Current.Value; + + int currentCombo = 0; + double comboPortion = 0; + double currentBaseScore = 0; + double maxBaseScore = 0; + int currentHits = 0; + + for (int i = 0; i < maxCombo; i++) + applyHitV2(base_great); + + double comboPortionMax = comboPortion; + + currentCombo = 0; + comboPortion = 0; + currentBaseScore = 0; + maxBaseScore = 0; + currentHits = 0; + + void applyHitV2(int baseScore) + { + maxBaseScore += base_great; + currentBaseScore += baseScore; + comboPortion += baseScore * (1 + ++currentCombo / 10.0); + + currentHits++; + } + + runForAlgorithm("ScoreV2", Color4.OrangeRed, + () => applyHitV2(base_great), + () => applyHitV2(base_ok), + () => + { + currentHits++; + maxBaseScore += base_great; + currentCombo = 0; + }, () => + { + double accuracy = currentBaseScore / maxBaseScore; + + return (int)Math.Round + ( + 700000 * comboPortion / comboPortionMax + + 300000 * Math.Pow(accuracy, 10) * ((double)currentHits / maxCombo) + ); + }); + } + + private void runForProcessor(string name, Color4 colour, ScoreProcessor processor) + { + int maxCombo = sliderMaxCombo.Current.Value; + + var beatmap = new OsuBeatmap(); + for (int i = 0; i < maxCombo; i++) + beatmap.HitObjects.Add(new HitCircle()); + + processor.ApplyBeatmap(beatmap); + + runForAlgorithm(name, colour, + () => 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); + } + + private void runForAlgorithm(string name, Color4 colour, Action applyHit, Action applyNonPerfect, Action applyMiss, Func getTotalScore) + { + int maxCombo = sliderMaxCombo.Current.Value; + + List results = new List(); + + for (int i = 0; i < maxCombo; i++) + { + if (graphs.MissLocations.Contains(i)) + applyMiss(); + else if (graphs.NonPerfectLocations.Contains(i)) + applyNonPerfect(); + else + applyHit(); + + results.Add(getTotalScore()); + } + + graphs.Add(new LineGraph + { + Name = name, + RelativeSizeAxes = Axes.Both, + LineColour = colour, + Values = results + }); + + legend.Add(new OsuSpriteText + { + Colour = colour, + RelativeSizeAxes = Axes.X, + Width = 0.5f, + Text = $"{FontAwesome.Solid.Circle.Icon} {name}" + }); + + legend.Add(new OsuSpriteText + { + Colour = colour, + RelativeSizeAxes = Axes.X, + Width = 0.5f, + Text = $"final score {getTotalScore():#,0}" + }); + } + } + + public partial class GraphContainer : Container, IHasCustomTooltip> + { + public readonly BindableList MissLocations = new BindableList(); + public readonly BindableList NonPerfectLocations = new BindableList(); + + public Bindable MaxCombo = new Bindable(); + + protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; + + private readonly Box hoverLine; + + private readonly Container missLines; + private readonly Container verticalGridLines; + + public int CurrentHoverCombo { get; private set; } + + public GraphContainer() + { + InternalChild = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = OsuColour.Gray(0.1f), + RelativeSizeAxes = Axes.Both, + }, + verticalGridLines = new Container + { + RelativeSizeAxes = Axes.Both, + }, + hoverLine = new Box + { + Colour = Color4.Yellow, + RelativeSizeAxes = Axes.Y, + Origin = Anchor.TopCentre, + Alpha = 0, + Width = 1, + }, + missLines = new Container + { + Alpha = 0.6f, + RelativeSizeAxes = Axes.Both, + }, + Content, + } + }; + + MissLocations.BindCollectionChanged((_, _) => updateMissLocations()); + NonPerfectLocations.BindCollectionChanged((_, _) => updateMissLocations()); + + MaxCombo.BindValueChanged(_ => + { + updateMissLocations(); + updateVerticalGridLines(); + }, true); + } + + private void updateVerticalGridLines() + { + verticalGridLines.Clear(); + + for (int i = 0; i < MaxCombo.Value; i++) + { + if (i % 100 == 0) + { + verticalGridLines.AddRange(new Drawable[] + { + new Box + { + Colour = OsuColour.Gray(0.2f), + Origin = Anchor.TopCentre, + Width = 1, + RelativeSizeAxes = Axes.Y, + RelativePositionAxes = Axes.X, + X = (float)i / MaxCombo.Value, + }, + new OsuSpriteText + { + RelativePositionAxes = Axes.X, + X = (float)i / MaxCombo.Value, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Text = $"{i:#,0}", + Rotation = -30, + Y = -20, + } + }); + } + } + } + + private void updateMissLocations() + { + missLines.Clear(); + + foreach (int miss in MissLocations) + { + missLines.Add(new Box + { + Colour = Color4.Red, + Origin = Anchor.TopCentre, + Width = 1, + RelativeSizeAxes = Axes.Y, + RelativePositionAxes = Axes.X, + X = (float)miss / MaxCombo.Value, + }); + } + + foreach (int miss in NonPerfectLocations) + { + missLines.Add(new Box + { + Colour = Color4.Orange, + Origin = Anchor.TopCentre, + Width = 1, + RelativeSizeAxes = Axes.Y, + RelativePositionAxes = Axes.X, + X = (float)miss / MaxCombo.Value, + }); + } + } + + protected override bool OnHover(HoverEvent e) + { + hoverLine.Show(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + hoverLine.Hide(); + base.OnHoverLost(e); + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + CurrentHoverCombo = (int)(e.MousePosition.X / DrawWidth * MaxCombo.Value); + + hoverLine.X = e.MousePosition.X; + return base.OnMouseMove(e); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button == MouseButton.Left) + MissLocations.Add(CurrentHoverCombo); + else + NonPerfectLocations.Add(CurrentHoverCombo); + + return true; + } + + private GraphTooltip? tooltip; + + public ITooltip> GetCustomTooltip() => tooltip ??= new GraphTooltip(this); + + public IEnumerable TooltipContent => Content.OfType(); + + public partial class GraphTooltip : CompositeDrawable, ITooltip> + { + private readonly GraphContainer graphContainer; + + private readonly OsuTextFlowContainer textFlow; + + public GraphTooltip(GraphContainer graphContainer) + { + this.graphContainer = graphContainer; + AutoSizeAxes = Axes.Both; + + Masking = true; + CornerRadius = 10; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = OsuColour.Gray(0.15f), + RelativeSizeAxes = Axes.Both, + }, + textFlow = new OsuTextFlowContainer + { + Colour = Color4.White, + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding(10), + } + }; + } + + private int? lastContentCombo; + + public void SetContent(IEnumerable content) + { + int relevantCombo = graphContainer.CurrentHoverCombo; + + if (lastContentCombo == relevantCombo) + return; + + lastContentCombo = relevantCombo; + textFlow.Clear(); + + textFlow.AddParagraph($"At combo {relevantCombo}:"); + + foreach (var graph in content) + { + float valueAtHover = graph.Values.ElementAt(relevantCombo); + float ofTotal = valueAtHover / graph.Values.Last(); + + textFlow.AddParagraph($"{graph.Name}: {valueAtHover:#,0} ({ofTotal * 100:N0}% of final)\n", st => st.Colour = graph.LineColour); + } + } + + public void Move(Vector2 pos) => this.MoveTo(pos); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs index 283fe594ea..317d01553a 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -9,8 +11,10 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Threading; +using osu.Framework.Timing; using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; @@ -25,7 +29,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] - public class TestSceneScrollingHitObjects : OsuTestScene + public partial class TestSceneScrollingHitObjects : OsuTestScene { [Cached(typeof(IReadOnlyList))] private IReadOnlyList mods { get; set; } = Array.Empty(); @@ -165,14 +169,39 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("add control points", () => addControlPoints(testControlPoints, Time.Current)); } - private void addHitObject(double time) + [Test] + public void TestVeryFlowScroll() + { + const double long_time_range = 100000; + var manualClock = new ManualClock(); + + AddStep("set manual clock", () => + { + manualClock.CurrentTime = 0; + scrollContainers.ForEach(c => c.Clock = new FramedClock(manualClock)); + + setScrollAlgorithm(ScrollVisualisationMethod.Constant); + scrollContainers.ForEach(c => c.TimeRange = long_time_range); + }); + + AddStep("add hit objects", () => + { + addHitObject(long_time_range); + addHitObject(long_time_range + 100, 250); + }); + + AddAssert("hit objects are alive", () => playfields.All(p => p.HitObjectContainer.AliveObjects.Count() == 2)); + } + + private void addHitObject(double time, float size = 75) { playfields.ForEach(p => { - var hitObject = new TestDrawableHitObject(time); - setAnchor(hitObject, p); + var hitObject = new TestHitObject(size) { StartTime = time }; + var drawable = new TestDrawableHitObject(hitObject); - p.Add(hitObject); + setAnchor(drawable, p); + p.Add(drawable); }); } @@ -185,7 +214,7 @@ namespace osu.Game.Tests.Visual.Gameplay private void addControlPoints(IList controlPoints, double sequenceStartTime) { - controlPoints.ForEach(point => point.StartTime += sequenceStartTime); + controlPoints.ForEach(point => point.Time += sequenceStartTime); scrollContainers.ForEach(container => { @@ -195,7 +224,7 @@ namespace osu.Game.Tests.Visual.Gameplay foreach (var playfield in playfields) { foreach (var controlPoint in controlPoints) - playfield.Add(createDrawablePoint(playfield, controlPoint.StartTime)); + playfield.Add(createDrawablePoint(playfield, controlPoint.Time)); } } @@ -223,7 +252,7 @@ namespace osu.Game.Tests.Visual.Gameplay private void setScrollAlgorithm(ScrollVisualisationMethod algorithm) => scrollContainers.ForEach(c => c.ScrollAlgorithm = algorithm); - private class TestPlayfield : ScrollingPlayfield + private partial class TestPlayfield : ScrollingPlayfield { public new ScrollingDirection Direction => base.Direction.Value; @@ -246,9 +275,11 @@ namespace osu.Game.Tests.Visual.Gameplay } }; } + + protected override ScrollingHitObjectContainer CreateScrollingHitObjectContainer() => new TestScrollingHitObjectContainer(); } - private class TestDrawableControlPoint : DrawableHitObject + private partial class TestDrawableControlPoint : DrawableHitObject { public TestDrawableControlPoint(ScrollingDirection direction, double time) : base(new HitObject { StartTime = time, HitWindows = HitWindows.Empty }) @@ -279,22 +310,41 @@ namespace osu.Game.Tests.Visual.Gameplay } } - private class TestDrawableHitObject : DrawableHitObject + private class TestHitObject : HitObject { - public TestDrawableHitObject(double time) - : base(new HitObject { StartTime = time, HitWindows = HitWindows.Empty }) - { - Origin = Anchor.Custom; - OriginPosition = new Vector2(75 / 4.0f); + public readonly float Size; - AutoSizeAxes = Axes.Both; + public TestHitObject(float size) + { + Size = size; + } + } + + private partial class TestDrawableHitObject : DrawableHitObject + { + public TestDrawableHitObject(TestHitObject hitObject) + : base(hitObject) + { + Origin = Anchor.Centre; + Size = new Vector2(hitObject.Size); AddInternal(new Box { - Size = new Vector2(75), + RelativeSizeAxes = Axes.Both, Colour = new Color4(RNG.NextSingle(), RNG.NextSingle(), RNG.NextSingle(), 1) }); } } + + private partial class TestScrollingHitObjectContainer : ScrollingHitObjectContainer + { + protected override RectangleF GetConservativeBoundingBox(HitObjectLifetimeEntry entry) + { + if (entry.HitObject is TestHitObject testObject) + return new RectangleF().Inflate(testObject.Size / 2); + + return base.GetConservativeBoundingBox(entry); + } + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs index 74ce9726e7..119b753d70 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs @@ -1,55 +1,243 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; using osu.Framework.Testing; using osu.Game.Overlays; using osu.Game.Overlays.Settings; +using osu.Game.Overlays.SkinEditor; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Edit; using osu.Game.Screens.Play.HUD.HitErrorMeters; -using osu.Game.Skinning.Editor; +using osu.Game.Skinning; +using osu.Game.Skinning.Components; +using osuTK; using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneSkinEditor : PlayerTestScene + public partial class TestSceneSkinEditor : PlayerTestScene { - private SkinEditor skinEditor; + private SkinEditor skinEditor = null!; protected override bool Autoplay => true; [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + [Cached] + public readonly EditorClipboard Clipboard = new EditorClipboard(); + + private SkinComponentsContainer targetContainer => Player.ChildrenOfType().First(); + [SetUpSteps] public override void SetUpSteps() { base.SetUpSteps(); + AddUntilStep("wait for hud load", () => targetContainer.ComponentsLoaded); + AddStep("reload skin editor", () => { - skinEditor?.Expire(); + if (skinEditor.IsNotNull()) + skinEditor.Expire(); Player.ScaleTo(0.4f); LoadComponentAsync(skinEditor = new SkinEditor(Player), Add); }); 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)); + } + + [TestCase(false)] + [TestCase(true)] + public void TestBringToFront(bool alterSelectionOrder) + { + AddAssert("Ensure over three components available", () => targetContainer.Components.Count, () => Is.GreaterThan(3)); + + IEnumerable originalOrder = null!; + + AddStep("Save order of components before operation", () => originalOrder = targetContainer.Components.Take(3).ToArray()); + + if (alterSelectionOrder) + AddStep("Select first three components in reverse order", () => skinEditor.SelectedComponents.AddRange(originalOrder.Reverse())); + else + AddStep("Select first three components", () => skinEditor.SelectedComponents.AddRange(originalOrder)); + + AddAssert("Components are not front-most", () => targetContainer.Components.TakeLast(3).ToArray(), () => Is.Not.EqualTo(skinEditor.SelectedComponents)); + + AddStep("Bring to front", () => skinEditor.BringSelectionToFront()); + AddAssert("Ensure components are now front-most in original order", () => targetContainer.Components.TakeLast(3).ToArray(), () => Is.EqualTo(originalOrder)); + AddStep("Bring to front again", () => skinEditor.BringSelectionToFront()); + AddAssert("Ensure components are still front-most in original order", () => targetContainer.Components.TakeLast(3).ToArray(), () => Is.EqualTo(originalOrder)); + } + + [TestCase(false)] + [TestCase(true)] + public void TestSendToBack(bool alterSelectionOrder) + { + AddAssert("Ensure over three components available", () => targetContainer.Components.Count, () => Is.GreaterThan(3)); + + IEnumerable originalOrder = null!; + + AddStep("Save order of components before operation", () => originalOrder = targetContainer.Components.TakeLast(3).ToArray()); + + if (alterSelectionOrder) + AddStep("Select last three components in reverse order", () => skinEditor.SelectedComponents.AddRange(originalOrder.Reverse())); + else + AddStep("Select last three components", () => skinEditor.SelectedComponents.AddRange(originalOrder)); + + AddAssert("Components are not back-most", () => targetContainer.Components.Take(3).ToArray(), () => Is.Not.EqualTo(skinEditor.SelectedComponents)); + + AddStep("Send to back", () => skinEditor.SendSelectionToBack()); + AddAssert("Ensure components are now back-most in original order", () => targetContainer.Components.Take(3).ToArray(), () => Is.EqualTo(originalOrder)); + AddStep("Send to back again", () => skinEditor.SendSelectionToBack()); + AddAssert("Ensure components are still back-most in original order", () => targetContainer.Components.Take(3).ToArray(), () => Is.EqualTo(originalOrder)); + } + [Test] public void TestToggleEditor() { - AddToggleStep("toggle editor visibility", visible => skinEditor.ToggleVisibility()); + AddToggleStep("toggle editor visibility", _ => skinEditor.ToggleVisibility()); } [Test] public void TestEditComponent() { - BarHitErrorMeter hitErrorMeter = null; + BarHitErrorMeter hitErrorMeter = null!; AddStep("select bar hit error blueprint", () => { @@ -60,6 +248,10 @@ namespace osu.Game.Tests.Visual.Gameplay skinEditor.SelectedComponents.Add(blueprint.Item); }); + AddStep("move by keyboard", () => InputManager.Key(Key.Right)); + + AddAssert("hitErrorMeter moved", () => hitErrorMeter.X != 0); + AddAssert("value is default", () => hitErrorMeter.JudgementLineThickness.IsDefault); AddStep("hover first slider", () => diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorComponentsList.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorComponentsList.cs index 5385a9983b..2ae5e6f998 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorComponentsList.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorComponentsList.cs @@ -1,17 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Overlays; +using osu.Game.Overlays.SkinEditor; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; -using osu.Game.Skinning.Editor; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneSkinEditorComponentsList : SkinnableTestScene + public partial class TestSceneSkinEditorComponentsList : SkinnableTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs index 5f838b8813..93fec60de4 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs @@ -1,22 +1,26 @@ // 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; using osu.Framework.Testing; using osu.Framework.Timing; +using osu.Game.Overlays.SkinEditor; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Edit; using osu.Game.Screens.Play; -using osu.Game.Skinning.Editor; +using osu.Game.Screens.Play.HUD; using osu.Game.Tests.Gameplay; using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneSkinEditorMultipleSkins : SkinnableTestScene + public partial class TestSceneSkinEditorMultipleSkins : SkinnableTestScene { [Cached] private readonly ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); @@ -27,8 +31,11 @@ namespace osu.Game.Tests.Visual.Gameplay [Cached] private GameplayState gameplayState = TestGameplayState.Create(new OsuRuleset()); + [Cached(typeof(IGameplayClock))] + private readonly IGameplayClock gameplayClock = new GameplayClockContainer(new FramedClock()); + [Cached] - private readonly GameplayClock gameplayClock = new GameplayClock(new FramedClock()); + public readonly EditorClipboard Clipboard = new EditorClipboard(); [SetUpSteps] public void SetUpSteps() @@ -51,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/TestSceneSkinnableAccuracyCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableAccuracyCounter.cs index 9c713b4616..6f079778c5 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableAccuracyCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableAccuracyCounter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneSkinnableAccuracyCounter : SkinnableHUDComponentTestScene + public partial class TestSceneSkinnableAccuracyCounter : SkinnableHUDComponentTestScene { [Cached] private ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs index f507172931..93fa953ef4 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -8,10 +10,11 @@ using osu.Framework.Testing; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneSkinnableComboCounter : SkinnableHUDComponentTestScene + public partial class TestSceneSkinnableComboCounter : SkinnableHUDComponentTestScene { [Cached] private ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs index 35130f3109..c02cec8c75 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Globalization; @@ -11,7 +13,6 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; using osu.Game.Audio; @@ -23,7 +24,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneSkinnableDrawable : OsuTestScene + public partial class TestSceneSkinnableDrawable : OsuTestScene { [Test] public void TestConfineScaleDown() @@ -101,7 +102,7 @@ namespace osu.Game.Tests.Visual.Gameplay Child = new SkinProvidingContainer(secondarySource) { RelativeSizeAxes = Axes.Both, - Child = consumer = new SkinConsumer("test", name => new NamedBox("Default Implementation")) + Child = consumer = new SkinConsumer("test", _ => new NamedBox("Default Implementation")) } }; }); @@ -130,7 +131,7 @@ namespace osu.Game.Tests.Visual.Gameplay }; }); - AddStep("add permissive", () => target.Add(consumer = new SkinConsumer("test", name => new NamedBox("Default Implementation")))); + AddStep("add permissive", () => target.Add(consumer = new SkinConsumer("test", _ => new NamedBox("Default Implementation")))); AddAssert("consumer using override source", () => consumer.Drawable is SecondarySourceBox); AddAssert("skinchanged only called once", () => consumer.SkinChangedCount == 1); } @@ -153,17 +154,17 @@ namespace osu.Game.Tests.Visual.Gameplay }; }); - AddStep("add permissive", () => target.Add(consumer = new SkinConsumer("test", name => new NamedBox("Default Implementation")))); + AddStep("add permissive", () => target.Add(consumer = new SkinConsumer("test", _ => new NamedBox("Default Implementation")))); AddAssert("consumer using override source", () => consumer.Drawable is SecondarySourceBox); AddStep("disable", () => target.Disable()); AddAssert("consumer using base source", () => consumer.Drawable is BaseSourceBox); } - private class SwitchableSkinProvidingContainer : SkinProvidingContainer + private partial class SwitchableSkinProvidingContainer : SkinProvidingContainer { private bool allow = true; - protected override bool AllowDrawableLookup(ISkinComponent component) => allow; + protected override bool AllowDrawableLookup(ISkinComponentLookup lookup) => allow; public void Disable() { @@ -177,17 +178,17 @@ namespace osu.Game.Tests.Visual.Gameplay } } - private class ExposedSkinnableDrawable : SkinnableDrawable + private partial class ExposedSkinnableDrawable : SkinnableDrawable { public new Drawable Drawable => base.Drawable; - public ExposedSkinnableDrawable(string name, Func defaultImplementation, ConfineMode confineMode = ConfineMode.ScaleToFit) - : base(new TestSkinComponent(name), defaultImplementation, confineMode) + public ExposedSkinnableDrawable(string name, Func defaultImplementation, ConfineMode confineMode = ConfineMode.ScaleToFit) + : base(new TestSkinComponentLookup(name), defaultImplementation, confineMode) { } } - private class DefaultBox : DrawWidthBox + private partial class DefaultBox : DrawWidthBox { public DefaultBox() { @@ -195,7 +196,7 @@ namespace osu.Game.Tests.Visual.Gameplay } } - private class DrawWidthBox : Container + private partial class DrawWidthBox : Container { private readonly OsuSpriteText text; @@ -223,7 +224,7 @@ namespace osu.Game.Tests.Visual.Gameplay } } - private class NamedBox : Container + private partial class NamedBox : Container { public NamedBox(string name) { @@ -245,13 +246,13 @@ namespace osu.Game.Tests.Visual.Gameplay } } - private class SkinConsumer : SkinnableDrawable + private partial class SkinConsumer : SkinnableDrawable { public new Drawable Drawable => base.Drawable; public int SkinChangedCount { get; private set; } - public SkinConsumer(string name, Func defaultImplementation) - : base(new TestSkinComponent(name), defaultImplementation) + public SkinConsumer(string name, Func defaultImplementation) + : base(new TestSkinComponentLookup(name), defaultImplementation) { } @@ -262,7 +263,7 @@ namespace osu.Game.Tests.Visual.Gameplay } } - private class BaseSourceBox : NamedBox + private partial class BaseSourceBox : NamedBox { public BaseSourceBox() : base("Base Source") @@ -270,7 +271,7 @@ namespace osu.Game.Tests.Visual.Gameplay } } - private class SecondarySourceBox : NamedBox + private partial class SecondarySourceBox : NamedBox { public SecondarySourceBox() : base("Secondary Source") @@ -287,8 +288,8 @@ namespace osu.Game.Tests.Visual.Gameplay this.size = size; } - public Drawable GetDrawableComponent(ISkinComponent componentName) => - componentName.LookupName == "available" + public Drawable GetDrawableComponent(ISkinComponentLookup componentLookupName) => + (componentLookupName as TestSkinComponentLookup)?.LookupName == "available" ? new DrawWidthBox { Colour = Color4.Yellow, @@ -305,7 +306,7 @@ namespace osu.Game.Tests.Visual.Gameplay private class SecondarySource : ISkin { - public Drawable GetDrawableComponent(ISkinComponent componentName) => new SecondarySourceBox(); + public Drawable GetDrawableComponent(ISkinComponentLookup componentLookupName) => new SecondarySourceBox(); public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); @@ -315,9 +316,9 @@ namespace osu.Game.Tests.Visual.Gameplay } [Cached(typeof(ISkinSource))] - private class SkinSourceContainer : Container, ISkinSource + private partial class SkinSourceContainer : Container, ISkinSource { - public Drawable GetDrawableComponent(ISkinComponent componentName) => new BaseSourceBox(); + public Drawable GetDrawableComponent(ISkinComponentLookup componentLookupName) => new BaseSourceBox(); public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); @@ -336,9 +337,9 @@ namespace osu.Game.Tests.Visual.Gameplay } } - private class TestSkinComponent : ISkinComponent + private class TestSkinComponentLookup : ISkinComponentLookup { - public TestSkinComponent(string name) + public TestSkinComponentLookup(string name) { LookupName = name; } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs index 5f2d9ee9e8..0439656aae 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -16,12 +18,13 @@ 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; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneSkinnableHUDOverlay : SkinnableTestScene + public partial class TestSceneSkinnableHUDOverlay : SkinnableTestScene { private HUDOverlay hudOverlay; @@ -34,14 +37,14 @@ namespace osu.Game.Tests.Visual.Gameplay [Cached] private GameplayState gameplayState = TestGameplayState.Create(new OsuRuleset()); - [Cached] - private readonly GameplayClock gameplayClock = new GameplayClock(new FramedClock()); + [Cached(typeof(IGameplayClock))] + private readonly IGameplayClock gameplayClock = new GameplayClockContainer(new FramedClock()); private IEnumerable hudOverlays => CreatedDrawables.OfType(); // 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() @@ -86,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/TestSceneSkinnableHealthDisplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs index 057798c922..7f6c9d7804 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -14,7 +16,7 @@ using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneSkinnableHealthDisplay : SkinnableHUDComponentTestScene + public partial class TestSceneSkinnableHealthDisplay : SkinnableHUDComponentTestScene { [Cached(typeof(HealthProcessor))] private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs index a871e37ad4..c95e8ee5b2 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneSkinnableScoreCounter : SkinnableHUDComponentTestScene + public partial class TestSceneSkinnableScoreCounter : SkinnableHUDComponentTestScene { [Cached] private ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs index 31abcb6748..3f78dbfd96 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs @@ -11,7 +11,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Audio; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.Testing; using osu.Game.Audio; @@ -19,10 +18,12 @@ using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneSkinnableSound : OsuTestScene + 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() @@ -35,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))); }); } @@ -98,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(); @@ -117,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; }); @@ -130,23 +149,32 @@ namespace osu.Game.Tests.Visual.Gameplay } [Cached(typeof(ISkinSource))] - private class TestSkinSourceContainer : Container, ISkinSource, ISamplePlaybackDisabler + 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(ISkinComponent component) => source?.GetDrawableComponent(component); - 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/TestSceneSkipOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs index e08e03b789..8b1a8307ca 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs @@ -1,9 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Timing; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Play; using osuTK; @@ -12,7 +15,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] - public class TestSceneSkipOverlay : OsuManualInputManagerTestScene + public partial class TestSceneSkipOverlay : OsuManualInputManagerTestScene { private TestSkipOverlay skip; private int requestCount; @@ -20,25 +23,23 @@ namespace osu.Game.Tests.Visual.Gameplay private double increment; private GameplayClockContainer gameplayClockContainer; - private GameplayClock gameplayClock; + private IFrameBasedClock gameplayClock; private const double skip_time = 6000; - [SetUp] - public void SetUp() => Schedule(() => + private void createTest(double skipTime = skip_time) => AddStep("create test", () => { requestCount = 0; increment = skip_time; var working = CreateWorkingBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)); - working.LoadTrack(); Child = gameplayClockContainer = new MasterGameplayClockContainer(working, 0) { RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - skip = new TestSkipOverlay(skip_time) + skip = new TestSkipOverlay(skipTime) { RequestSkip = () => { @@ -50,12 +51,28 @@ namespace osu.Game.Tests.Visual.Gameplay }; gameplayClockContainer.Start(); - gameplayClock = gameplayClockContainer.GameplayClock; + gameplayClock = gameplayClockContainer; }); + [Test] + public void TestSkipTimeZero() + { + createTest(0); + AddUntilStep("wait for skip overlay expired", () => !skip.IsAlive); + } + + [Test] + public void TestSkipTimeEqualToSkip() + { + createTest(MasterGameplayClockContainer.MINIMUM_SKIP_TIME); + AddUntilStep("wait for skip overlay expired", () => !skip.IsAlive); + } + [Test] public void TestFadeOnIdle() { + createTest(); + AddStep("move mouse", () => InputManager.MoveMouseTo(Vector2.Zero)); AddUntilStep("fully visible", () => skip.FadingContent.Alpha == 1); AddUntilStep("wait for fade", () => skip.FadingContent.Alpha < 1); @@ -68,15 +85,28 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestClickableAfterFade() { + createTest(); + AddStep("move mouse", () => InputManager.MoveMouseTo(skip.ScreenSpaceDrawQuad.Centre)); AddUntilStep("wait for fade", () => skip.FadingContent.Alpha == 0); AddStep("click", () => InputManager.Click(MouseButton.Left)); checkRequestCount(1); } + [Test] + public void TestAutomaticSkipActuatesOnce() + { + createTest(); + AddStep("start automated skip", () => skip.SkipWhenReady()); + AddUntilStep("wait for button disabled", () => !skip.IsButtonVisible); + checkRequestCount(1); + } + [Test] public void TestClickOnlyActuatesOnce() { + createTest(); + AddStep("move mouse", () => InputManager.MoveMouseTo(skip.ScreenSpaceDrawQuad.Centre)); AddStep("click", () => { @@ -89,9 +119,21 @@ namespace osu.Game.Tests.Visual.Gameplay checkRequestCount(1); } + [Test] + public void TestAutomaticSkipActuatesMultipleTimes() + { + createTest(); + AddStep("set increment lower", () => increment = 3000); + AddStep("start automated skip", () => skip.SkipWhenReady()); + AddUntilStep("wait for button disabled", () => !skip.IsButtonVisible); + checkRequestCount(2); + } + [Test] public void TestClickOnlyActuatesMultipleTimes() { + createTest(); + AddStep("set increment lower", () => increment = 3000); AddStep("move mouse", () => InputManager.MoveMouseTo(skip.ScreenSpaceDrawQuad.Centre)); AddStep("click", () => InputManager.Click(MouseButton.Left)); @@ -104,6 +146,8 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestDoesntFadeOnMouseDown() { + createTest(); + AddStep("move mouse", () => InputManager.MoveMouseTo(skip.ScreenSpaceDrawQuad.Centre)); AddStep("button down", () => InputManager.PressButton(MouseButton.Left)); AddUntilStep("wait for overlay disappear", () => !skip.OverlayContent.IsPresent); @@ -112,10 +156,13 @@ namespace osu.Game.Tests.Visual.Gameplay checkRequestCount(0); } - private void checkRequestCount(int expected) => - AddAssert($"request count is {expected}", () => requestCount == expected); + private void checkRequestCount(int expected) + { + AddAssert($"skip count is {expected}", () => skip.SkipCount, () => Is.EqualTo(expected)); + AddAssert($"request count is {expected}", () => requestCount, () => Is.EqualTo(expected)); + } - private class TestSkipOverlay : SkipOverlay + private partial class TestSkipOverlay : SkipOverlay { public TestSkipOverlay(double startTime) : base(startTime) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSliderPath.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSliderPath.cs index 9750838433..dfa9fdf03b 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSliderPath.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSliderPath.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -12,7 +14,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneSliderPath : OsuTestScene + public partial class TestSceneSliderPath : OsuTestScene { private readonly SmoothPath drawablePath; private SliderPath path; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs new file mode 100644 index 0000000000..8ae6a2a5fc --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs @@ -0,0 +1,123 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Select; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public partial class TestSceneSoloGameplayLeaderboard : OsuTestScene + { + [Cached] + private readonly ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); + + private readonly BindableList scores = new BindableList(); + + private readonly Bindable configVisibility = new Bindable(); + private readonly Bindable beatmapTabType = new Bindable(); + + private SoloGameplayLeaderboard leaderboard = null!; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + config.BindWith(OsuSetting.GameplayLeaderboard, configVisibility); + config.BindWith(OsuSetting.BeatmapDetailTab, beatmapTabType); + } + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("clear scores", () => scores.Clear()); + + AddStep("create component", () => + { + var trackingUser = new APIUser + { + Username = "local user", + Id = 2, + }; + + Child = leaderboard = new SoloGameplayLeaderboard(trackingUser) + { + Scores = { BindTarget = scores }, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AlwaysVisible = { Value = false }, + Expanded = { Value = true }, + }; + }); + + AddStep("add scores", () => scores.AddRange(createSampleScores())); + } + + [Test] + public void TestLocalUser() + { + AddSliderStep("score", 0, 1000000, 500000, v => scoreProcessor.TotalScore.Value = v); + AddSliderStep("accuracy", 0f, 1f, 0.5f, v => scoreProcessor.Accuracy.Value = v); + AddSliderStep("combo", 0, 10000, 0, v => scoreProcessor.HighestCombo.Value = v); + AddStep("toggle expanded", () => leaderboard.Expanded.Value = !leaderboard.Expanded.Value); + } + + [TestCase(PlayBeatmapDetailArea.TabType.Local, 51)] + [TestCase(PlayBeatmapDetailArea.TabType.Global, null)] + [TestCase(PlayBeatmapDetailArea.TabType.Country, null)] + [TestCase(PlayBeatmapDetailArea.TabType.Friends, null)] + public void TestTrackedScorePosition(PlayBeatmapDetailArea.TabType tabType, int? expectedOverflowIndex) + { + AddStep($"change TabType to {tabType}", () => beatmapTabType.Value = tabType); + AddUntilStep("tracked player is #50", () => leaderboard.TrackedScore?.ScorePosition, () => Is.EqualTo(50)); + + AddStep("add one more score", () => scores.Add(new ScoreInfo { User = new APIUser { Username = "New player 1" }, TotalScore = RNG.Next(600000, 1000000) })); + + AddUntilStep("wait for sort", () => leaderboard.ChildrenOfType().First().ScorePosition != null); + + if (expectedOverflowIndex == null) + AddUntilStep("tracked player has null position", () => leaderboard.TrackedScore?.ScorePosition, () => Is.Null); + else + AddUntilStep($"tracked player is #{expectedOverflowIndex}", () => leaderboard.TrackedScore?.ScorePosition, () => Is.EqualTo(expectedOverflowIndex)); + } + + [Test] + public void TestVisibility() + { + AddStep("set config visible true", () => configVisibility.Value = true); + AddUntilStep("leaderboard visible", () => leaderboard.Alpha == 1); + + AddStep("set config visible false", () => configVisibility.Value = false); + AddUntilStep("leaderboard not visible", () => leaderboard.Alpha == 0); + + AddStep("set always visible", () => leaderboard.AlwaysVisible.Value = true); + AddUntilStep("leaderboard visible", () => leaderboard.Alpha == 1); + + AddStep("set config visible true", () => configVisibility.Value = true); + AddAssert("leaderboard still visible", () => leaderboard.Alpha == 1); + } + + private static List createSampleScores() + { + return new[] + { + new ScoreInfo { User = new APIUser { Username = @"peppy" }, TotalScore = RNG.Next(500000, 1000000) }, + new ScoreInfo { User = new APIUser { Username = @"smoogipoo" }, TotalScore = RNG.Next(500000, 1000000) }, + new ScoreInfo { User = new APIUser { Username = @"spaceman_atlas" }, TotalScore = RNG.Next(500000, 1000000) }, + new ScoreInfo { User = new APIUser { Username = @"frenzibyte" }, TotalScore = RNG.Next(500000, 1000000) }, + new ScoreInfo { User = new APIUser { Username = @"Susko3" }, TotalScore = RNG.Next(500000, 1000000) }, + }.Concat(Enumerable.Range(0, 44).Select(i => new ScoreInfo { User = new APIUser { Username = $"User {i + 1}" }, TotalScore = 1000000 - i * 10000 })).ToList(); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSongProgress.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSongProgress.cs index 733e8f4290..5855838d3c 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSongProgress.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSongProgress.cs @@ -1,159 +1,89 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; +using System; +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Framework.Testing; -using osu.Framework.Utils; -using osu.Framework.Timing; -using osu.Game.Graphics; -using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.UI; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] - public class TestSceneSongProgress : OsuTestScene + public partial class TestSceneSongProgress : SkinnableHUDComponentTestScene { - private SongProgress progress; - private TestSongProgressGraph graph; - private readonly Container progressContainer; + private GameplayClockContainer gameplayClockContainer = null!; - private readonly StopwatchClock clock; - private readonly FramedClock framedClock; + private const double skip_target_time = -2000; - [Cached] - private readonly GameplayClock gameplayClock; - - public TestSceneSongProgress() + [BackgroundDependencyLoader] + private void load() { - clock = new StopwatchClock(); - gameplayClock = new GameplayClock(framedClock = new FramedClock(clock)); + Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); - Add(progressContainer = new Container + FrameStabilityContainer frameStabilityContainer; + + Add(gameplayClockContainer = new MasterGameplayClockContainer(Beatmap.Value, skip_target_time) { - RelativeSizeAxes = Axes.X, - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Height = 100, - Y = -100, - Child = new Box + Child = frameStabilityContainer = new FrameStabilityContainer { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(1), + MaxCatchUpFrames = 1 } }); + + Dependencies.CacheAs(gameplayClockContainer); + Dependencies.CacheAs(frameStabilityContainer); } [SetUpSteps] public void SetupSteps() { - AddStep("add new song progress", () => + AddStep("reset clock", () => gameplayClockContainer.Reset()); + AddStep("set hit objects", () => this.ChildrenOfType().ForEach(progress => progress.Objects = Beatmap.Value.Beatmap.HitObjects)); + AddStep("hook seeking", () => { - if (progress != null) - { - progress.Expire(); - progress = null; - } - - progressContainer.Add(progress = new SongProgress - { - RelativeSizeAxes = Axes.X, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - }); + applyToDefaultProgress(d => d.ChildrenOfType().Single().OnSeek += t => gameplayClockContainer.Seek(t)); + applyToArgonProgress(d => d.ChildrenOfType().Single().OnSeek += t => gameplayClockContainer.Seek(t)); }); - - AddStep("add new big graph", () => - { - if (graph != null) - { - graph.Expire(); - graph = null; - } - - Add(graph = new TestSongProgressGraph - { - RelativeSizeAxes = Axes.X, - Height = 200, - Anchor = Anchor.TopLeft, - Origin = Anchor.TopLeft, - }); - }); - - AddStep("reset clock", clock.Reset); + AddStep("seek to intro", () => gameplayClockContainer.Seek(skip_target_time)); + AddStep("start", () => gameplayClockContainer.Start()); } [Test] - public void TestGraphRecreation() + public void TestBasic() { - AddAssert("ensure not created", () => graph.CreationCount == 0); - AddStep("display values", displayRandomValues); - AddUntilStep("wait for creation count", () => graph.CreationCount == 1); - AddRepeatStep("new values", displayRandomValues, 5); - AddWaitStep("wait some", 5); - AddAssert("ensure recreation debounced", () => graph.CreationCount == 2); - } - - [Test] - public void TestDisplay() - { - AddStep("display max values", displayMaxValues); - AddUntilStep("wait for graph", () => graph.CreationCount == 1); - AddStep("start", clock.Start); - AddStep("allow seeking", () => progress.AllowSeeking.Value = true); - AddStep("hide graph", () => progress.ShowGraph.Value = false); - AddStep("disallow seeking", () => progress.AllowSeeking.Value = false); - AddStep("allow seeking", () => progress.AllowSeeking.Value = true); - AddStep("show graph", () => progress.ShowGraph.Value = true); - AddStep("stop", clock.Stop); - } - - private void displayRandomValues() - { - var objects = new List(); - for (double i = 0; i < 5000; i += RNG.NextDouble() * 10 + i / 1000) - objects.Add(new HitObject { StartTime = i }); - - replaceObjects(objects); - } - - private void displayMaxValues() - { - var objects = new List(); - for (double i = 0; i < 5000; i++) - objects.Add(new HitObject { StartTime = i }); - - replaceObjects(objects); - } - - private void replaceObjects(List objects) - { - progress.Objects = objects; - graph.Objects = objects; - - progress.RequestSeek = pos => clock.Seek(pos); - } - - protected override void Update() - { - base.Update(); - framedClock.ProcessFrame(); - } - - private class TestSongProgressGraph : SongProgressGraph - { - public int CreationCount { get; private set; } - - protected override void RecreateGraph() + AddToggleStep("toggle seeking", b => { - base.RecreateGraph(); - CreationCount++; - } + applyToDefaultProgress(s => s.Interactive.Value = b); + applyToArgonProgress(s => s.Interactive.Value = b); + }); + + AddToggleStep("toggle graph", b => + { + applyToDefaultProgress(s => s.ShowGraph.Value = b); + applyToArgonProgress(s => s.ShowGraph.Value = b); + }); + + AddStep("stop", gameplayClockContainer.Stop); } + + private void applyToArgonProgress(Action action) => + this.ChildrenOfType().ForEach(action); + + private void applyToDefaultProgress(Action action) => + this.ChildrenOfType().ForEach(action); + + protected override Drawable CreateDefaultImplementation() => new DefaultSongProgress(); + + protected override Drawable CreateArgonImplementation() => new ArgonSongProgress(); + + protected override Drawable CreateLegacyImplementation() => new LegacySongProgress(); } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs index b5cdd61ee5..ffd034e4d2 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -26,7 +28,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneSpectator : ScreenTestScene + public partial class TestSceneSpectator : ScreenTestScene { private readonly APIUser streamingUser = new APIUser { Id = MultiplayerTestScene.PLAYER_1_ID, Username = "Test user" }; @@ -83,7 +85,7 @@ namespace osu.Game.Tests.Visual.Gameplay sendFrames(startTime: gameplay_start); - AddAssert("time is greater than seek target", () => currentFrameStableTime > gameplay_start); + AddAssert("time is greater than seek target", () => currentFrameStableTime, () => Is.GreaterThan(gameplay_start)); } /// @@ -117,7 +119,7 @@ namespace osu.Game.Tests.Visual.Gameplay waitForPlayer(); AddUntilStep("state is playing", () => spectatorClient.WatchedUserStates[streamingUser.Id].State == SpectatedUserState.Playing); - AddAssert("time is greater than seek target", () => currentFrameStableTime > gameplay_start); + AddAssert("time is greater than seek target", () => currentFrameStableTime, () => Is.GreaterThan(gameplay_start)); } [Test] @@ -145,7 +147,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for frame starvation", () => replayHandler.WaitingForFrame); checkPaused(true); - AddAssert("time advanced", () => currentFrameStableTime > pausedTime); + AddAssert("time advanced", () => currentFrameStableTime, () => Is.GreaterThan(pausedTime)); } [Test] @@ -174,7 +176,7 @@ namespace osu.Game.Tests.Visual.Gameplay sendFrames(300); - AddUntilStep("playing from correct point in time", () => player.ChildrenOfType().First().FrameStableClock.CurrentTime > 30000); + AddUntilStep("playing from correct point in time", () => player.ChildrenOfType().First().FrameStableClock.CurrentTime, () => Is.GreaterThan(30000)); } [Test] @@ -259,7 +261,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestFinalFramesPurgedBeforeEndingPlay() { - AddStep("begin playing", () => spectatorClient.BeginPlaying(TestGameplayState.Create(new OsuRuleset()), new Score())); + AddStep("begin playing", () => spectatorClient.BeginPlaying(0, TestGameplayState.Create(new OsuRuleset()), new Score())); AddStep("send frames and finish play", () => { @@ -361,7 +363,7 @@ namespace osu.Game.Tests.Visual.Gameplay private Player player => Stack.CurrentScreen as Player; private double currentFrameStableTime - => player.ChildrenOfType().First().FrameStableClock.CurrentTime; + => player.ChildrenOfType().First().CurrentTime; private void waitForPlayer() => AddUntilStep("wait for player", () => (Stack.CurrentScreen as Player)?.IsLoaded == true); @@ -386,7 +388,7 @@ namespace osu.Game.Tests.Visual.Gameplay /// /// Used for the sole purpose of adding as a resolvable dependency. /// - private class DependenciesScreen : OsuScreen + private partial class DependenciesScreen : OsuScreen { [Cached(typeof(SpectatorClient))] public readonly TestSpectatorClient SpectatorClient = new TestSpectatorClient(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorHost.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorHost.cs index 034519fbf8..1c09c29748 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorHost.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorHost.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Screens; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Spectator; @@ -12,7 +15,7 @@ using osu.Game.Tests.Visual.Spectator; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneSpectatorHost : PlayerTestScene + public partial class TestSceneSpectatorHost : PlayerTestScene { protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); @@ -41,11 +44,26 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("spectator client sent correct ruleset", () => spectatorClient.WatchedUserStates[dummy_user_id].RulesetID == Ruleset.Value.OnlineID); } + [Test] + public void TestRestart() + { + AddAssert("spectator client sees playing state", () => spectatorClient.WatchedUserStates[dummy_user_id].State == SpectatedUserState.Playing); + + AddStep("exit player", () => Player.Exit()); + AddStep("reload player", LoadPlayer); + AddUntilStep("wait for player load", () => Player.IsLoaded && Player.Alpha == 1); + + AddAssert("spectator client sees playing state", () => spectatorClient.WatchedUserStates[dummy_user_id].State == SpectatedUserState.Playing); + + AddWaitStep("wait", 5); + AddUntilStep("spectator client still sees playing state", () => spectatorClient.WatchedUserStates[dummy_user_id].State == SpectatedUserState.Playing); + } + public override void TearDownSteps() { base.TearDownSteps(); AddStep("stop watching user", () => spectatorClient.StopWatchingUser(dummy_user_id)); - AddStep("remove test spectator client", () => Remove(spectatorClient)); + AddStep("remove test spectator client", () => Remove(spectatorClient, false)); } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs index 2d2e05c4c9..794860b9ec 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -25,7 +27,6 @@ using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays.Types; using osu.Game.Rulesets.UI; using osu.Game.Scoring; -using osu.Game.Screens.Play; using osu.Game.Tests.Gameplay; using osu.Game.Tests.Mods; using osu.Game.Tests.Visual.Spectator; @@ -34,21 +35,17 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneSpectatorPlayback : OsuManualInputManagerTestScene + public partial class TestSceneSpectatorPlayback : OsuManualInputManagerTestScene { private TestRulesetInputManager playbackManager; private TestRulesetInputManager recordingManager; - private Replay replay; - + private Score recordingScore; + private Replay playbackReplay; private TestSpectatorClient spectatorClient; - private ManualClock manualClock; - private TestReplayRecorder recorder; - private OsuSpriteText latencyDisplay; - private TestFramedReplayInputHandler replayHandler; [SetUpSteps] @@ -56,7 +53,16 @@ namespace osu.Game.Tests.Visual.Gameplay { AddStep("Setup containers", () => { - replay = new Replay(); + recordingScore = new Score + { + ScoreInfo = + { + BeatmapInfo = new BeatmapInfo(), + Ruleset = new OsuRuleset().RulesetInfo, + } + }; + + playbackReplay = new Replay(); manualClock = new ManualClock(); Child = new DependencyProvidingContainer @@ -65,7 +71,6 @@ namespace osu.Game.Tests.Visual.Gameplay CachedDependencies = new[] { (typeof(SpectatorClient), (object)(spectatorClient = new TestSpectatorClient())), - (typeof(GameplayState), TestGameplayState.Create(new OsuRuleset())) }, Children = new Drawable[] { @@ -79,7 +84,7 @@ namespace osu.Game.Tests.Visual.Gameplay { recordingManager = new TestRulesetInputManager(TestCustomisableModRuleset.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) { - Recorder = recorder = new TestReplayRecorder + Recorder = recorder = new TestReplayRecorder(recordingScore) { ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos), }, @@ -110,7 +115,7 @@ namespace osu.Game.Tests.Visual.Gameplay playbackManager = new TestRulesetInputManager(TestCustomisableModRuleset.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) { Clock = new FramedClock(manualClock), - ReplayInputHandler = replayHandler = new TestFramedReplayInputHandler(replay) + ReplayInputHandler = replayHandler = new TestFramedReplayInputHandler(playbackReplay) { GamefieldToScreenSpace = pos => playbackManager.ToScreenSpace(pos), }, @@ -142,6 +147,7 @@ namespace osu.Game.Tests.Visual.Gameplay } }; + spectatorClient.BeginPlaying(0, TestGameplayState.Create(new OsuRuleset()), recordingScore); spectatorClient.OnNewFrames += onNewFrames; }); } @@ -149,15 +155,15 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestBasic() { - AddUntilStep("received frames", () => replay.Frames.Count > 50); + AddUntilStep("received frames", () => playbackReplay.Frames.Count > 50); AddStep("stop sending frames", () => recorder.Expire()); - AddUntilStep("wait for all frames received", () => replay.Frames.Count == recorder.SentFrames.Count); + AddUntilStep("wait for all frames received", () => playbackReplay.Frames.Count == recorder.SentFrames.Count); } [Test] public void TestWithSendFailure() { - AddUntilStep("received frames", () => replay.Frames.Count > 50); + AddUntilStep("received frames", () => playbackReplay.Frames.Count > 50); int framesReceivedSoFar = 0; int frameSendAttemptsSoFar = 0; @@ -165,21 +171,26 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("start failing sends", () => { spectatorClient.ShouldFailSendingFrames = true; - framesReceivedSoFar = replay.Frames.Count; frameSendAttemptsSoFar = spectatorClient.FrameSendAttempts; }); - AddUntilStep("wait for send attempts", () => spectatorClient.FrameSendAttempts > frameSendAttemptsSoFar + 5); - AddAssert("frames did not increase", () => framesReceivedSoFar == replay.Frames.Count); + AddUntilStep("wait for next send attempt", () => + { + framesReceivedSoFar = playbackReplay.Frames.Count; + return spectatorClient.FrameSendAttempts > frameSendAttemptsSoFar + 1; + }); + + AddUntilStep("wait for more send attempts", () => spectatorClient.FrameSendAttempts > frameSendAttemptsSoFar + 10); + AddAssert("frames did not increase", () => framesReceivedSoFar == playbackReplay.Frames.Count); AddStep("stop failing sends", () => spectatorClient.ShouldFailSendingFrames = false); - AddUntilStep("wait for next frames", () => framesReceivedSoFar < replay.Frames.Count); + AddUntilStep("wait for next frames", () => framesReceivedSoFar < playbackReplay.Frames.Count); AddStep("stop sending frames", () => recorder.Expire()); - AddUntilStep("wait for all frames received", () => replay.Frames.Count == recorder.SentFrames.Count); - AddAssert("ensure frames were received in the correct sequence", () => replay.Frames.Select(f => f.Time).SequenceEqual(recorder.SentFrames.Select(f => f.Time))); + AddUntilStep("wait for all frames received", () => playbackReplay.Frames.Count == recorder.SentFrames.Count); + AddAssert("ensure frames were received in the correct sequence", () => playbackReplay.Frames.Select(f => f.Time).SequenceEqual(recorder.SentFrames.Select(f => f.Time))); } private void onNewFrames(int userId, FrameDataBundle frames) @@ -188,10 +199,10 @@ namespace osu.Game.Tests.Visual.Gameplay { var frame = new TestReplayFrame(); frame.FromLegacy(legacyFrame, null); - replay.Frames.Add(frame); + playbackReplay.Frames.Add(frame); } - Logger.Log($"Received {frames.Frames.Count} new frames (total {replay.Frames.Count} of {recorder.SentFrames.Count})"); + Logger.Log($"Received {frames.Frames.Count} new frames (total {playbackReplay.Frames.Count} of {recorder.SentFrames.Count})"); } private double latency = SpectatorClient.TIME_BETWEEN_SENDS; @@ -212,7 +223,7 @@ namespace osu.Game.Tests.Visual.Gameplay if (!replayHandler.HasFrames) return; - var lastFrame = replay.Frames.LastOrDefault(); + var lastFrame = playbackReplay.Frames.LastOrDefault(); // this isn't perfect as we basically can't be aware of the rate-of-send here (the streamer is not sending data when not being moved). // in gameplay playback, the case where NextFrame is null would pause gameplay and handle this correctly; it's strictly a test limitation / best effort implementation. @@ -246,7 +257,7 @@ namespace osu.Game.Tests.Visual.Gameplay } } - public class TestInputConsumer : CompositeDrawable, IKeyBindingHandler + public partial class TestInputConsumer : CompositeDrawable, IKeyBindingHandler { public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parent.ReceivePositionalInputAt(screenSpacePos); @@ -289,7 +300,7 @@ namespace osu.Game.Tests.Visual.Gameplay } } - public class TestRulesetInputManager : RulesetInputManager + public partial class TestRulesetInputManager : RulesetInputManager { public TestRulesetInputManager(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) : base(ruleset, variant, unique) @@ -299,7 +310,7 @@ namespace osu.Game.Tests.Visual.Gameplay protected override KeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) => new TestKeyBindingContainer(); - internal class TestKeyBindingContainer : KeyBindingContainer + internal partial class TestKeyBindingContainer : KeyBindingContainer { public override IEnumerable DefaultKeyBindings => new[] { @@ -349,19 +360,12 @@ namespace osu.Game.Tests.Visual.Gameplay Down, } - internal class TestReplayRecorder : ReplayRecorder + internal partial class TestReplayRecorder : ReplayRecorder { public List SentFrames = new List(); - public TestReplayRecorder() - : base(new Score - { - ScoreInfo = - { - BeatmapInfo = new BeatmapInfo(), - Ruleset = new OsuRuleset().RulesetInfo, - } - }) + public TestReplayRecorder(Score score) + : base(score) { } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStarCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStarCounter.cs index 717485bcc1..699c8ea20a 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStarCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStarCounter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Utils; @@ -11,7 +13,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] - public class TestSceneStarCounter : OsuTestScene + public partial class TestSceneStarCounter : OsuTestScene { private readonly StarCounter starCounter; private readonly OsuSpriteText starsLabel; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs index 014ccb1652..dbce62cbef 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs @@ -20,10 +20,11 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] - public class TestSceneStoryboard : OsuTestScene + public partial class TestSceneStoryboard : OsuTestScene { - private Container storyboardContainer; - private DrawableStoryboard storyboard; + private Container storyboardContainer = null!; + + private DrawableStoryboard? storyboard; [Test] public void TestStoryboard() @@ -38,7 +39,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestStoryboardMissingVideo() { - AddStep("Load storyboard with missing video", loadStoryboardNoVideo); + AddStep("Load storyboard with missing video", () => loadStoryboard("storyboard_no_video.osu")); } [BackgroundDependencyLoader] @@ -75,53 +76,44 @@ namespace osu.Game.Tests.Visual.Gameplay Beatmap.BindValueChanged(beatmapChanged, true); } - private void beatmapChanged(ValueChangedEvent e) => loadStoryboard(e.NewValue); + private void beatmapChanged(ValueChangedEvent e) => loadStoryboard(e.NewValue.Storyboard); private void restart() { var track = Beatmap.Value.Track; track.Reset(); - loadStoryboard(Beatmap.Value); + loadStoryboard(Beatmap.Value.Storyboard); track.Start(); } - private void loadStoryboard(IWorkingBeatmap working) + private void loadStoryboard(Storyboard toLoad) { if (storyboard != null) - storyboardContainer.Remove(storyboard); + storyboardContainer.Remove(storyboard, true); var decoupledClock = new DecoupleableInterpolatingFramedClock { IsCoupled = true }; storyboardContainer.Clock = decoupledClock; - storyboard = working.Storyboard.CreateDrawable(SelectedMods.Value); + storyboard = toLoad.CreateDrawable(SelectedMods.Value); storyboard.Passing = false; - storyboardContainer.Add(storyboard); - decoupledClock.ChangeSource(working.Track); - } - - private void loadStoryboardNoVideo() - { - if (storyboard != null) - storyboardContainer.Remove(storyboard); - - var decoupledClock = new DecoupleableInterpolatingFramedClock { IsCoupled = true }; - storyboardContainer.Clock = decoupledClock; - - Storyboard sb; - - using (var str = TestResources.OpenResource("storyboard_no_video.osu")) - using (var bfr = new LineBufferedReader(str)) - { - var decoder = new LegacyStoryboardDecoder(); - sb = decoder.Decode(bfr); - } - - storyboard = sb.CreateDrawable(SelectedMods.Value); - storyboardContainer.Add(storyboard); decoupledClock.ChangeSource(Beatmap.Value.Track); } + + private void loadStoryboard(string filename) + { + Storyboard loaded; + + using (var str = TestResources.OpenResource(filename)) + using (var bfr = new LineBufferedReader(str)) + { + var decoder = new LegacyStoryboardDecoder(); + loaded = decoder.Decode(bfr); + } + + loadStoryboard(loaded); + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardSamplePlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardSamplePlayback.cs index 909cab5e3d..a9d4508f70 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardSamplePlayback.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardSamplePlayback.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -21,7 +23,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneStoryboardSamplePlayback : PlayerTestScene + public partial class TestSceneStoryboardSamplePlayback : PlayerTestScene { private Storyboard storyboard; @@ -119,7 +121,7 @@ namespace osu.Game.Tests.Visual.Gameplay private void createPlayerTest() { - CreateTest(null); + CreateTest(); AddAssert("storyboard loaded", () => Player.Beatmap.Value.Storyboard != null); waitUntilStoryboardSamplesPlay(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs index b87183cbc7..283866bef2 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using System.Threading.Tasks; @@ -11,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; @@ -24,12 +27,13 @@ using osuTK; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneStoryboardWithOutro : PlayerTestScene + public partial class TestSceneStoryboardWithOutro : PlayerTestScene { protected override bool HasCustomSteps => true; protected new OutroPlayer Player => (OutroPlayer)base.Player; + private double currentBeatmapDuration; private double currentStoryboardDuration; private bool showResults = true; @@ -42,33 +46,35 @@ namespace osu.Game.Tests.Visual.Gameplay base.SetUpSteps(); AddStep("enable storyboard", () => LocalConfig.SetValue(OsuSetting.ShowStoryboard, true)); AddStep("set dim level to 0", () => LocalConfig.SetValue(OsuSetting.DimLevel, 0)); - AddStep("reset fail conditions", () => currentFailConditions = (_, __) => false); - AddStep("set storyboard duration to 2s", () => currentStoryboardDuration = 2000); + AddStep("reset fail conditions", () => currentFailConditions = (_, _) => false); + AddStep("set beatmap duration to 0s", () => currentBeatmapDuration = 0); + AddStep("set storyboard duration to 8s", () => currentStoryboardDuration = 8000); AddStep("set ShowResults = true", () => showResults = true); } [Test] public void TestStoryboardSkipOutro() { - CreateTest(null); + AddStep("set storyboard duration to long", () => currentStoryboardDuration = 200000); + CreateTest(); AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value); AddStep("skip outro", () => InputManager.Key(osuTK.Input.Key.Space)); - AddAssert("player is no longer current screen", () => !Player.IsCurrentScreen()); + AddUntilStep("player is no longer current screen", () => !Player.IsCurrentScreen()); AddUntilStep("wait for score shown", () => Player.IsScoreShown); } [Test] public void TestStoryboardNoSkipOutro() { - CreateTest(null); - AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); + CreateTest(); + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.CurrentTime >= currentStoryboardDuration); AddUntilStep("wait for score shown", () => Player.IsScoreShown); } [Test] public void TestStoryboardExitDuringOutroStillExits() { - CreateTest(null); + CreateTest(); AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value); AddStep("exit via pause", () => Player.ExitViaPause()); AddAssert("player exited", () => !Player.IsCurrentScreen() && Player.GetChildScreen() == null); @@ -78,7 +84,7 @@ namespace osu.Game.Tests.Visual.Gameplay [TestCase(true)] public void TestStoryboardToggle(bool enabledAtBeginning) { - CreateTest(null); + CreateTest(); AddStep($"{(enabledAtBeginning ? "enable" : "disable")} storyboard", () => LocalConfig.SetValue(OsuSetting.ShowStoryboard, enabledAtBeginning)); AddStep("toggle storyboard", () => LocalConfig.SetValue(OsuSetting.ShowStoryboard, !enabledAtBeginning)); AddUntilStep("wait for score shown", () => Player.IsScoreShown); @@ -89,7 +95,7 @@ namespace osu.Game.Tests.Visual.Gameplay { CreateTest(() => { - AddStep("fail on first judgement", () => currentFailConditions = (_, __) => true); + AddStep("fail on first judgement", () => currentFailConditions = (_, _) => true); // Fail occurs at 164ms with the provided beatmap. // Fail animation runs for 2.5s realtime but the gameplay time change is *variable* due to the frequency transform being applied, so we need a bit of lenience. @@ -97,10 +103,30 @@ namespace osu.Game.Tests.Visual.Gameplay }); AddUntilStep("wait for fail", () => Player.GameplayState.HasFailed); - AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.CurrentTime >= currentStoryboardDuration); 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() { @@ -108,7 +134,7 @@ namespace osu.Game.Tests.Visual.Gameplay { AddStep("set ShowResults = false", () => showResults = false); }); - AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.CurrentTime >= currentStoryboardDuration); AddWaitStep("wait", 10); AddAssert("no score shown", () => !Player.IsScoreShown); } @@ -117,7 +143,7 @@ namespace osu.Game.Tests.Visual.Gameplay public void TestStoryboardEndsBeforeCompletion() { CreateTest(() => AddStep("set storyboard duration to .1s", () => currentStoryboardDuration = 100)); - AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.CurrentTime >= currentStoryboardDuration); AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value); AddUntilStep("wait for score shown", () => Player.IsScoreShown); } @@ -127,7 +153,7 @@ namespace osu.Game.Tests.Visual.Gameplay { SkipOverlay.FadeContainer fadeContainer() => Player.ChildrenOfType().First(); - CreateTest(null); + CreateTest(); AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value); AddUntilStep("skip overlay content becomes visible", () => fadeContainer().State == Visibility.Visible); @@ -135,19 +161,37 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("skip overlay content not visible", () => fadeContainer().State == Visibility.Hidden); AddUntilStep("skip overlay content becomes visible", () => fadeContainer().State == Visibility.Visible); - AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.CurrentTime >= currentStoryboardDuration); } [Test] public void TestPerformExitNoOutro() { - CreateTest(null); + CreateTest(); AddStep("disable storyboard", () => LocalConfig.SetValue(OsuSetting.ShowStoryboard, false)); AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value); AddStep("exit via pause", () => Player.ExitViaPause()); AddAssert("player exited", () => Stack.CurrentScreen == null); } + [Test] + public void TestPerformExitAfterOutro() + { + CreateTest(() => + { + AddStep("set beatmap duration to 4s", () => currentBeatmapDuration = 4000); + AddStep("set storyboard duration to 1s", () => currentStoryboardDuration = 1000); + }); + + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.CurrentTime >= currentStoryboardDuration); + AddStep("exit via pause", () => Player.ExitViaPause()); + AddAssert("player paused", () => !Player.IsResuming); + + AddStep("resume player", () => Player.Resume()); + AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value); + AddUntilStep("wait for score shown", () => Player.IsScoreShown); + } + protected override bool AllowFail => true; protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); @@ -157,7 +201,7 @@ namespace osu.Game.Tests.Visual.Gameplay protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) { var beatmap = new Beatmap(); - beatmap.HitObjects.Add(new HitCircle()); + beatmap.HitObjects.Add(new HitCircle { StartTime = currentBeatmapDuration }); return beatmap; } @@ -175,7 +219,7 @@ namespace osu.Game.Tests.Visual.Gameplay return storyboard; } - protected class OutroPlayer : TestPlayer + protected partial class OutroPlayer : TestPlayer { public void ExitViaPause() => PerformExit(true); @@ -186,7 +230,7 @@ namespace osu.Game.Tests.Visual.Gameplay private event Func failConditions; public OutroPlayer(Func failConditions, bool showResults = true) - : base(false, showResults) + : base(showResults: showResults) { this.failConditions = failConditions; } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneUnknownMod.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneUnknownMod.cs index c0f1112905..cb5631e599 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneUnknownMod.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneUnknownMod.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -8,7 +10,7 @@ using osu.Game.Rulesets.Osu; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneUnknownMod : ModTestScene + public partial class TestSceneUnknownMod : ModTestScene { protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneUnstableRateCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneUnstableRateCounter.cs index ca8ecd490d..d0e516ed39 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneUnstableRateCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneUnstableRateCounter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -15,17 +17,23 @@ using osuTK; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneUnstableRateCounter : OsuTestScene + public partial class TestSceneUnstableRateCounter : OsuTestScene { [Cached(typeof(ScoreProcessor))] private TestScoreProcessor scoreProcessor = new TestScoreProcessor(); - private readonly OsuHitWindows hitWindows = new OsuHitWindows(); + private readonly OsuHitWindows hitWindows; private UnstableRateCounter counter; private double prev; + public TestSceneUnstableRateCounter() + { + hitWindows = new OsuHitWindows(); + hitWindows.SetDifficulty(5); + } + [SetUpSteps] public void SetUp() { @@ -101,7 +109,7 @@ namespace osu.Game.Tests.Visual.Gameplay }); } - private class TestScoreProcessor : ScoreProcessor + private partial class TestScoreProcessor : ScoreProcessor { public TestScoreProcessor() : base(new OsuRuleset()) diff --git a/osu.Game.Tests/Visual/Menus/IntroTestScene.cs b/osu.Game.Tests/Visual/Menus/IntroTestScene.cs index c68cd39c65..b09dbc1a91 100644 --- a/osu.Game.Tests/Visual/Menus/IntroTestScene.cs +++ b/osu.Game.Tests/Visual/Menus/IntroTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -15,7 +17,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Menus { [TestFixture] - public abstract class IntroTestScene : OsuTestScene + public abstract partial class IntroTestScene : OsuTestScene { [Cached] private OsuLogo logo; @@ -50,6 +52,7 @@ namespace osu.Game.Tests.Visual.Menus }, notifications = new NotificationOverlay { + Depth = float.MinValue, Anchor = Anchor.TopRight, Origin = Anchor.TopRight, } @@ -80,7 +83,14 @@ namespace osu.Game.Tests.Visual.Menus [Test] public virtual void TestPlayIntroWithFailingAudioDevice() { - AddStep("hide notifications", () => notifications.Hide()); + AddStep("reset notifications", () => + { + notifications.Show(); + notifications.Hide(); + }); + + AddUntilStep("wait for no notifications", () => notifications.UnreadCount.Value, () => Is.EqualTo(0)); + AddStep("restart sequence", () => { logo.FinishTransforms(); diff --git a/osu.Game.Tests/Visual/Menus/TestSceneDisclaimer.cs b/osu.Game.Tests/Visual/Menus/TestSceneDisclaimer.cs index db5891bd4e..45e5a7c270 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneDisclaimer.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneDisclaimer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; @@ -8,7 +10,7 @@ using osu.Game.Screens.Menu; namespace osu.Game.Tests.Visual.Menus { - public class TestSceneDisclaimer : ScreenTestScene + public partial class TestSceneDisclaimer : ScreenTestScene { [BackgroundDependencyLoader] private void load() diff --git a/osu.Game.Tests/Visual/Menus/TestSceneIntroCircles.cs b/osu.Game.Tests/Visual/Menus/TestSceneIntroCircles.cs index 7ad49b5dcd..0c024248ea 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneIntroCircles.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneIntroCircles.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Screens.Menu; namespace osu.Game.Tests.Visual.Menus { [TestFixture] - public class TestSceneIntroCircles : IntroTestScene + public partial class TestSceneIntroCircles : IntroTestScene { protected override bool IntroReliesOnTrack => false; protected override IntroScreen CreateScreen() => new IntroCircles(); diff --git a/osu.Game.Tests/Visual/Menus/TestSceneIntroTriangles.cs b/osu.Game.Tests/Visual/Menus/TestSceneIntroTriangles.cs index abe8936330..23373892d1 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneIntroTriangles.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneIntroTriangles.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Screens.Menu; namespace osu.Game.Tests.Visual.Menus { [TestFixture] - public class TestSceneIntroTriangles : IntroTestScene + public partial class TestSceneIntroTriangles : IntroTestScene { protected override bool IntroReliesOnTrack => true; protected override IntroScreen CreateScreen() => new IntroTriangles(); diff --git a/osu.Game.Tests/Visual/Menus/TestSceneIntroWelcome.cs b/osu.Game.Tests/Visual/Menus/TestSceneIntroWelcome.cs index 11cea25865..2ccf184525 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneIntroWelcome.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneIntroWelcome.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Utils; using osu.Game.Screens.Menu; @@ -8,7 +10,7 @@ using osu.Game.Screens.Menu; namespace osu.Game.Tests.Visual.Menus { [TestFixture] - public class TestSceneIntroWelcome : IntroTestScene + public partial class TestSceneIntroWelcome : IntroTestScene { protected override bool IntroReliesOnTrack => false; protected override IntroScreen CreateScreen() => new IntroWelcome(); diff --git a/osu.Game.Tests/Visual/Menus/TestSceneLoader.cs b/osu.Game.Tests/Visual/Menus/TestSceneLoader.cs index c44363d9ea..aac9614ddb 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneLoader.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneLoader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using System.Threading; using NUnit.Framework; @@ -17,7 +19,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Menus { [TestFixture] - public class TestSceneLoader : ScreenTestScene + public partial class TestSceneLoader : ScreenTestScene { private TestLoader loader; @@ -66,7 +68,7 @@ namespace osu.Game.Tests.Visual.Menus AddUntilStep("not current", () => !loader.IsCurrentScreen()); } - private class TestLoader : Loader + private partial class TestLoader : Loader { public readonly ManualResetEventSlim AllowLoad = new ManualResetEventSlim(); @@ -78,7 +80,7 @@ namespace osu.Game.Tests.Visual.Menus protected override OsuScreen CreateLoadableScreen() => screen = new TestScreen(); protected override ShaderPrecompiler CreateShaderPrecompiler() => new TestShaderPrecompiler(AllowLoad); - private class TestShaderPrecompiler : ShaderPrecompiler + private partial class TestShaderPrecompiler : ShaderPrecompiler { private readonly ManualResetEventSlim allowLoad; @@ -90,7 +92,7 @@ namespace osu.Game.Tests.Visual.Menus protected override bool AllLoaded => allowLoad.IsSet; } - private class TestScreen : OsuScreen + private partial class TestScreen : OsuScreen { public TestScreen() { diff --git a/osu.Game.Tests/Visual/Menus/TestSceneLoginPanel.cs b/osu.Game.Tests/Visual/Menus/TestSceneLoginPanel.cs index 642cc68de5..738220f5ce 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneLoginPanel.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneLoginPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -14,7 +16,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Menus { [TestFixture] - public class TestSceneLoginPanel : OsuManualInputManagerTestScene + public partial class TestSceneLoginPanel : OsuManualInputManagerTestScene { private LoginPanel loginPanel; private int hideCount; diff --git a/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs b/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs index 10a82089b3..f17433244b 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -14,7 +16,7 @@ using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.Menus { - public class TestSceneMusicActionHandling : OsuGameTestScene + public partial class TestSceneMusicActionHandling : OsuGameTestScene { private GlobalActionContainer globalActionContainer => Game.ChildrenOfType().First(); @@ -22,10 +24,11 @@ namespace osu.Game.Tests.Visual.Menus public void TestMusicPlayAction() { AddStep("ensure playing something", () => Game.MusicController.EnsurePlayingSomething()); + AddUntilStep("music playing", () => Game.MusicController.IsPlaying); AddStep("toggle playback", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPlay)); - AddAssert("music paused", () => !Game.MusicController.IsPlaying && Game.MusicController.UserPauseRequested); + AddUntilStep("music paused", () => !Game.MusicController.IsPlaying && Game.MusicController.UserPauseRequested); AddStep("toggle playback", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPlay)); - AddAssert("music resumed", () => Game.MusicController.IsPlaying && !Game.MusicController.UserPauseRequested); + AddUntilStep("music resumed", () => Game.MusicController.IsPlaying && !Game.MusicController.UserPauseRequested); } [Test] diff --git a/osu.Game.Tests/Visual/Menus/TestSceneSideOverlays.cs b/osu.Game.Tests/Visual/Menus/TestSceneSideOverlays.cs index bbab6380ba..e5e092b382 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneSideOverlays.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneSideOverlays.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -11,7 +13,7 @@ using osu.Game.Overlays; namespace osu.Game.Tests.Visual.Menus { - public class TestSceneSideOverlays : OsuGameTestScene + public partial class TestSceneSideOverlays : OsuGameTestScene { [SetUpSteps] public override void SetUpSteps() diff --git a/osu.Game.Tests/Visual/Menus/TestSceneSongTicker.cs b/osu.Game.Tests/Visual/Menus/TestSceneSongTicker.cs index 4b22af38c5..c54c66df7e 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneSongTicker.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneSongTicker.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Overlays; @@ -8,7 +10,7 @@ using osu.Game.Screens.Menu; namespace osu.Game.Tests.Visual.Menus { - public class TestSceneSongTicker : OsuTestScene + public partial class TestSceneSongTicker : OsuTestScene { public TestSceneSongTicker() { diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs index dbc7e54b5e..22c7bb64b2 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs @@ -1,43 +1,76 @@ // 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 JetBrains.Annotations; using Moq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Testing; +using osu.Game.Graphics.Containers; using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; using osu.Game.Overlays.Toolbar; using osu.Game.Rulesets; +using osuTK; +using osuTK.Graphics; using osuTK.Input; namespace osu.Game.Tests.Visual.Menus { [TestFixture] - public class TestSceneToolbar : OsuManualInputManagerTestScene + public partial class TestSceneToolbar : OsuManualInputManagerTestScene { private TestToolbar toolbar; [Resolved] private IRulesetStore rulesets { get; set; } - private readonly Mock notifications = new Mock(); + [Cached] + private readonly NowPlayingOverlay nowPlayingOverlay = new NowPlayingOverlay + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Y = Toolbar.HEIGHT, + }; + + [Cached] + private readonly VolumeOverlay volumeOverlay = new VolumeOverlay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }; + + private readonly Mock notifications = new Mock(); private readonly BindableInt unreadNotificationCount = new BindableInt(); [BackgroundDependencyLoader] private void load() { - Dependencies.CacheAs(notifications.Object); + Dependencies.CacheAs(notifications.Object); notifications.SetupGet(n => n.UnreadCount).Returns(unreadNotificationCount); } [SetUp] public void SetUp() => Schedule(() => { - Child = toolbar = new TestToolbar { State = { Value = Visibility.Visible } }; + Remove(nowPlayingOverlay, false); + Remove(volumeOverlay, false); + + Children = new Drawable[] + { + nowPlayingOverlay, + volumeOverlay, + toolbar = new TestToolbar { State = { Value = Visibility.Visible } }, + }; }); [Test] @@ -81,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) @@ -95,9 +141,115 @@ namespace osu.Game.Tests.Visual.Menus AddAssert("toolbar is visible", () => toolbar.State.Value == Visibility.Visible); } - public class TestToolbar : Toolbar + [Test] + public void TestScrollInput() + { + OsuScrollContainer scroll = null; + + AddStep("add scroll layer", () => Add(scroll = new OsuScrollContainer + { + Depth = 1f, + RelativeSizeAxes = Axes.Both, + Child = new Box + { + RelativeSizeAxes = Axes.X, + Height = DrawHeight * 2, + Colour = ColourInfo.GradientVertical(Color4.Gray, Color4.DarkGray), + } + })); + + AddStep("hover toolbar", () => InputManager.MoveMouseTo(toolbar)); + AddStep("perform scroll", () => InputManager.ScrollVerticalBy(500)); + AddAssert("not scrolled", () => scroll.Current == 0); + } + + [Test] + public void TestVolumeControlViaMusicButtonScroll() + { + AddStep("hover toolbar music button", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + + AddStep("reset volume", () => Audio.Volume.Value = 1); + AddStep("hide volume overlay", () => volumeOverlay.Hide()); + + AddRepeatStep("scroll down", () => InputManager.ScrollVerticalBy(-10), 5); + AddAssert("volume lowered down", () => Audio.Volume.Value < 1); + AddRepeatStep("scroll up", () => InputManager.ScrollVerticalBy(10), 5); + AddAssert("volume raised up", () => Audio.Volume.Value == 1); + + AddStep("move mouse away", () => InputManager.MoveMouseTo(Vector2.Zero)); + AddAssert("button not hovered", () => !this.ChildrenOfType().Single().IsHovered); + + AddStep("set volume to 0.5", () => Audio.Volume.Value = 0.5); + AddStep("hide volume overlay", () => volumeOverlay.Hide()); + + AddRepeatStep("scroll down", () => InputManager.ScrollVerticalBy(-10), 5); + AddAssert("volume not changed", () => Audio.Volume.Value == 0.5); + AddRepeatStep("scroll up", () => InputManager.ScrollVerticalBy(10), 5); + AddAssert("volume not changed", () => Audio.Volume.Value == 0.5); + } + + [Test] + public void TestVolumeControlViaMusicButtonArrowKeys() + { + AddStep("hover toolbar music button", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + + AddStep("reset volume", () => Audio.Volume.Value = 1); + AddStep("hide volume overlay", () => volumeOverlay.Hide()); + + AddRepeatStep("arrow down", () => InputManager.Key(Key.Down), 5); + AddAssert("volume lowered down", () => Audio.Volume.Value < 1); + AddRepeatStep("arrow up", () => InputManager.Key(Key.Up), 5); + AddAssert("volume raised up", () => Audio.Volume.Value == 1); + + AddStep("hide volume overlay", () => volumeOverlay.Hide()); + AddStep("move mouse away", () => InputManager.MoveMouseTo(Vector2.Zero)); + AddAssert("button not hovered", () => !this.ChildrenOfType().Single().IsHovered); + + AddStep("set volume", () => Audio.Volume.Value = 0.5); + AddStep("hide volume overlay", () => volumeOverlay.Hide()); + + AddRepeatStep("arrow down", () => InputManager.Key(Key.Down), 5); + AddAssert("volume not changed", () => Audio.Volume.Value == 0.5); + AddRepeatStep("arrow up", () => InputManager.Key(Key.Up), 5); + AddAssert("volume not changed", () => Audio.Volume.Value == 0.5); + } + + [Test] + public void TestRulesetSelectorOverflow() + { + AddStep("set toolbar width", () => + { + toolbar.RelativeSizeAxes = Axes.None; + toolbar.Width = 400; + }); + AddStep("move mouse over news toggle button", () => + { + var button = toolbar.ChildrenOfType().Single(); + InputManager.MoveMouseTo(button); + }); + AddAssert("no ruleset toggle buttons hovered", () => !toolbar.ChildrenOfType().Any(button => button.IsHovered)); + AddUntilStep("toolbar gradient visible", () => toolbar.ChildrenOfType().Single().Children.All(d => d.Alpha > 0)); + } + + public partial class TestToolbar : Toolbar { public new Bindable OverlayActivationMode => base.OverlayActivationMode as Bindable; } + + // interface mocks break hot reload, mocking this stub implementation instead works around it. + // see: https://github.com/moq/moq4/issues/1252 + [UsedImplicitly] + public class TestNotificationOverlay : INotificationOverlay + { + public virtual void Post(Notification notification) + { + } + + public virtual void Hide() + { + } + + public virtual IBindable UnreadCount => null; + } } } diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs index 87d836687f..f38ad5af15 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbarClock.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -16,7 +18,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Menus { [TestFixture] - public class TestSceneToolbarClock : OsuManualInputManagerTestScene + public partial class TestSceneToolbarClock : OsuManualInputManagerTestScene { private Bindable clockDisplayMode; diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs new file mode 100644 index 0000000000..2bdfc8959d --- /dev/null +++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs @@ -0,0 +1,87 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Online.API; +using osu.Game.Overlays.Toolbar; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.Menus +{ + [TestFixture] + public partial class TestSceneToolbarUserButton : OsuManualInputManagerTestScene + { + public TestSceneToolbarUserButton() + { + Container mainContainer; + + Children = new Drawable[] + { + mainContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = Toolbar.HEIGHT, + Children = new Drawable[] + { + new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new Box + { + Colour = Color4.DarkRed, + RelativeSizeAxes = Axes.Y, + Width = 2, + }, + new ToolbarUserButton(), + new Box + { + Colour = Color4.DarkRed, + RelativeSizeAxes = Axes.Y, + Width = 2, + }, + } + }, + } + }, + }; + + AddSliderStep("scale", 0.5, 4, 1, scale => mainContainer.Scale = new Vector2((float)scale)); + } + + [Test] + public void TestLoginLogout() + { + AddStep("Log out", () => ((DummyAPIAccess)API).Logout()); + AddStep("Log in", () => ((DummyAPIAccess)API).Login("wang", "jang")); + } + + [Test] + public void TestStates() + { + AddStep("Log in", () => ((DummyAPIAccess)API).Login("wang", "jang")); + + foreach (var state in Enum.GetValues()) + { + AddStep($"Change state to {state}", () => ((DummyAPIAccess)API).SetState(state)); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Mods/TestSceneModFailCondition.cs b/osu.Game.Tests/Visual/Mods/TestSceneModFailCondition.cs index af874cec91..49256c7a01 100644 --- a/osu.Game.Tests/Visual/Mods/TestSceneModFailCondition.cs +++ b/osu.Game.Tests/Visual/Mods/TestSceneModFailCondition.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Graphics.Containers; @@ -12,7 +14,7 @@ using osu.Game.Screens.Play; namespace osu.Game.Tests.Visual.Mods { - public class TestSceneModFailCondition : ModTestScene + public partial class TestSceneModFailCondition : ModTestScene { private bool restartRequested; @@ -21,7 +23,7 @@ namespace osu.Game.Tests.Visual.Mods protected override TestPlayer CreateModPlayer(Ruleset ruleset) { var player = base.CreateModPlayer(ruleset); - player.RestartRequested = () => restartRequested = true; + player.RestartRequested = _ => restartRequested = true; return player; } diff --git a/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs b/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs index a738debecc..649c662e41 100644 --- a/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs +++ b/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Collections.Specialized; @@ -24,7 +26,7 @@ using osu.Game.Screens.Play.HUD; namespace osu.Game.Tests.Visual.Multiplayer { - public abstract class MultiplayerGameplayLeaderboardTestScene : OsuTestScene + public abstract partial class MultiplayerGameplayLeaderboardTestScene : OsuTestScene { protected const int TOTAL_USERS = 16; @@ -55,7 +57,7 @@ namespace osu.Game.Tests.Visual.Multiplayer // To emulate `MultiplayerClient.CurrentMatchPlayingUserIds` we need a bindable list of *only IDs*. // This tracks the list of users 1:1. - MultiplayerUsers.BindCollectionChanged((c, e) => + MultiplayerUsers.BindCollectionChanged((_, e) => { switch (e.Action) { @@ -115,11 +117,9 @@ namespace osu.Game.Tests.Visual.Multiplayer BeatmapID = 0, RulesetID = 0, Mods = user.Mods, - MaximumScoringValues = new ScoringValues + MaximumStatistics = new Dictionary { - BaseScore = 10000, - MaxCombo = 1000, - CountBasicHitObjects = 1000 + { HitResult.Perfect, 100 } } }; } diff --git a/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs b/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs index bafc579134..0f1ba9ba75 100644 --- a/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs +++ b/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -14,6 +16,7 @@ using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; +using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Lounge; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; @@ -22,7 +25,7 @@ using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.Multiplayer { - public abstract class QueueModeTestScene : ScreenTestScene + public abstract partial class QueueModeTestScene : ScreenTestScene { protected abstract QueueMode Mode { get; } @@ -33,7 +36,6 @@ namespace osu.Game.Tests.Visual.Multiplayer protected IScreen CurrentSubScreen => multiplayerComponents.MultiplayerScreen.CurrentSubScreen; private BeatmapManager beatmaps; - private RulesetStore rulesets; private BeatmapSetInfo importedSet; private TestMultiplayerComponents multiplayerComponents; @@ -43,8 +45,8 @@ namespace osu.Game.Tests.Visual.Multiplayer [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); } @@ -84,12 +86,13 @@ namespace osu.Game.Tests.Visual.Multiplayer ClickButtonWhenEnabled(); AddUntilStep("wait for join", () => MultiplayerClient.RoomJoined); + AddUntilStep("wait for ongoing operation to complete", () => !(CurrentScreen as OnlinePlayScreen).ChildrenOfType().Single().InProgress.Value); } [Test] public void TestCreatedWithCorrectMode() { - AddAssert("room created with correct mode", () => MultiplayerClient.APIRoom?.QueueMode.Value == Mode); + AddUntilStep("room created with correct mode", () => MultiplayerClient.ClientAPIRoom?.QueueMode.Value == Mode); } protected void RunGameplay() diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneAllPlayersQueueMode.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneAllPlayersQueueMode.cs index 0785315b26..869b8bb328 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneAllPlayersQueueMode.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneAllPlayersQueueMode.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 enable - using System; using System.Collections.Generic; using System.Linq; @@ -24,26 +22,14 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneAllPlayersQueueMode : QueueModeTestScene + public partial class TestSceneAllPlayersQueueMode : QueueModeTestScene { protected override QueueMode Mode => QueueMode.AllPlayers; [Test] public void TestFirstItemSelectedByDefault() { - AddAssert("first item selected", () => MultiplayerClient.Room?.Settings.PlaylistItemId == MultiplayerClient.APIRoom?.Playlist[0].ID); - } - - [Test] - public void TestItemAddedToTheEndOfQueue() - { - addItem(() => OtherBeatmap); - AddAssert("playlist has 2 items", () => MultiplayerClient.APIRoom?.Playlist.Count == 2); - - addItem(() => InitialBeatmap); - AddAssert("playlist has 3 items", () => MultiplayerClient.APIRoom?.Playlist.Count == 3); - - AddAssert("first item still selected", () => MultiplayerClient.Room?.Settings.PlaylistItemId == MultiplayerClient.APIRoom?.Playlist[0].ID); + AddUntilStep("first item selected", () => MultiplayerClient.ClientRoom?.Settings.PlaylistItemId == MultiplayerClient.ClientAPIRoom?.Playlist[0].ID); } [Test] @@ -51,9 +37,21 @@ namespace osu.Game.Tests.Visual.Multiplayer { RunGameplay(); - AddAssert("playlist has only one item", () => MultiplayerClient.APIRoom?.Playlist.Count == 1); - AddAssert("playlist item is expired", () => MultiplayerClient.APIRoom?.Playlist[0].Expired == true); - AddAssert("last item selected", () => MultiplayerClient.Room?.Settings.PlaylistItemId == MultiplayerClient.APIRoom?.Playlist[0].ID); + AddUntilStep("playlist has only one item", () => MultiplayerClient.ClientAPIRoom?.Playlist.Count == 1); + AddUntilStep("playlist item is expired", () => MultiplayerClient.ClientAPIRoom?.Playlist[0].Expired == true); + AddUntilStep("last item selected", () => MultiplayerClient.ClientRoom?.Settings.PlaylistItemId == MultiplayerClient.ClientAPIRoom?.Playlist[0].ID); + } + + [Test] + public void TestItemAddedToTheEndOfQueue() + { + addItem(() => OtherBeatmap); + AddUntilStep("playlist has 2 items", () => MultiplayerClient.ClientAPIRoom?.Playlist.Count == 2); + + addItem(() => InitialBeatmap); + AddUntilStep("playlist has 3 items", () => MultiplayerClient.ClientAPIRoom?.Playlist.Count == 3); + + AddUntilStep("first item still selected", () => MultiplayerClient.ClientRoom?.Settings.PlaylistItemId == MultiplayerClient.ClientAPIRoom?.Playlist[0].ID); } [Test] @@ -64,13 +62,13 @@ namespace osu.Game.Tests.Visual.Multiplayer RunGameplay(); - AddAssert("first item expired", () => MultiplayerClient.APIRoom?.Playlist[0].Expired == true); - AddAssert("next item selected", () => MultiplayerClient.Room?.Settings.PlaylistItemId == MultiplayerClient.APIRoom?.Playlist[1].ID); + AddUntilStep("first item expired", () => MultiplayerClient.ClientAPIRoom?.Playlist[0].Expired == true); + AddUntilStep("next item selected", () => MultiplayerClient.ClientRoom?.Settings.PlaylistItemId == MultiplayerClient.ClientAPIRoom?.Playlist[1].ID); RunGameplay(); - AddAssert("second item expired", () => MultiplayerClient.APIRoom?.Playlist[1].Expired == true); - AddAssert("next item selected", () => MultiplayerClient.Room?.Settings.PlaylistItemId == MultiplayerClient.APIRoom?.Playlist[2].ID); + AddUntilStep("second item expired", () => MultiplayerClient.ClientAPIRoom?.Playlist[1].Expired == true); + AddUntilStep("next item selected", () => MultiplayerClient.ClientRoom?.Settings.PlaylistItemId == MultiplayerClient.ClientAPIRoom?.Playlist[2].ID); } [Test] @@ -83,9 +81,9 @@ namespace osu.Game.Tests.Visual.Multiplayer RunGameplay(); AddStep("change queue mode", () => MultiplayerClient.ChangeSettings(queueMode: QueueMode.HostOnly)); - AddAssert("playlist has 3 items", () => MultiplayerClient.APIRoom?.Playlist.Count == 3); - AddAssert("item 2 is not expired", () => MultiplayerClient.APIRoom?.Playlist[1].Expired == false); - AddAssert("current item is the other beatmap", () => MultiplayerClient.Room?.Settings.PlaylistItemId == 2); + AddUntilStep("playlist has 3 items", () => MultiplayerClient.ClientAPIRoom?.Playlist.Count == 3); + AddUntilStep("item 2 is not expired", () => MultiplayerClient.ClientAPIRoom?.Playlist[1].Expired == false); + AddUntilStep("current item is the other beatmap", () => MultiplayerClient.ClientRoom?.Settings.PlaylistItemId == 2); } [Test] diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneCreateMultiplayerMatchButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneCreateMultiplayerMatchButton.cs index 0674fc7a39..11b0f8b91c 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneCreateMultiplayerMatchButton.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneCreateMultiplayerMatchButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Framework.Graphics; @@ -8,7 +10,7 @@ using osu.Game.Screens.OnlinePlay.Multiplayer; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneCreateMultiplayerMatchButton : MultiplayerTestScene + public partial class TestSceneCreateMultiplayerMatchButton : MultiplayerTestScene { private CreateMultiplayerMatchButton button; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableLoungeRoom.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableLoungeRoom.cs index ea895a23d2..4de911b6b6 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableLoungeRoom.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableLoungeRoom.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using System.Threading; @@ -19,7 +21,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneDrawableLoungeRoom : OsuManualInputManagerTestScene + public partial class TestSceneDrawableLoungeRoom : OsuManualInputManagerTestScene { private readonly Room room = new Room { @@ -40,11 +42,11 @@ namespace osu.Game.Tests.Visual.Multiplayer var mockLounge = new Mock(); mockLounge .Setup(l => l.Join(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>())) - .Callback, Action>((a, b, c, d) => + .Callback, Action>((_, _, _, d) => { Task.Run(() => { - allowResponseCallback.Wait(); + allowResponseCallback.Wait(10000); allowResponseCallback.Reset(); Schedule(() => d?.Invoke("Incorrect password")); }); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs index 3172a68b81..4ffccdbf0e 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -25,7 +27,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneDrawableRoom : OsuTestScene + public partial class TestSceneDrawableRoom : OsuTestScene { [Cached] protected readonly OverlayColourProvider ColourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs index 60f47a01a9..98abc93994 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomParticipantsList.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -13,33 +15,37 @@ using osu.Game.Users.Drawables; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneDrawableRoomParticipantsList : OnlinePlayTestScene + public partial class TestSceneDrawableRoomParticipantsList : OnlinePlayTestScene { private DrawableRoomParticipantsList list; - [SetUp] - public new void Setup() => Schedule(() => + public override void SetUpSteps() { - SelectedRoom.Value = new Room - { - Name = { Value = "test room" }, - Host = - { - Value = new APIUser - { - Id = 2, - Username = "peppy", - } - } - }; + base.SetUpSteps(); - Child = list = new DrawableRoomParticipantsList + AddStep("create list", () => { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - NumberOfCircles = 4 - }; - }); + SelectedRoom.Value = new Room + { + Name = { Value = "test room" }, + Host = + { + Value = new APIUser + { + Id = 2, + Username = "peppy", + } + } + }; + + Child = list = new DrawableRoomParticipantsList + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + NumberOfCircles = 4 + }; + }); + } [Test] public void TestCircleCountNearLimit() diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs index e2b4b2870f..312135402f 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; @@ -16,6 +18,7 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Database; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; using osu.Game.Models; using osu.Game.Online.API; using osu.Game.Online.Rooms; @@ -30,18 +33,17 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneDrawableRoomPlaylist : MultiplayerTestScene + public partial class TestSceneDrawableRoomPlaylist : MultiplayerTestScene { private TestPlaylist playlist; private BeatmapManager manager; - private RulesetStore rulesets; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); } @@ -193,12 +195,15 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestDownloadButtonHiddenWhenBeatmapExists() { - var beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo; Live imported = null; - Debug.Assert(beatmap.BeatmapSet != null); + AddStep("import beatmap", () => + { + var beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo; - AddStep("import beatmap", () => imported = manager.Import(beatmap.BeatmapSet)); + Debug.Assert(beatmap.BeatmapSet != null); + imported = manager.Import(beatmap.BeatmapSet); + }); createPlaylistWithBeatmaps(() => imported.PerformRead(s => s.Beatmaps.Detach())); @@ -243,40 +248,35 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestExpiredItems() { - AddStep("create playlist", () => + createPlaylist(p => { - Child = playlist = new TestPlaylist + p.Items.Clear(); + p.Items.AddRange(new[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(500, 300), - Items = + new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) { - new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) + ID = 0, + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + Expired = true, + RequiredMods = new[] { - ID = 0, - RulesetID = new OsuRuleset().RulesetInfo.OnlineID, - Expired = true, - RequiredMods = new[] - { - new APIMod(new OsuModHardRock()), - new APIMod(new OsuModDoubleTime()), - new APIMod(new OsuModAutoplay()) - } - }, - new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) + new APIMod(new OsuModHardRock()), + new APIMod(new OsuModDoubleTime()), + new APIMod(new OsuModAutoplay()) + } + }, + new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) + { + ID = 1, + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + RequiredMods = new[] { - ID = 1, - RulesetID = new OsuRuleset().RulesetInfo.OnlineID, - RequiredMods = new[] - { - new APIMod(new OsuModHardRock()), - new APIMod(new OsuModDoubleTime()), - new APIMod(new OsuModAutoplay()) - } + new APIMod(new OsuModHardRock()), + new APIMod(new OsuModDoubleTime()), + new APIMod(new OsuModAutoplay()) } } - }; + }); }); AddUntilStep("wait for items to load", () => playlist.ItemMap.Values.All(i => i.IsLoaded)); @@ -319,19 +319,44 @@ namespace osu.Game.Tests.Visual.Multiplayer => AddAssert($"delete button {index} {(visible ? "is" : "is not")} visible", () => (playlist.ChildrenOfType().ElementAt(2 + index * 2).Alpha > 0) == visible); + private void createPlaylistWithBeatmaps(Func> beatmaps) => createPlaylist(p => + { + int index = 0; + + p.Items.Clear(); + + foreach (var b in beatmaps()) + { + p.Items.Add(new PlaylistItem(b) + { + ID = index++, + OwnerID = 2, + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + RequiredMods = new[] + { + new APIMod(new OsuModHardRock()), + new APIMod(new OsuModDoubleTime()), + new APIMod(new OsuModAutoplay()) + } + }); + } + }); + private void createPlaylist(Action setupPlaylist = null) { AddStep("create playlist", () => { - Child = playlist = new TestPlaylist + Child = new OsuContextMenuContainer { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(500, 300) + RelativeSizeAxes = Axes.Both, + Child = playlist = new TestPlaylist + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(500, 300) + } }; - setupPlaylist?.Invoke(playlist); - for (int i = 0; i < 20; i++) { playlist.Items.Add(new PlaylistItem(i % 2 == 1 @@ -358,45 +383,14 @@ namespace osu.Game.Tests.Visual.Multiplayer } }); } + + setupPlaylist?.Invoke(playlist); }); AddUntilStep("wait for items to load", () => playlist.ItemMap.Values.All(i => i.IsLoaded)); } - private void createPlaylistWithBeatmaps(Func> beatmaps) - { - AddStep("create playlist", () => - { - Child = playlist = new TestPlaylist - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(500, 300) - }; - - int index = 0; - - foreach (var b in beatmaps()) - { - playlist.Items.Add(new PlaylistItem(b) - { - ID = index++, - OwnerID = 2, - RulesetID = new OsuRuleset().RulesetInfo.OnlineID, - RequiredMods = new[] - { - new APIMod(new OsuModHardRock()), - new APIMod(new OsuModDoubleTime()), - new APIMod(new OsuModAutoplay()) - } - }); - } - }); - - AddUntilStep("wait for items to load", () => playlist.ItemMap.Values.All(i => i.IsLoaded)); - } - - private class TestPlaylist : DrawableRoomPlaylist + private partial class TestPlaylist : DrawableRoomPlaylist { public new IReadOnlyDictionary> ItemMap => base.ItemMap; } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs index a8471edbf8..45f671618e 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -17,7 +19,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneFreeModSelectOverlay : MultiplayerTestScene + public partial class TestSceneFreeModSelectOverlay : MultiplayerTestScene { private FreeModSelectOverlay freeModSelectOverlay; private readonly Bindable>> availableMods = new Bindable>>(); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs index c3487751b9..979cb4424e 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using Moq; using NUnit.Framework; @@ -16,7 +18,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneGameplayChatDisplay : OsuManualInputManagerTestScene + public partial class TestSceneGameplayChatDisplay : OsuManualInputManagerTestScene { private GameplayChatDisplay chatDisplay; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.cs index c3ec7a5369..78baa4a39b 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -15,30 +17,14 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneHostOnlyQueueMode : QueueModeTestScene + public partial class TestSceneHostOnlyQueueMode : QueueModeTestScene { protected override QueueMode Mode => QueueMode.HostOnly; [Test] public void TestFirstItemSelectedByDefault() { - AddAssert("first item selected", () => MultiplayerClient.Room?.Settings.PlaylistItemId == MultiplayerClient.APIRoom?.Playlist[0].ID); - } - - [Test] - public void TestItemStillSelectedAfterChangeToSameBeatmap() - { - selectNewItem(() => InitialBeatmap); - - AddAssert("playlist item still selected", () => MultiplayerClient.Room?.Settings.PlaylistItemId == MultiplayerClient.APIRoom?.Playlist[0].ID); - } - - [Test] - public void TestItemStillSelectedAfterChangeToOtherBeatmap() - { - selectNewItem(() => OtherBeatmap); - - AddAssert("playlist item still selected", () => MultiplayerClient.Room?.Settings.PlaylistItemId == MultiplayerClient.APIRoom?.Playlist[0].ID); + AddUntilStep("first item selected", () => MultiplayerClient.ClientRoom?.Settings.PlaylistItemId == MultiplayerClient.ClientAPIRoom?.Playlist[0].ID); } [Test] @@ -46,24 +32,10 @@ namespace osu.Game.Tests.Visual.Multiplayer { RunGameplay(); - AddAssert("playlist contains two items", () => MultiplayerClient.APIRoom?.Playlist.Count == 2); - AddAssert("first playlist item expired", () => MultiplayerClient.APIRoom?.Playlist[0].Expired == true); - AddAssert("second playlist item not expired", () => MultiplayerClient.APIRoom?.Playlist[1].Expired == false); - AddAssert("second playlist item selected", () => MultiplayerClient.Room?.Settings.PlaylistItemId == MultiplayerClient.APIRoom?.Playlist[1].ID); - } - - [Test] - public void TestOnlyLastItemChangedAfterGameplayFinished() - { - RunGameplay(); - - IBeatmapInfo firstBeatmap = null; - AddStep("get first playlist item beatmap", () => firstBeatmap = MultiplayerClient.APIRoom?.Playlist[0].Beatmap); - - selectNewItem(() => OtherBeatmap); - - AddAssert("first playlist item hasn't changed", () => MultiplayerClient.APIRoom?.Playlist[0].Beatmap == firstBeatmap); - AddAssert("second playlist item changed", () => MultiplayerClient.APIRoom?.Playlist[1].Beatmap != firstBeatmap); + AddUntilStep("playlist contains two items", () => MultiplayerClient.ClientAPIRoom?.Playlist.Count == 2); + AddUntilStep("first playlist item expired", () => MultiplayerClient.ClientAPIRoom?.Playlist[0].Expired == true); + AddUntilStep("second playlist item not expired", () => MultiplayerClient.ClientAPIRoom?.Playlist[1].Expired == false); + AddUntilStep("second playlist item selected", () => MultiplayerClient.ClientRoom?.Settings.PlaylistItemId == MultiplayerClient.ClientAPIRoom?.Playlist[1].ID); } [Test] @@ -74,7 +46,37 @@ namespace osu.Game.Tests.Visual.Multiplayer QueueMode = QueueMode.AllPlayers }).WaitSafely()); - AddUntilStep("api room updated", () => MultiplayerClient.APIRoom?.QueueMode.Value == QueueMode.AllPlayers); + AddUntilStep("api room updated", () => MultiplayerClient.ClientAPIRoom?.QueueMode.Value == QueueMode.AllPlayers); + } + + [Test] + public void TestItemStillSelectedAfterChangeToSameBeatmap() + { + selectNewItem(() => InitialBeatmap); + + AddUntilStep("playlist item still selected", () => MultiplayerClient.ClientRoom?.Settings.PlaylistItemId == MultiplayerClient.ClientAPIRoom?.Playlist[0].ID); + } + + [Test] + public void TestItemStillSelectedAfterChangeToOtherBeatmap() + { + selectNewItem(() => OtherBeatmap); + + AddUntilStep("playlist item still selected", () => MultiplayerClient.ClientRoom?.Settings.PlaylistItemId == MultiplayerClient.ClientAPIRoom?.Playlist[0].ID); + } + + [Test] + public void TestOnlyLastItemChangedAfterGameplayFinished() + { + RunGameplay(); + + IBeatmapInfo firstBeatmap = null; + AddStep("get first playlist item beatmap", () => firstBeatmap = MultiplayerClient.ServerAPIRoom?.Playlist[0].Beatmap); + + selectNewItem(() => OtherBeatmap); + + AddUntilStep("first playlist item hasn't changed", () => MultiplayerClient.ServerAPIRoom?.Playlist[0].Beatmap == firstBeatmap); + AddUntilStep("second playlist item changed", () => MultiplayerClient.ClientAPIRoom?.Playlist[1].Beatmap != firstBeatmap); } [Test] @@ -82,7 +84,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { addItem(() => OtherBeatmap); - AddAssert("playlist contains two items", () => MultiplayerClient.APIRoom?.Playlist.Count == 2); + AddUntilStep("playlist contains two items", () => MultiplayerClient.ClientAPIRoom?.Playlist.Count == 2); } private void selectNewItem(Func beatmap) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs index 93cd281bc5..d99d764449 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -17,29 +19,33 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneLoungeRoomsContainer : OnlinePlayTestScene + public partial class TestSceneLoungeRoomsContainer : OnlinePlayTestScene { protected new TestRoomManager RoomManager => (TestRoomManager)base.RoomManager; private RoomsContainer container; - [SetUp] - public new void Setup() => Schedule(() => + public override void SetUpSteps() { - Child = new PopoverContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 0.5f, + base.SetUpSteps(); - Child = container = new RoomsContainer + AddStep("create container", () => + { + Child = new PopoverContainer { - SelectedRoom = { BindTarget = SelectedRoom } - } - }; - }); + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f, + + Child = container = new RoomsContainer + { + SelectedRoom = { BindTarget = SelectedRoom } + } + }; + }); + } [Test] public void TestBasicListChanges() @@ -155,6 +161,28 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("3 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 3); } + [Test] + public void TestAccessTypeFiltering() + { + AddStep("add rooms", () => + { + RoomManager.AddRooms(1, withPassword: true); + RoomManager.AddRooms(1, withPassword: false); + }); + + AddStep("apply default filter", () => container.Filter.SetDefault()); + + AddUntilStep("both rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 2); + + AddStep("filter public rooms", () => container.Filter.Value = new FilterCriteria { Permissions = RoomPermissionsFilter.Public }); + + AddUntilStep("private room hidden", () => container.Rooms.All(r => !r.Room.HasPassword.Value)); + + AddStep("filter private rooms", () => container.Filter.Value = new FilterCriteria { Permissions = RoomPermissionsFilter.Private }); + + AddUntilStep("public room hidden", () => container.Rooms.All(r => r.Room.HasPassword.Value)); + } + [Test] public void TestPasswordProtectedRooms() { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs index 6f43511e8a..63a0ada3dc 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs @@ -1,7 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using NUnit.Framework; +#nullable disable + using osu.Framework.Graphics; using osu.Game.Online.API; using osu.Game.Online.Rooms; @@ -14,21 +15,25 @@ using osuTK; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMatchBeatmapDetailArea : OnlinePlayTestScene + public partial class TestSceneMatchBeatmapDetailArea : OnlinePlayTestScene { - [SetUp] - public new void Setup() => Schedule(() => + public override void SetUpSteps() { - SelectedRoom.Value = new Room(); + base.SetUpSteps(); - Child = new MatchBeatmapDetailArea + AddStep("create area", () => { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(500), - CreateNewItem = createNewItem - }; - }); + SelectedRoom.Value = new Room(); + + Child = new MatchBeatmapDetailArea + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(500), + CreateNewItem = createNewItem + }; + }); + } private void createNewItem() { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs index 955710bd50..defb3006cc 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.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.Collections.Generic; -using NUnit.Framework; -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; @@ -15,61 +15,64 @@ using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMatchLeaderboard : OnlinePlayTestScene + public partial class TestSceneMatchLeaderboard : OnlinePlayTestScene { - [BackgroundDependencyLoader] - private void load() + public override void SetUpSteps() { - ((DummyAPIAccess)API).HandleRequest = r => + base.SetUpSteps(); + + AddStep("setup API", () => { - switch (r) + ((DummyAPIAccess)API).HandleRequest = r => { - case GetRoomLeaderboardRequest leaderboardRequest: - leaderboardRequest.TriggerSuccess(new APILeaderboard - { - Leaderboard = new List + switch (r) + { + case GetRoomLeaderboardRequest leaderboardRequest: + leaderboardRequest.TriggerSuccess(new APILeaderboard { - new APIUserScoreAggregate + Leaderboard = new List { - UserID = 2, - User = new APIUser { Id = 2, Username = "peppy" }, - TotalScore = 995533, - RoomID = 3, - CompletedBeatmaps = 1, - TotalAttempts = 6, - Accuracy = 0.9851 - }, - new APIUserScoreAggregate - { - UserID = 1040328, - User = new APIUser { Id = 1040328, Username = "smoogipoo" }, - TotalScore = 981100, - RoomID = 3, - CompletedBeatmaps = 1, - TotalAttempts = 9, - Accuracy = 0.937 + new APIUserScoreAggregate + { + UserID = 2, + User = new APIUser { Id = 2, Username = "peppy" }, + TotalScore = 995533, + RoomID = 3, + CompletedBeatmaps = 1, + TotalAttempts = 6, + Accuracy = 0.9851 + }, + new APIUserScoreAggregate + { + UserID = 1040328, + User = new APIUser { Id = 1040328, Username = "smoogipoo" }, + TotalScore = 981100, + RoomID = 3, + CompletedBeatmaps = 1, + TotalAttempts = 9, + Accuracy = 0.937 + } } - } - }); - return true; - } + }); + return true; + } - return false; - }; - } + return false; + }; + }); - [SetUp] - public new void Setup() => Schedule(() => - { - SelectedRoom.Value = new Room { RoomID = { Value = 3 } }; - - Child = new MatchLeaderboard + AddStep("create leaderboard", () => { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Size = new Vector2(550f, 450f), - Scope = MatchLeaderboardScope.Overall, - }; - }); + SelectedRoom.Value = new Room { RoomID = { Value = 3 } }; + + Child = new MatchLeaderboard + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Size = new Vector2(550f, 450f), + Scope = MatchLeaderboardScope.Overall, + }; + }); + } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs index ff6c02c4e5..3efc7fbd30 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using Moq; @@ -26,7 +28,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMatchStartControl : OsuManualInputManagerTestScene + public partial class TestSceneMatchStartControl : OsuManualInputManagerTestScene { private readonly Mock multiplayerClient = new Mock(); private readonly Mock availabilityTracker = new Mock(); @@ -88,9 +90,8 @@ namespace osu.Game.Tests.Visual.Multiplayer setRoomCountdown(countdownStart.Duration); break; - case StopCountdownRequest _: - multiplayerRoom.Countdown = null; - raiseRoomUpdated(); + case StopCountdownRequest: + clearRoomCountdown(); break; } }); @@ -242,14 +243,14 @@ namespace osu.Game.Tests.Visual.Multiplayer }); AddStep("start countdown", () => multiplayerClient.Object.SendMatchRequest(new StartMatchCountdownRequest { Duration = TimeSpan.FromMinutes(1) }).WaitSafely()); - AddUntilStep("countdown started", () => multiplayerRoom.Countdown != null); + AddUntilStep("countdown started", () => multiplayerRoom.ActiveCountdowns.Any()); AddStep("transfer host to local user", () => transferHost(localUser)); AddUntilStep("local user is host", () => multiplayerRoom.Host?.Equals(multiplayerClient.Object.LocalUser) == true); ClickButtonWhenEnabled(); checkLocalUserState(MultiplayerUserState.Ready); - AddAssert("countdown still active", () => multiplayerRoom.Countdown != null); + AddAssert("countdown still active", () => multiplayerRoom.ActiveCountdowns.Any()); } [Test] @@ -390,7 +391,13 @@ namespace osu.Game.Tests.Visual.Multiplayer private void setRoomCountdown(TimeSpan duration) { - multiplayerRoom.Countdown = new MatchStartCountdown { TimeRemaining = duration }; + multiplayerRoom.ActiveCountdowns.Add(new MatchStartCountdown { TimeRemaining = duration }); + raiseRoomUpdated(); + } + + private void clearRoomCountdown() + { + multiplayerRoom.ActiveCountdowns.Clear(); raiseRoomUpdated(); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiHeader.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiHeader.cs index 2244dcfc56..3d85a47ca9 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiHeader.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiHeader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Screens; @@ -10,7 +12,7 @@ using osu.Game.Screens.OnlinePlay; namespace osu.Game.Tests.Visual.Multiplayer { [TestFixture] - public class TestSceneMultiHeader : OsuTestScene + public partial class TestSceneMultiHeader : OsuTestScene { public TestSceneMultiHeader() { @@ -29,7 +31,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("push multi screen", () => screenStack.CurrentScreen.Push(new TestOnlinePlaySubScreen(++index))); } - private class TestOnlinePlaySubScreen : OsuScreen, IOnlinePlaySubScreen + private partial class TestOnlinePlaySubScreen : OsuScreen, IOnlinePlaySubScreen { private readonly int index; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs index 60215dc8b3..049c02ffde 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -14,14 +16,16 @@ using osu.Game.Screens.Play.HUD; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMultiSpectatorLeaderboard : MultiplayerTestScene + public partial class TestSceneMultiSpectatorLeaderboard : MultiplayerTestScene { private Dictionary clocks; private MultiSpectatorLeaderboard leaderboard; [SetUpSteps] - public new void SetUpSteps() + public override void SetUpSteps() { + base.SetUpSteps(); + AddStep("reset", () => { leaderboard?.RemoveAndDisposeImmediately(); @@ -116,6 +120,6 @@ namespace osu.Game.Tests.Visual.Multiplayer => AddStep($"set user {userId} time {time}", () => clocks[userId].CurrentTime = time); private void assertCombo(int userId, int expectedCombo) - => AddUntilStep($"player {userId} has {expectedCombo} combo", () => this.ChildrenOfType().Single(s => s.User?.Id == userId).Combo.Value == expectedCombo); + => AddUntilStep($"player {userId} has {expectedCombo} combo", () => this.ChildrenOfType().Single(s => s.User?.OnlineID == userId).Combo.Value == expectedCombo); } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs index c048722804..e09496b6e9 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs @@ -13,9 +13,11 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.UI; using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; using osu.Game.Screens.Play; @@ -28,23 +30,24 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMultiSpectatorScreen : MultiplayerTestScene + public partial class TestSceneMultiSpectatorScreen : MultiplayerTestScene { [Resolved] - private OsuGameBase game { get; set; } + private OsuGameBase game { get; set; } = null!; [Resolved] - private OsuConfigManager config { get; set; } + private OsuConfigManager config { get; set; } = null!; [Resolved] - private BeatmapManager beatmapManager { get; set; } + private BeatmapManager beatmapManager { get; set; } = null!; - private MultiSpectatorScreen spectatorScreen; + private MultiSpectatorScreen spectatorScreen = null!; private readonly List playingUsers = new List(); - private BeatmapSetInfo importedSet; - private BeatmapInfo importedBeatmap; + private BeatmapSetInfo importedSet = null!; + private BeatmapInfo importedBeatmap = null!; + private int importedBeatmapId; [BackgroundDependencyLoader] @@ -55,8 +58,12 @@ namespace osu.Game.Tests.Visual.Multiplayer importedBeatmapId = importedBeatmap.OnlineID; } - [SetUp] - public new void Setup() => Schedule(() => playingUsers.Clear()); + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("clear playing users", () => playingUsers.Clear()); + } [Test] public void TestDelayedStart() @@ -114,7 +121,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("all interactive elements removed", () => this.ChildrenOfType().All(p => !p.ChildrenOfType().Any() && !p.ChildrenOfType().Any() && - p.ChildrenOfType().SingleOrDefault()?.ShowHandle == false)); + p.ChildrenOfType().SingleOrDefault()?.Interactive == false)); AddStep("restore config hud visibility", () => config.SetValue(OsuSetting.HUDVisibilityMode, originalConfigValue)); } @@ -160,11 +167,11 @@ namespace osu.Game.Tests.Visual.Multiplayer sendFrames(PLAYER_1_ID, 40); sendFrames(PLAYER_2_ID, 20); - checkPaused(PLAYER_2_ID, true); - checkPausedInstant(PLAYER_1_ID, false); + waitUntilPaused(PLAYER_2_ID); + checkRunningInstant(PLAYER_1_ID); AddAssert("master clock still running", () => this.ChildrenOfType().Single().IsRunning); - checkPaused(PLAYER_1_ID, true); + waitUntilPaused(PLAYER_1_ID); AddUntilStep("master clock paused", () => !this.ChildrenOfType().Single().IsRunning); } @@ -176,13 +183,13 @@ namespace osu.Game.Tests.Visual.Multiplayer // Send frames for one player only, both should remain paused. sendFrames(PLAYER_1_ID, 20); - checkPausedInstant(PLAYER_1_ID, true); - checkPausedInstant(PLAYER_2_ID, true); + checkPausedInstant(PLAYER_1_ID); + checkPausedInstant(PLAYER_2_ID); // Send frames for the other player, both should now start playing. sendFrames(PLAYER_2_ID, 20); - checkPausedInstant(PLAYER_1_ID, false); - checkPausedInstant(PLAYER_2_ID, false); + checkRunningInstant(PLAYER_1_ID); + checkRunningInstant(PLAYER_2_ID); } [Test] @@ -193,15 +200,15 @@ namespace osu.Game.Tests.Visual.Multiplayer // Send frames for one player only, both should remain paused. sendFrames(PLAYER_1_ID, 1000); - checkPausedInstant(PLAYER_1_ID, true); - checkPausedInstant(PLAYER_2_ID, true); + checkPausedInstant(PLAYER_1_ID); + checkPausedInstant(PLAYER_2_ID); // Wait for the start delay seconds... - AddWaitStep("wait maximum start delay seconds", (int)(CatchUpSyncManager.MAXIMUM_START_DELAY / TimePerAction)); + AddWaitStep("wait maximum start delay seconds", (int)(SpectatorSyncManager.MAXIMUM_START_DELAY / TimePerAction)); // Player 1 should start playing by itself, player 2 should remain paused. - checkPausedInstant(PLAYER_1_ID, false); - checkPausedInstant(PLAYER_2_ID, true); + checkRunningInstant(PLAYER_1_ID); + checkPausedInstant(PLAYER_2_ID); } [Test] @@ -213,26 +220,26 @@ namespace osu.Game.Tests.Visual.Multiplayer // Send initial frames for both players. A few more for player 1. sendFrames(PLAYER_1_ID, 20); sendFrames(PLAYER_2_ID); - checkPausedInstant(PLAYER_1_ID, false); - checkPausedInstant(PLAYER_2_ID, false); + checkRunningInstant(PLAYER_1_ID); + checkRunningInstant(PLAYER_2_ID); // Eventually player 2 will pause, player 1 must remain running. - checkPaused(PLAYER_2_ID, true); - checkPausedInstant(PLAYER_1_ID, false); + waitUntilPaused(PLAYER_2_ID); + checkRunningInstant(PLAYER_1_ID); // Eventually both players will run out of frames and should pause. - checkPaused(PLAYER_1_ID, true); - checkPausedInstant(PLAYER_2_ID, true); + waitUntilPaused(PLAYER_1_ID); + checkPausedInstant(PLAYER_2_ID); // Send more frames for the first player only. Player 1 should start playing with player 2 remaining paused. sendFrames(PLAYER_1_ID, 20); - checkPausedInstant(PLAYER_2_ID, true); - checkPausedInstant(PLAYER_1_ID, false); + checkPausedInstant(PLAYER_2_ID); + checkRunningInstant(PLAYER_1_ID); // Send more frames for the second player. Both should be playing sendFrames(PLAYER_2_ID, 20); - checkPausedInstant(PLAYER_2_ID, false); - checkPausedInstant(PLAYER_1_ID, false); + checkRunningInstant(PLAYER_2_ID); + checkRunningInstant(PLAYER_1_ID); } [Test] @@ -244,16 +251,16 @@ namespace osu.Game.Tests.Visual.Multiplayer // Send initial frames for both players. A few more for player 1. sendFrames(PLAYER_1_ID, 1000); sendFrames(PLAYER_2_ID, 30); - checkPausedInstant(PLAYER_1_ID, false); - checkPausedInstant(PLAYER_2_ID, false); + checkRunningInstant(PLAYER_1_ID); + checkRunningInstant(PLAYER_2_ID); // Eventually player 2 will run out of frames and should pause. - checkPaused(PLAYER_2_ID, true); + waitUntilPaused(PLAYER_2_ID); AddWaitStep("wait a few more frames", 10); // Send more frames for player 2. It should unpause. sendFrames(PLAYER_2_ID, 1000); - checkPausedInstant(PLAYER_2_ID, false); + checkRunningInstant(PLAYER_2_ID); // Player 2 should catch up to player 1 after unpausing. waitForCatchup(PLAYER_2_ID); @@ -266,21 +273,28 @@ namespace osu.Game.Tests.Visual.Multiplayer start(new[] { PLAYER_1_ID, PLAYER_2_ID }); loadSpectateScreen(); + // With no frames, the synchronisation state will be TooFarAhead. + // In this state, all players should be muted. assertMuted(PLAYER_1_ID, true); assertMuted(PLAYER_2_ID, true); - sendFrames(PLAYER_1_ID); + // Send frames for both players, with more frames for player 2. + sendFrames(PLAYER_1_ID, 5); sendFrames(PLAYER_2_ID, 20); - checkPaused(PLAYER_1_ID, false); - assertOneNotMuted(); - checkPaused(PLAYER_1_ID, true); + // While both players are running, one of them should be un-muted. + waitUntilRunning(PLAYER_1_ID); + assertOnePlayerNotMuted(); + + // After player 1 runs out of frames, the un-muted player should always be player 2. + waitUntilPaused(PLAYER_1_ID); + waitUntilRunning(PLAYER_2_ID); assertMuted(PLAYER_1_ID, true); assertMuted(PLAYER_2_ID, false); sendFrames(PLAYER_1_ID, 100); waitForCatchup(PLAYER_1_ID); - checkPaused(PLAYER_2_ID, true); + waitUntilPaused(PLAYER_2_ID); assertMuted(PLAYER_1_ID, false); assertMuted(PLAYER_2_ID, true); @@ -313,13 +327,25 @@ namespace osu.Game.Tests.Visual.Multiplayer loadSpectateScreen(); sendFrames(PLAYER_1_ID, 300); - AddWaitStep("wait maximum start delay seconds", (int)(CatchUpSyncManager.MAXIMUM_START_DELAY / TimePerAction)); - checkPaused(PLAYER_1_ID, false); + AddWaitStep("wait maximum start delay seconds", (int)(SpectatorSyncManager.MAXIMUM_START_DELAY / TimePerAction)); + waitUntilRunning(PLAYER_1_ID); sendFrames(PLAYER_2_ID, 300); AddUntilStep("player 2 playing from correct point in time", () => getPlayer(PLAYER_2_ID).ChildrenOfType().Single().FrameStableClock.CurrentTime > 30000); } + [Test] + public void TestGameplayRateAdjust() + { + start(getPlayerIds(4), mods: new[] { new APIMod(new OsuModDoubleTime()) }); + + loadSpectateScreen(); + + sendFrames(getPlayerIds(4), 300); + + AddUntilStep("wait for correct track speed", () => Beatmap.Value.Track.Rate, () => Is.EqualTo(1.5)); + } + [Test] public void TestPlayersLeaveWhileSpectating() { @@ -338,7 +364,7 @@ namespace osu.Game.Tests.Visual.Multiplayer sendFrames(getPlayerIds(count), 300); } - Player player = null; + Player? player = null; AddStep($"get {PLAYER_1_ID} player instance", () => player = getInstance(PLAYER_1_ID).ChildrenOfType().Single()); @@ -352,12 +378,18 @@ namespace osu.Game.Tests.Visual.Multiplayer /// /// Tests spectating with a beatmap that has a high value. + /// + /// This test is not intended not to check the correct initial time value, but only to guard against + /// gameplay potentially getting stuck in a stopped state due to lead in time being present. /// [Test] public void TestAudioLeadIn() => testLeadIn(b => b.BeatmapInfo.AudioLeadIn = 2000); /// /// Tests spectating with a beatmap that has a storyboard element with a negative start time (i.e. intro storyboard element). + /// + /// This test is not intended not to check the correct initial time value, but only to guard against + /// gameplay potentially getting stuck in a stopped state due to lead in time being present. /// [Test] public void TestIntroStoryboardElement() => testLeadIn(b => @@ -367,7 +399,7 @@ namespace osu.Game.Tests.Visual.Multiplayer b.Storyboard.GetLayer("Background").Add(sprite); }); - private void testLeadIn(Action applyToBeatmap = null) + private void testLeadIn(Action? applyToBeatmap = null) { start(PLAYER_1_ID); @@ -379,13 +411,13 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for player load", () => spectatorScreen.AllPlayersLoaded); - AddWaitStep("wait for progression", 3); + AddUntilStep("wait for clock running", () => getInstance(PLAYER_1_ID).SpectatorPlayerClock.IsRunning); assertNotCatchingUp(PLAYER_1_ID); - assertRunning(PLAYER_1_ID); + waitUntilRunning(PLAYER_1_ID); } - private void loadSpectateScreen(bool waitForPlayerLoad = true, Action applyToBeatmap = null) + private void loadSpectateScreen(bool waitForPlayerLoad = true, Action? applyToBeatmap = null) { AddStep("load screen", () => { @@ -402,7 +434,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private void start(int userId, int? beatmapId = null) => start(new[] { userId }, beatmapId); - private void start(int[] userIds, int? beatmapId = null) + private void start(int[] userIds, int? beatmapId = null, APIMod[]? mods = null) { AddStep("start play", () => { @@ -411,10 +443,11 @@ namespace osu.Game.Tests.Visual.Multiplayer var user = new MultiplayerRoomUser(id) { User = new APIUser { Id = id }, + Mods = mods ?? Array.Empty(), }; - OnlinePlayDependencies.MultiplayerClient.AddUser(user.User, true); - SpectatorClient.SendStartPlay(id, beatmapId ?? importedBeatmapId); + OnlinePlayDependencies.MultiplayerClient.AddUser(user, true); + SpectatorClient.SendStartPlay(id, beatmapId ?? importedBeatmapId, mods); playingUsers.Add(user); } @@ -427,13 +460,17 @@ namespace osu.Game.Tests.Visual.Multiplayer { var user = playingUsers.Single(u => u.UserID == userId); - OnlinePlayDependencies.MultiplayerClient.RemoveUser(user.User.AsNonNull()); SpectatorClient.SendEndPlay(userId); + OnlinePlayDependencies.MultiplayerClient.RemoveUser(user.User.AsNonNull()); playingUsers.Remove(user); }); } + /// + /// Send new frames on behalf of a user. + /// Frames will last for count * 100 milliseconds. + /// private void sendFrames(int userId, int count = 10) => sendFrames(new[] { userId }, count); private void sendFrames(int[] userIds, int count = 10) @@ -445,36 +482,47 @@ namespace osu.Game.Tests.Visual.Multiplayer }); } - private void checkPaused(int userId, bool state) - => AddUntilStep($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType().First().GameplayClock.IsRunning != state); - - private void checkPausedInstant(int userId, bool state) + private void checkRunningInstant(int userId) { - checkPaused(userId, state); + waitUntilRunning(userId); // Todo: The following should work, but is broken because SpectatorScreen retrieves the WorkingBeatmap via the BeatmapManager, bypassing the test scene clock and running real-time. // AddAssert($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType().First().GameplayClock.IsRunning != state); } - private void assertOneNotMuted() => AddAssert("one player not muted", () => spectatorScreen.ChildrenOfType().Count(p => !p.Mute) == 1); + private void checkPausedInstant(int userId) + { + waitUntilPaused(userId); + + // Todo: The following should work, but is broken because SpectatorScreen retrieves the WorkingBeatmap via the BeatmapManager, bypassing the test scene clock and running real-time. + // AddAssert($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType().First().GameplayClock.IsRunning != state); + } + + private void assertOnePlayerNotMuted() => AddAssert(nameof(assertOnePlayerNotMuted), () => spectatorScreen.ChildrenOfType().Count(p => !p.Mute) == 1); private void assertMuted(int userId, bool muted) - => AddAssert($"{userId} {(muted ? "is" : "is not")} muted", () => getInstance(userId).Mute == muted); + => AddAssert($"{nameof(assertMuted)}({userId}, {muted})", () => getInstance(userId).Mute == muted); private void assertRunning(int userId) - => AddAssert($"{userId} clock running", () => getInstance(userId).GameplayClock.IsRunning); + => AddAssert($"{nameof(assertRunning)}({userId})", () => getInstance(userId).SpectatorPlayerClock.IsRunning); + + private void waitUntilPaused(int userId) + => AddUntilStep($"{nameof(waitUntilPaused)}({userId})", () => !getPlayer(userId).ChildrenOfType().First().IsRunning); + + private void waitUntilRunning(int userId) + => AddUntilStep($"{nameof(waitUntilRunning)}({userId})", () => getPlayer(userId).ChildrenOfType().First().IsRunning); private void assertNotCatchingUp(int userId) - => AddAssert($"{userId} in sync", () => !getInstance(userId).GameplayClock.IsCatchingUp); + => AddAssert($"{nameof(assertNotCatchingUp)}({userId})", () => !getInstance(userId).SpectatorPlayerClock.IsCatchingUp); private void waitForCatchup(int userId) - => AddUntilStep($"{userId} not catching up", () => !getInstance(userId).GameplayClock.IsCatchingUp); + => AddUntilStep($"{nameof(waitForCatchup)}({userId})", () => !getInstance(userId).SpectatorPlayerClock.IsCatchingUp); private Player getPlayer(int userId) => getInstance(userId).ChildrenOfType().Single(); private PlayerArea getInstance(int userId) => spectatorScreen.ChildrenOfType().Single(p => p.UserId == userId); - private GameplayLeaderboardScore getLeaderboardScore(int userId) => spectatorScreen.ChildrenOfType().Single(s => s.User?.Id == userId); + private GameplayLeaderboardScore getLeaderboardScore(int userId) => spectatorScreen.ChildrenOfType().Single(s => s.User?.OnlineID == userId); private int[] getPlayerIds(int count) => Enumerable.Range(PLAYER_1_ID, count).ToArray(); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index 8e45d99eae..d747d23229 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -24,7 +24,6 @@ using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; -using osu.Game.Overlays.Mods; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mods; @@ -47,25 +46,24 @@ using ReadyButton = osu.Game.Screens.OnlinePlay.Components.ReadyButton; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMultiplayer : ScreenTestScene + public partial class TestSceneMultiplayer : ScreenTestScene { - private BeatmapManager beatmaps; - private RulesetStore rulesets; - private BeatmapSetInfo importedSet; + private BeatmapManager beatmaps = null!; + private BeatmapSetInfo importedSet = null!; - private TestMultiplayerComponents multiplayerComponents; + private TestMultiplayerComponents multiplayerComponents = null!; private TestMultiplayerClient multiplayerClient => multiplayerComponents.MultiplayerClient; private TestMultiplayerRoomManager roomManager => multiplayerComponents.RoomManager; [Resolved] - private OsuConfigManager config { get; set; } + private OsuConfigManager config { get; set; } = null!; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, rulesets, API, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, API, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); } @@ -114,25 +112,25 @@ namespace osu.Game.Tests.Visual.Multiplayer // all ready AddUntilStep("all players ready", () => { - var nextUnready = multiplayerClient.Room?.Users.FirstOrDefault(c => c.State == MultiplayerUserState.Idle); + var nextUnready = multiplayerClient.ClientRoom?.Users.FirstOrDefault(c => c.State == MultiplayerUserState.Idle); if (nextUnready != null) multiplayerClient.ChangeUserState(nextUnready.UserID, MultiplayerUserState.Ready); - return multiplayerClient.Room?.Users.All(u => u.State == MultiplayerUserState.Ready) == true; + return multiplayerClient.ClientRoom?.Users.All(u => u.State == MultiplayerUserState.Ready) == true; }); AddStep("unready all players at once", () => { - Debug.Assert(multiplayerClient.Room != null); + Debug.Assert(multiplayerClient.ServerRoom != null); - foreach (var u in multiplayerClient.Room.Users) multiplayerClient.ChangeUserState(u.UserID, MultiplayerUserState.Idle); + foreach (var u in multiplayerClient.ServerRoom.Users) multiplayerClient.ChangeUserState(u.UserID, MultiplayerUserState.Idle); }); AddStep("ready all players at once", () => { - Debug.Assert(multiplayerClient.Room != null); + Debug.Assert(multiplayerClient.ServerRoom != null); - foreach (var u in multiplayerClient.Room.Users) multiplayerClient.ChangeUserState(u.UserID, MultiplayerUserState.Ready); + foreach (var u in multiplayerClient.ServerRoom.Users) multiplayerClient.ChangeUserState(u.UserID, MultiplayerUserState.Ready); }); } @@ -144,7 +142,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private void removeLastUser() { - APIUser lastUser = multiplayerClient.Room?.Users.Last().User; + APIUser? lastUser = multiplayerClient.ServerRoom?.Users.Last().User; if (lastUser == null || lastUser == multiplayerClient.LocalUser?.User) return; @@ -154,7 +152,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private void kickLastUser() { - APIUser lastUser = multiplayerClient.Room?.Users.Last().User; + APIUser? lastUser = multiplayerClient.ServerRoom?.Users.Last().User; if (lastUser == null || lastUser == multiplayerClient.LocalUser?.User) return; @@ -164,14 +162,14 @@ namespace osu.Game.Tests.Visual.Multiplayer private void markNextPlayerReady() { - var nextUnready = multiplayerClient.Room?.Users.FirstOrDefault(c => c.State == MultiplayerUserState.Idle); + var nextUnready = multiplayerClient.ServerRoom?.Users.FirstOrDefault(c => c.State == MultiplayerUserState.Idle); if (nextUnready != null) multiplayerClient.ChangeUserState(nextUnready.UserID, MultiplayerUserState.Ready); } private void markNextPlayerIdle() { - var nextUnready = multiplayerClient.Room?.Users.FirstOrDefault(c => c.State == MultiplayerUserState.Ready); + var nextUnready = multiplayerClient.ServerRoom?.Users.FirstOrDefault(c => c.State == MultiplayerUserState.Ready); if (nextUnready != null) multiplayerClient.ChangeUserState(nextUnready.UserID, MultiplayerUserState.Idle); } @@ -241,8 +239,8 @@ namespace osu.Game.Tests.Visual.Multiplayer } }); - AddAssert("Check participant count correct", () => multiplayerClient.APIRoom?.ParticipantCount.Value == 1); - AddAssert("Check participant list contains user", () => multiplayerClient.APIRoom?.RecentParticipants.Count(u => u.Id == API.LocalUser.Value.Id) == 1); + AddUntilStep("Check participant count correct", () => multiplayerClient.ClientAPIRoom?.ParticipantCount.Value == 1); + AddUntilStep("Check participant list contains user", () => multiplayerClient.ClientAPIRoom?.RecentParticipants.Count(u => u.Id == API.LocalUser.Value.Id) == 1); } [Test] @@ -301,8 +299,8 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for room open", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); AddUntilStep("wait for join", () => multiplayerClient.RoomJoined); - AddAssert("Check participant count correct", () => multiplayerClient.APIRoom?.ParticipantCount.Value == 1); - AddAssert("Check participant list contains user", () => multiplayerClient.APIRoom?.RecentParticipants.Count(u => u.Id == API.LocalUser.Value.Id) == 1); + AddUntilStep("Check participant count correct", () => multiplayerClient.ClientAPIRoom?.ParticipantCount.Value == 1); + AddUntilStep("Check participant list contains user", () => multiplayerClient.ClientAPIRoom?.RecentParticipants.Count(u => u.Id == API.LocalUser.Value.Id) == 1); } [Test] @@ -321,7 +319,7 @@ namespace osu.Game.Tests.Visual.Multiplayer } }); - AddAssert("room has password", () => multiplayerClient.APIRoom?.Password.Value == "password"); + AddUntilStep("room has password", () => multiplayerClient.ClientAPIRoom?.Password.Value == "password"); } [Test] @@ -349,7 +347,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("select room", () => InputManager.Key(Key.Down)); AddStep("join room", () => InputManager.Key(Key.Enter)); - DrawableLoungeRoom.PasswordEntryPopover passwordEntryPopover = null; + DrawableLoungeRoom.PasswordEntryPopover? passwordEntryPopover = null; AddUntilStep("password prompt appeared", () => (passwordEntryPopover = InputManager.ChildrenOfType().FirstOrDefault()) != null); AddStep("enter password in text box", () => passwordEntryPopover.ChildrenOfType().First().Text = "password"); AddStep("press join room button", () => passwordEntryPopover.ChildrenOfType().First().TriggerClick()); @@ -375,10 +373,21 @@ namespace osu.Game.Tests.Visual.Multiplayer }); AddStep("change password", () => multiplayerClient.ChangeSettings(password: "password2")); - AddUntilStep("local password changed", () => multiplayerClient.APIRoom?.Password.Value == "password2"); + AddUntilStep("local password changed", () => multiplayerClient.ClientAPIRoom?.Password.Value == "password2"); } [Test] + [FlakyTest] + /* + * On a slight investigation, this is occurring due to the ready button + * not receiving the click input generated by the manual input manager. + * + * TearDown : System.TimeoutException : "wait for ready button to be enabled" timed out + * --TearDown + * at osu.Framework.Testing.Drawables.Steps.UntilStepButton.<>c__DisplayClass11_0.<.ctor>b__0() + * at osu.Framework.Testing.Drawables.Steps.StepButton.PerformStep(Boolean userTriggered) + * at osu.Framework.Testing.TestScene.runNextStep(Action onCompletion, Action`1 onError, Func`2 stopCondition) + */ public void TestUserSetToIdleWhenBeatmapDeleted() { createRoom(() => new Room @@ -400,18 +409,21 @@ namespace osu.Game.Tests.Visual.Multiplayer } [Test] + [FlakyTest] // See above public void TestPlayStartsWithCorrectBeatmapWhileAtSongSelect() { - createRoom(() => new Room + PlaylistItem? item = null; + createRoom(() => { - Name = { Value = "Test Room" }, - Playlist = + item = new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { - new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) - { - RulesetID = new OsuRuleset().RulesetInfo.OnlineID - } - } + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + }; + return new Room + { + Name = { Value = "Test Room" }, + Playlist = { item } + }; }); pressReadyButton(); @@ -419,37 +431,40 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("Enter song select", () => { var currentSubScreen = ((Screens.OnlinePlay.Multiplayer.Multiplayer)multiplayerComponents.CurrentScreen).CurrentSubScreen; - ((MultiplayerMatchSubScreen)currentSubScreen).OpenSongSelection(multiplayerClient.Room?.Settings.PlaylistItemId); + ((MultiplayerMatchSubScreen)currentSubScreen).OpenSongSelection(item); }); AddUntilStep("wait for song select", () => this.ChildrenOfType().FirstOrDefault()?.BeatmapSetsLoaded == true); - AddAssert("Beatmap matches current item", () => Beatmap.Value.BeatmapInfo.OnlineID == multiplayerClient.Room?.Playlist.First().BeatmapID); + AddUntilStep("Beatmap matches current item", () => Beatmap.Value.BeatmapInfo.OnlineID == multiplayerClient.ClientRoom?.Playlist.First().BeatmapID); AddStep("Select next beatmap", () => InputManager.Key(Key.Down)); - AddUntilStep("Beatmap doesn't match current item", () => Beatmap.Value.BeatmapInfo.OnlineID != multiplayerClient.Room?.Playlist.First().BeatmapID); + AddUntilStep("Beatmap doesn't match current item", () => Beatmap.Value.BeatmapInfo.OnlineID != multiplayerClient.ClientRoom?.Playlist.First().BeatmapID); AddStep("start match externally", () => multiplayerClient.StartMatch().WaitSafely()); AddUntilStep("play started", () => multiplayerComponents.CurrentScreen is Player); - AddAssert("Beatmap matches current item", () => Beatmap.Value.BeatmapInfo.OnlineID == multiplayerClient.Room?.Playlist.First().BeatmapID); + AddUntilStep("Beatmap matches current item", () => Beatmap.Value.BeatmapInfo.OnlineID == multiplayerClient.ClientRoom?.Playlist.First().BeatmapID); } [Test] + [FlakyTest] // See above public void TestPlayStartsWithCorrectRulesetWhileAtSongSelect() { - createRoom(() => new Room + PlaylistItem? item = null; + createRoom(() => { - Name = { Value = "Test Room" }, - Playlist = + item = new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { - new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) - { - RulesetID = new OsuRuleset().RulesetInfo.OnlineID - } - } + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + }; + return new Room + { + Name = { Value = "Test Room" }, + Playlist = { item } + }; }); pressReadyButton(); @@ -457,37 +472,40 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("Enter song select", () => { var currentSubScreen = ((Screens.OnlinePlay.Multiplayer.Multiplayer)multiplayerComponents.CurrentScreen).CurrentSubScreen; - ((MultiplayerMatchSubScreen)currentSubScreen).OpenSongSelection(multiplayerClient.Room?.Settings.PlaylistItemId); + ((MultiplayerMatchSubScreen)currentSubScreen).OpenSongSelection(item); }); AddUntilStep("wait for song select", () => this.ChildrenOfType().FirstOrDefault()?.BeatmapSetsLoaded == true); - AddAssert("Ruleset matches current item", () => Ruleset.Value.OnlineID == multiplayerClient.Room?.Playlist.First().RulesetID); + AddUntilStep("Ruleset matches current item", () => Ruleset.Value.OnlineID == multiplayerClient.ClientRoom?.Playlist.First().RulesetID); AddStep("Switch ruleset", () => ((MultiplayerMatchSongSelect)multiplayerComponents.MultiplayerScreen.CurrentSubScreen).Ruleset.Value = new CatchRuleset().RulesetInfo); - AddUntilStep("Ruleset doesn't match current item", () => Ruleset.Value.OnlineID != multiplayerClient.Room?.Playlist.First().RulesetID); + AddUntilStep("Ruleset doesn't match current item", () => Ruleset.Value.OnlineID != multiplayerClient.ClientRoom?.Playlist.First().RulesetID); AddStep("start match externally", () => multiplayerClient.StartMatch().WaitSafely()); AddUntilStep("play started", () => multiplayerComponents.CurrentScreen is Player); - AddAssert("Ruleset matches current item", () => Ruleset.Value.OnlineID == multiplayerClient.Room?.Playlist.First().RulesetID); + AddUntilStep("Ruleset matches current item", () => Ruleset.Value.OnlineID == multiplayerClient.ClientRoom?.Playlist.First().RulesetID); } [Test] + [FlakyTest] // See above public void TestPlayStartsWithCorrectModsWhileAtSongSelect() { - createRoom(() => new Room + PlaylistItem? item = null; + createRoom(() => { - Name = { Value = "Test Room" }, - Playlist = + item = new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { - new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) - { - RulesetID = new OsuRuleset().RulesetInfo.OnlineID - } - } + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + }; + return new Room + { + Name = { Value = "Test Room" }, + Playlist = { item } + }; }); pressReadyButton(); @@ -495,25 +513,25 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("Enter song select", () => { var currentSubScreen = ((Screens.OnlinePlay.Multiplayer.Multiplayer)multiplayerComponents.CurrentScreen).CurrentSubScreen; - ((MultiplayerMatchSubScreen)currentSubScreen).OpenSongSelection(multiplayerClient.Room?.Settings.PlaylistItemId); + ((MultiplayerMatchSubScreen)currentSubScreen).OpenSongSelection(item); }); AddUntilStep("wait for song select", () => this.ChildrenOfType().FirstOrDefault()?.BeatmapSetsLoaded == true); - AddAssert("Mods match current item", - () => SelectedMods.Value.Select(m => m.Acronym).SequenceEqual(multiplayerClient.Room.AsNonNull().Playlist.First().RequiredMods.Select(m => m.Acronym))); + AddUntilStep("Mods match current item", + () => SelectedMods.Value.Select(m => m.Acronym).SequenceEqual(multiplayerClient.ClientRoom.AsNonNull().Playlist.First().RequiredMods.Select(m => m.Acronym))); AddStep("Switch required mods", () => ((MultiplayerMatchSongSelect)multiplayerComponents.MultiplayerScreen.CurrentSubScreen).Mods.Value = new Mod[] { new OsuModDoubleTime() }); - AddAssert("Mods don't match current item", - () => !SelectedMods.Value.Select(m => m.Acronym).SequenceEqual(multiplayerClient.Room.AsNonNull().Playlist.First().RequiredMods.Select(m => m.Acronym))); + AddUntilStep("Mods don't match current item", + () => !SelectedMods.Value.Select(m => m.Acronym).SequenceEqual(multiplayerClient.ClientRoom.AsNonNull().Playlist.First().RequiredMods.Select(m => m.Acronym))); AddStep("start match externally", () => multiplayerClient.StartMatch().WaitSafely()); AddUntilStep("play started", () => multiplayerComponents.CurrentScreen is Player); - AddAssert("Mods match current item", - () => SelectedMods.Value.Select(m => m.Acronym).SequenceEqual(multiplayerClient.Room.AsNonNull().Playlist.First().RequiredMods.Select(m => m.Acronym))); + AddUntilStep("Mods match current item", + () => SelectedMods.Value.Select(m => m.Acronym).SequenceEqual(multiplayerClient.ClientRoom.AsNonNull().Playlist.First().RequiredMods.Select(m => m.Acronym))); } [Test] @@ -627,7 +645,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("invoke on back button", () => multiplayerComponents.OnBackButton()); - AddAssert("mod overlay is hidden", () => this.ChildrenOfType().Single().State.Value == Visibility.Hidden); + AddAssert("mod overlay is hidden", () => this.ChildrenOfType().Single().UserModsSelectOverlay.State.Value == Visibility.Hidden); AddAssert("dialog overlay is hidden", () => DialogOverlay.State.Value == Visibility.Hidden); @@ -647,6 +665,7 @@ namespace osu.Game.Tests.Visual.Multiplayer } [Test] + [FlakyTest] // See above public void TestGameplayFlow() { createRoom(() => new Room @@ -667,16 +686,17 @@ namespace osu.Game.Tests.Visual.Multiplayer for (double i = 1000; i < TestResources.QUICK_BEATMAP_LENGTH; i += 1000) { double time = i; - AddUntilStep($"wait for time > {i}", () => this.ChildrenOfType().SingleOrDefault()?.GameplayClock.CurrentTime > time); + AddUntilStep($"wait for time > {i}", () => this.ChildrenOfType().SingleOrDefault()?.CurrentTime > time); } AddUntilStep("wait for results", () => multiplayerComponents.CurrentScreen is ResultsScreen); } [Test] + [FlakyTest] // See above public void TestGameplayExitFlow() { - Bindable holdDelay = null; + Bindable? holdDelay = null; AddStep("Set hold delay to zero", () => { @@ -707,10 +727,11 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for lounge", () => multiplayerComponents.CurrentScreen is Screens.OnlinePlay.Multiplayer.Multiplayer); AddStep("stop holding", () => InputManager.ReleaseKey(Key.Escape)); - AddStep("set hold delay to default", () => holdDelay.SetDefault()); + AddStep("set hold delay to default", () => holdDelay?.SetDefault()); } [Test] + [FlakyTest] // See above public void TestGameplayDoesntStartWithNonLoadedUser() { createRoom(() => new Room @@ -792,6 +813,7 @@ namespace osu.Game.Tests.Visual.Multiplayer } [Test] + [FlakyTest] // See above public void TestSpectatingStateResetOnBackButtonDuringGameplay() { createRoom(() => new Room @@ -827,6 +849,7 @@ namespace osu.Game.Tests.Visual.Multiplayer } [Test] + [FlakyTest] // See above public void TestSpectatingStateNotResetOnBackButtonOutsideOfGameplay() { createRoom(() => new Room @@ -865,6 +888,7 @@ namespace osu.Game.Tests.Visual.Multiplayer } [Test] + [FlakyTest] // See above public void TestItemAddedByOtherUserDuringGameplay() { createRoom(() => new Room @@ -888,13 +912,14 @@ namespace osu.Game.Tests.Visual.Multiplayer RulesetID = new OsuRuleset().RulesetInfo.OnlineID, })).WaitSafely()); - AddUntilStep("item arrived in playlist", () => multiplayerClient.Room?.Playlist.Count == 2); + AddUntilStep("item arrived in playlist", () => multiplayerClient.ClientRoom?.Playlist.Count == 2); AddStep("exit gameplay as initial user", () => multiplayerComponents.MultiplayerScreen.MakeCurrent()); AddUntilStep("queue contains item", () => this.ChildrenOfType().Single().Items.Single().ID == 2); } [Test] + [FlakyTest] // See above public void TestItemAddedAndDeletedByOtherUserDuringGameplay() { createRoom(() => new Room @@ -919,10 +944,10 @@ namespace osu.Game.Tests.Visual.Multiplayer RulesetID = new OsuRuleset().RulesetInfo.OnlineID, })).WaitSafely()); - AddUntilStep("item arrived in playlist", () => multiplayerClient.Room?.Playlist.Count == 2); + AddUntilStep("item arrived in playlist", () => multiplayerClient.ClientRoom?.Playlist.Count == 2); AddStep("delete item as other user", () => multiplayerClient.RemoveUserPlaylistItem(1234, 2).WaitSafely()); - AddUntilStep("item removed from playlist", () => multiplayerClient.Room?.Playlist.Count == 1); + AddUntilStep("item removed from playlist", () => multiplayerClient.ClientRoom?.Playlist.Count == 1); AddStep("exit gameplay as initial user", () => multiplayerComponents.MultiplayerScreen.MakeCurrent()); AddUntilStep("queue is empty", () => this.ChildrenOfType().Single().Items.Count == 0); @@ -955,7 +980,7 @@ namespace osu.Game.Tests.Visual.Multiplayer runGameplay(); AddStep("exit gameplay for other user", () => multiplayerClient.ChangeUserState(1234, MultiplayerUserState.Idle)); - AddUntilStep("wait for room to be idle", () => multiplayerClient.Room?.State == MultiplayerRoomState.Open); + AddUntilStep("wait for room to be idle", () => multiplayerClient.ClientRoom?.State == MultiplayerRoomState.Open); runGameplay(); @@ -967,9 +992,9 @@ namespace osu.Game.Tests.Visual.Multiplayer multiplayerClient.StartMatch().WaitSafely(); }); - AddUntilStep("wait for loading", () => multiplayerClient.Room?.State == MultiplayerRoomState.WaitingForLoad); + AddUntilStep("wait for loading", () => multiplayerClient.ClientRoom?.State == MultiplayerRoomState.WaitingForLoad); AddStep("set player loaded", () => multiplayerClient.ChangeUserState(1234, MultiplayerUserState.Loaded)); - AddUntilStep("wait for gameplay to start", () => multiplayerClient.Room?.State == MultiplayerRoomState.Playing); + AddUntilStep("wait for gameplay to start", () => multiplayerClient.ClientRoom?.State == MultiplayerRoomState.Playing); AddUntilStep("wait for local user to enter spectator", () => multiplayerComponents.CurrentScreen is MultiSpectatorScreen); } } @@ -990,11 +1015,11 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for ready button to be enabled", () => readyButton.Enabled.Value); MultiplayerUserState lastState = MultiplayerUserState.Idle; - MultiplayerRoomUser user = null; + MultiplayerRoomUser? user = null; AddStep("click ready button", () => { - user = playingUserId == null ? multiplayerClient.LocalUser : multiplayerClient.Room?.Users.Single(u => u.UserID == playingUserId); + user = playingUserId == null ? multiplayerClient.LocalUser : multiplayerClient.ServerRoom?.Users.Single(u => u.UserID == playingUserId); lastState = user?.State ?? MultiplayerUserState.Idle; InputManager.MoveMouseTo(readyButton); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs index cbbd535cee..a612167d57 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -13,7 +15,7 @@ using osu.Game.Screens.Play.HUD; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMultiplayerGameplayLeaderboard : MultiplayerGameplayLeaderboardTestScene + public partial class TestSceneMultiplayerGameplayLeaderboard : MultiplayerGameplayLeaderboardTestScene { protected override MultiplayerRoomUser CreateUser(int userId) { @@ -45,7 +47,7 @@ namespace osu.Game.Tests.Visual.Multiplayer }); } - private class TestLeaderboard : MultiplayerGameplayLeaderboard + private partial class TestLeaderboard : MultiplayerGameplayLeaderboard { public Dictionary> UserMods => UserScores.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ScoreProcessor.Mods); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs index c25884039f..48f74cf308 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboardTeams.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Graphics; using osu.Game.Online.Multiplayer; @@ -10,7 +12,7 @@ using osu.Game.Screens.Play.HUD; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMultiplayerGameplayLeaderboardTeams : MultiplayerGameplayLeaderboardTestScene + public partial class TestSceneMultiplayerGameplayLeaderboardTeams : MultiplayerGameplayLeaderboardTestScene { private int team; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs index de3df754a2..cf25e06799 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Graphics.Containers; @@ -16,7 +18,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMultiplayerLoungeSubScreen : OnlinePlayTestScene + public partial class TestSceneMultiplayerLoungeSubScreen : OnlinePlayTestScene { protected new TestRoomManager RoomManager => (TestRoomManager)base.RoomManager; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs index 111f51675d..d636373fbd 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs @@ -1,7 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using NUnit.Framework; +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -9,25 +10,29 @@ using osu.Game.Screens.OnlinePlay.Multiplayer.Match; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMultiplayerMatchFooter : MultiplayerTestScene + public partial class TestSceneMultiplayerMatchFooter : MultiplayerTestScene { - [SetUp] - public new void Setup() => Schedule(() => + public override void SetUpSteps() { - Child = new PopoverContainer + base.SetUpSteps(); + + AddStep("create footer", () => { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Child = new Container + Child = new PopoverContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - Height = 50, - Child = new MultiplayerMatchFooter() - } - }; - }); + RelativeSizeAxes = Axes.Both, + Child = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = 50, + Child = new MultiplayerMatchFooter() + } + }; + }); + } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs index eacd80925d..c0b6a0beab 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -17,7 +19,6 @@ using osu.Game.Database; using osu.Game.Online.Rooms; using osu.Game.Overlays.Mods; using osu.Game.Rulesets; -using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; @@ -30,7 +31,7 @@ using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMultiplayerMatchSongSelect : MultiplayerTestScene + public partial class TestSceneMultiplayerMatchSongSelect : MultiplayerTestScene { private BeatmapManager manager; private RulesetStore rulesets; @@ -45,7 +46,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private void load(GameHost host, AudioManager audio) { Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); importedBeatmapSet = manager.Import(TestResources.CreateTestBeatmapSetInfo(8, rulesets.AvailableRulesets.ToArray())); @@ -66,37 +67,6 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for present", () => songSelect.IsCurrentScreen() && songSelect.BeatmapSetsLoaded); } - [Test] - public void TestBeatmapRevertedOnExitIfNoSelection() - { - BeatmapInfo selectedBeatmap = null; - - AddStep("select beatmap", - () => songSelect.Carousel.SelectBeatmap(selectedBeatmap = beatmaps.Where(beatmap => beatmap.Ruleset.OnlineID == new OsuRuleset().LegacyID).ElementAt(1))); - AddUntilStep("wait for selection", () => Beatmap.Value.BeatmapInfo.Equals(selectedBeatmap)); - - AddStep("exit song select", () => songSelect.Exit()); - AddAssert("beatmap reverted", () => Beatmap.IsDefault); - } - - [Test] - public void TestModsRevertedOnExitIfNoSelection() - { - AddStep("change mods", () => SelectedMods.Value = new[] { new OsuModDoubleTime() }); - - AddStep("exit song select", () => songSelect.Exit()); - AddAssert("mods reverted", () => SelectedMods.Value.Count == 0); - } - - [Test] - public void TestRulesetRevertedOnExitIfNoSelection() - { - AddStep("change ruleset", () => Ruleset.Value = new CatchRuleset().RulesetInfo); - - AddStep("exit song select", () => songSelect.Exit()); - AddAssert("ruleset reverted", () => Ruleset.Value.Equals(new OsuRuleset().RulesetInfo)); - } - [Test] public void TestBeatmapConfirmed() { @@ -105,7 +75,10 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("change ruleset", () => Ruleset.Value = new TaikoRuleset().RulesetInfo); AddStep("select beatmap", () => songSelect.Carousel.SelectBeatmap(selectedBeatmap = beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == new TaikoRuleset().LegacyID))); + AddUntilStep("wait for selection", () => Beatmap.Value.BeatmapInfo.Equals(selectedBeatmap)); + AddUntilStep("wait for ongoing operation to complete", () => !OnlinePlayDependencies.OngoingOperationTracker.InProgress.Value); + AddStep("set mods", () => SelectedMods.Value = new[] { new TaikoModDoubleTime() }); AddStep("confirm selection", () => songSelect.FinaliseSelection()); @@ -139,7 +112,7 @@ namespace osu.Game.Tests.Visual.Multiplayer .All(b => b.Mod.GetType() != type)); } - private class TestMultiplayerMatchSongSelect : MultiplayerMatchSongSelect + private partial class TestMultiplayerMatchSongSelect : MultiplayerMatchSongSelect { public new Bindable> Mods => base.Mods; @@ -147,8 +120,8 @@ namespace osu.Game.Tests.Visual.Multiplayer public new BeatmapCarousel Carousel => base.Carousel; - public TestMultiplayerMatchSongSelect(Room room, WorkingBeatmap beatmap = null, RulesetInfo ruleset = null) - : base(room, null, beatmap, ruleset) + public TestMultiplayerMatchSongSelect(Room room) + : base(room) { } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs index 7ae81c9800..8816787ceb 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs @@ -1,7 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; +using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -15,6 +18,8 @@ using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; using osu.Game.Overlays.Mods; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; @@ -22,6 +27,7 @@ using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Taiko; using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Rulesets.UI; +using osu.Game.Screens.Menu; using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Screens.OnlinePlay.Multiplayer; @@ -33,12 +39,11 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMultiplayerMatchSubScreen : MultiplayerTestScene + public partial class TestSceneMultiplayerMatchSubScreen : MultiplayerTestScene { private MultiplayerMatchSubScreen screen; private BeatmapManager beatmaps; - private RulesetStore rulesets; private BeatmapSetInfo importedSet; public TestSceneMultiplayerMatchSubScreen() @@ -49,8 +54,8 @@ namespace osu.Game.Tests.Visual.Multiplayer [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); @@ -58,20 +63,38 @@ namespace osu.Game.Tests.Visual.Multiplayer importedSet = beatmaps.GetAllUsableBeatmapSets().First(); } - [SetUp] - public new void Setup() => Schedule(() => - { - SelectedRoom.Value = new Room { Name = { Value = "Test Room" } }; - }); - [SetUpSteps] public void SetupSteps() { - AddStep("load match", () => LoadScreen(screen = new MultiplayerMatchSubScreen(SelectedRoom.Value))); + AddStep("load match", () => + { + SelectedRoom.Value = new Room { Name = { Value = "Test Room" } }; + LoadScreen(screen = new TestMultiplayerMatchSubScreen(SelectedRoom.Value)); + }); + AddUntilStep("wait for load", () => screen.IsCurrentScreen()); } [Test] + [FlakyTest] + /* + * Fail rate around 1.5% + * + * TearDown : System.AggregateException : One or more errors occurred. (Index was out of range. Must be non-negative and less than the size of the collection. (Parameter 'index')) + ----> System.ArgumentOutOfRangeException : Index was out of range. Must be non-negative and less than the size of the collection. (Parameter 'index') + * --TearDown + * at System.Threading.Tasks.Task.ThrowIfExceptional(Boolean includeTaskCanceledExceptions) + * at System.Threading.Tasks.Task.Wait(Int32 millisecondsTimeout, CancellationToken cancellationToken) + * at osu.Framework.Extensions.TaskExtensions.WaitSafely(Task task) + * at osu.Framework.Testing.TestScene.checkForErrors() + * at osu.Framework.Testing.TestScene.RunTestsFromNUnit() + *--ArgumentOutOfRangeException + * at osu.Framework.Bindables.BindableList`1.removeAt(Int32 index, BindableList`1 caller) + * at osu.Framework.Bindables.BindableList`1.removeAt(Int32 index, BindableList`1 caller) + * at osu.Framework.Bindables.BindableList`1.removeAt(Int32 index, BindableList`1 caller) + * at osu.Game.Online.Multiplayer.MultiplayerClient.<>c__DisplayClass106_0.b__0() in C:\BuildAgent\work\ecd860037212ac52\osu.Game\Online\Multiplayer\MultiplayerClient .cs:line 702 + * at osu.Framework.Threading.ScheduledDelegate.RunTaskInternal() + */ public void TestCreatedRoom() { AddStep("add playlist item", () => @@ -88,6 +111,7 @@ namespace osu.Game.Tests.Visual.Multiplayer } [Test] + [FlakyTest] // See above public void TestTaikoOnlyMod() { AddStep("add playlist item", () => @@ -108,6 +132,7 @@ namespace osu.Game.Tests.Visual.Multiplayer } [Test] + [FlakyTest] // See above public void TestSettingValidity() { AddAssert("create button not enabled", () => !this.ChildrenOfType().Single().Enabled.Value); @@ -124,6 +149,7 @@ namespace osu.Game.Tests.Visual.Multiplayer } [Test] + [FlakyTest] // See above public void TestStartMatchWhileSpectating() { AddStep("set playlist", () => @@ -150,10 +176,11 @@ namespace osu.Game.Tests.Visual.Multiplayer ClickButtonWhenEnabled(); - AddUntilStep("match started", () => MultiplayerClient.Room?.State == MultiplayerRoomState.WaitingForLoad); + AddUntilStep("match started", () => MultiplayerClient.ClientRoom?.State == MultiplayerRoomState.WaitingForLoad); } [Test] + [FlakyTest] // See above public void TestFreeModSelectionHasAllowedMods() { AddStep("add playlist item with allowed mod", () => @@ -174,13 +201,13 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("mod select contents loaded", () => this.ChildrenOfType().Any() && this.ChildrenOfType().All(col => col.IsLoaded && col.ItemsLoaded)); AddUntilStep("mod select contains only double time mod", - () => this.ChildrenOfType() - .SingleOrDefault()? + () => this.ChildrenOfType().Single().UserModsSelectOverlay .ChildrenOfType() .SingleOrDefault(panel => !panel.Filtered.Value)?.Mod is OsuModDoubleTime); } [Test] + [FlakyTest] // See above public void TestModSelectKeyWithAllowedMods() { AddStep("add playlist item with allowed mod", () => @@ -198,10 +225,11 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("press toggle mod select key", () => InputManager.Key(Key.F1)); - AddUntilStep("mod select shown", () => this.ChildrenOfType().Single().State.Value == Visibility.Visible); + AddUntilStep("mod select shown", () => this.ChildrenOfType().Single().UserModsSelectOverlay.State.Value == Visibility.Visible); } [Test] + [FlakyTest] // See above public void TestModSelectKeyWithNoAllowedMods() { AddStep("add playlist item with no allowed mods", () => @@ -218,10 +246,11 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("press toggle mod select key", () => InputManager.Key(Key.F1)); AddWaitStep("wait some", 3); - AddAssert("mod select not shown", () => this.ChildrenOfType().Single().State.Value == Visibility.Hidden); + AddAssert("mod select not shown", () => this.ChildrenOfType().Single().UserModsSelectOverlay.State.Value == Visibility.Hidden); } [Test] + [FlakyTest] // See above public void TestNextPlaylistItemSelectedAfterCompletion() { AddStep("add two playlist items", () => @@ -252,9 +281,33 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("last playlist item selected", () => { - var lastItem = this.ChildrenOfType().Single(p => p.Item.ID == MultiplayerClient.APIRoom?.Playlist.Last().ID); + var lastItem = this.ChildrenOfType().Single(p => p.Item.ID == MultiplayerClient.ServerAPIRoom?.Playlist.Last().ID); return lastItem.IsSelectedItem; }); } + + private partial class TestMultiplayerMatchSubScreen : MultiplayerMatchSubScreen + { + [Resolved(canBeNull: true)] + [CanBeNull] + private IDialogOverlay dialogOverlay { get; set; } + + public TestMultiplayerMatchSubScreen(Room room) + : base(room) + { + } + + public override bool OnExiting(ScreenExitEvent e) + { + // For testing purposes allow the screen to exit without confirming on second attempt. + if (!ExitConfirmed && dialogOverlay?.CurrentDialog is ConfirmDiscardChangesDialog confirmDialog) + { + confirmDialog.PerformAction(); + return true; + } + + return base.OnExiting(e); + } + } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs index 8da077cd44..2da29ccc95 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Testing; using osu.Framework.Utils; +using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Online; using osu.Game.Online.API.Requests.Responses; @@ -22,7 +23,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMultiplayerParticipantsList : MultiplayerTestScene + public partial class TestSceneMultiplayerParticipantsList : MultiplayerTestScene { [SetUpSteps] public void SetupSteps() @@ -51,20 +52,20 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("one unique panel", () => this.ChildrenOfType().Select(p => p.User).Distinct().Count() == 1); AddStep("add non-resolvable user", () => MultiplayerClient.TestAddUnresolvedUser()); - AddAssert("null user added", () => MultiplayerClient.Room.AsNonNull().Users.Count(u => u.User == null) == 1); + AddUntilStep("null user added", () => MultiplayerClient.ClientRoom.AsNonNull().Users.Count(u => u.User == null) == 1); AddUntilStep("two unique panels", () => this.ChildrenOfType().Select(p => p.User).Distinct().Count() == 2); AddStep("kick null user", () => this.ChildrenOfType().Single(p => p.User.User == null) .ChildrenOfType().Single().TriggerClick()); - AddAssert("null user kicked", () => MultiplayerClient.Room.AsNonNull().Users.Count == 1); + AddUntilStep("null user kicked", () => MultiplayerClient.ClientRoom.AsNonNull().Users.Count == 1); } [Test] public void TestRemoveUser() { - APIUser secondUser = null; + APIUser? secondUser = null; AddStep("add a user", () => { @@ -78,7 +79,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("remove host", () => MultiplayerClient.RemoveUser(API.LocalUser.Value)); - AddAssert("single panel is for second user", () => this.ChildrenOfType().Single().User.User == secondUser); + AddAssert("single panel is for second user", () => this.ChildrenOfType().Single().User.UserID == secondUser?.Id); } [Test] @@ -215,7 +216,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("kick second user", () => this.ChildrenOfType().Single(d => d.IsPresent).TriggerClick()); - AddAssert("second user kicked", () => MultiplayerClient.Room?.Users.Single().UserID == API.LocalUser.Value.Id); + AddUntilStep("second user kicked", () => MultiplayerClient.ClientRoom?.Users.Single().UserID == API.LocalUser.Value.Id); } [Test] @@ -366,17 +367,21 @@ namespace osu.Game.Tests.Visual.Multiplayer private void createNewParticipantsList() { - ParticipantsList participantsList = null; + ParticipantsList? participantsList = null; - AddStep("create new list", () => Child = participantsList = new ParticipantsList + AddStep("create new list", () => Child = new OsuContextMenuContainer { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Y, - Size = new Vector2(380, 0.7f) + RelativeSizeAxes = Axes.Both, + Child = participantsList = new ParticipantsList + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Size = new Vector2(380, 0.7f) + } }); - AddUntilStep("wait for list to load", () => participantsList.IsLoaded); + AddUntilStep("wait for list to load", () => participantsList?.IsLoaded == true); } private void checkProgressBarVisibility(bool visible) => diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlayer.cs index e05580fed6..45c5c67fff 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlayer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Screens; @@ -12,7 +14,7 @@ using osu.Game.Screens.OnlinePlay.Multiplayer; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMultiplayerPlayer : MultiplayerTestScene + public partial class TestSceneMultiplayerPlayer : MultiplayerTestScene { private MultiplayerPlayer player; @@ -28,10 +30,10 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("initialise gameplay", () => { - Stack.Push(player = new MultiplayerPlayer(MultiplayerClient.APIRoom, new PlaylistItem(Beatmap.Value.BeatmapInfo) + Stack.Push(player = new MultiplayerPlayer(MultiplayerClient.ServerAPIRoom, new PlaylistItem(Beatmap.Value.BeatmapInfo) { RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID, - }, MultiplayerClient.Room?.Users.ToArray())); + }, MultiplayerClient.ServerRoom?.Users.ToArray())); }); AddUntilStep("wait for player to be current", () => player.IsCurrentScreen() && player.IsLoaded); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs index 1231866b36..d7578b4114 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -25,37 +27,37 @@ using osuTK; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMultiplayerPlaylist : MultiplayerTestScene + public partial class TestSceneMultiplayerPlaylist : MultiplayerTestScene { private MultiplayerPlaylist list; private BeatmapManager beatmaps; - private RulesetStore rulesets; private BeatmapSetInfo importedSet; private BeatmapInfo importedBeatmap; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); } - [SetUp] - public new void Setup() => Schedule(() => - { - Child = list = new MultiplayerPlaylist - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.4f, 0.8f) - }; - }); - [SetUpSteps] - public new void SetUpSteps() + public override void SetUpSteps() { + base.SetUpSteps(); + + AddStep("create list", () => + { + Child = list = new MultiplayerPlaylist + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.4f, 0.8f) + }; + }); + AddStep("import beatmap", () => { beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs index 117aaeba83..bb37f1a5a7 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -23,19 +25,18 @@ using osuTK; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMultiplayerQueueList : MultiplayerTestScene + public partial class TestSceneMultiplayerQueueList : MultiplayerTestScene { private MultiplayerQueueList playlist; private BeatmapManager beatmaps; - private RulesetStore rulesets; private BeatmapSetInfo importedSet; private BeatmapInfo importedBeatmap; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, rulesets, API, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, API, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); } @@ -50,7 +51,7 @@ namespace osu.Game.Tests.Visual.Multiplayer Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(500, 300), - Items = { BindTarget = MultiplayerClient.APIRoom!.Playlist } + Items = { BindTarget = MultiplayerClient.ClientAPIRoom!.Playlist } }; }); @@ -68,7 +69,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public void TestDeleteButtonAlwaysVisibleForHost() { AddStep("set all players queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }).WaitSafely()); - AddUntilStep("wait for queue mode change", () => MultiplayerClient.APIRoom?.QueueMode.Value == QueueMode.AllPlayers); + AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode.Value == QueueMode.AllPlayers); addPlaylistItem(() => API.LocalUser.Value.OnlineID); assertDeleteButtonVisibility(1, true); @@ -80,7 +81,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public void TestDeleteButtonOnlyVisibleForItemOwnerIfNotHost() { AddStep("set all players queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }).WaitSafely()); - AddUntilStep("wait for queue mode change", () => MultiplayerClient.APIRoom?.QueueMode.Value == QueueMode.AllPlayers); + AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode.Value == QueueMode.AllPlayers); AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 1234 })); AddStep("set other user as host", () => MultiplayerClient.TransferHost(1234)); @@ -96,18 +97,27 @@ namespace osu.Game.Tests.Visual.Multiplayer } [Test] - public void TestCurrentItemDoesNotHaveDeleteButton() + public void TestSingleItemDoesNotHaveDeleteButton() { AddStep("set all players queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }).WaitSafely()); - AddUntilStep("wait for queue mode change", () => MultiplayerClient.APIRoom?.QueueMode.Value == QueueMode.AllPlayers); + AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode.Value == QueueMode.AllPlayers); + + assertDeleteButtonVisibility(0, false); + } + + [Test] + public void TestCurrentItemHasDeleteButtonIfNotSingle() + { + AddStep("set all players queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }).WaitSafely()); + AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode.Value == QueueMode.AllPlayers); addPlaylistItem(() => API.LocalUser.Value.OnlineID); - assertDeleteButtonVisibility(0, false); + assertDeleteButtonVisibility(0, true); assertDeleteButtonVisibility(1, true); AddStep("finish current item", () => MultiplayerClient.FinishCurrentItem().WaitSafely()); - AddUntilStep("wait for next item to be selected", () => MultiplayerClient.Room?.Settings.PlaylistItemId == 2); + AddUntilStep("wait for next item to be selected", () => MultiplayerClient.ClientRoom?.Settings.PlaylistItemId == 2); AddUntilStep("wait for two items in playlist", () => playlist.ChildrenOfType().Count() == 2); assertDeleteButtonVisibility(0, false); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerResults.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerResults.cs index cc08135939..f030466fff 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerResults.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerResults.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Online.Rooms; using osu.Game.Rulesets.Osu; @@ -9,7 +11,7 @@ using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMultiplayerResults : ScreenTestScene + public partial class TestSceneMultiplayerResults : ScreenTestScene { [Test] public void TestDisplayResults() diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs index 13917f4eb0..816ba4ca32 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -24,7 +26,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMultiplayerSpectateButton : MultiplayerTestScene + public partial class TestSceneMultiplayerSpectateButton : MultiplayerTestScene { private MultiplayerSpectateButton spectateButton; private MatchStartControl startControl; @@ -33,55 +35,58 @@ namespace osu.Game.Tests.Visual.Multiplayer private BeatmapSetInfo importedSet; private BeatmapManager beatmaps; - private RulesetStore rulesets; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); } - [SetUp] - public new void Setup() => Schedule(() => + public override void SetUpSteps() { - AvailabilityTracker.SelectedItem.BindTo(selectedItem); + base.SetUpSteps(); - importedSet = beatmaps.GetAllUsableBeatmapSets().First(); - Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()); - selectedItem.Value = new PlaylistItem(Beatmap.Value.BeatmapInfo) + AddStep("create button", () => { - RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID, - }; + AvailabilityTracker.SelectedItem.BindTo(selectedItem); - Child = new PopoverContainer - { - RelativeSizeAxes = Axes.Both, - Child = new FillFlowContainer + importedSet = beatmaps.GetAllUsableBeatmapSets().First(); + Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()); + selectedItem.Value = new PlaylistItem(Beatmap.Value.BeatmapInfo) { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] + RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID, + }; + + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new FillFlowContainer { - spectateButton = new MultiplayerSpectateButton + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(200, 50), - }, - startControl = new MatchStartControl - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(200, 50), + spectateButton = new MultiplayerSpectateButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(200, 50), + }, + startControl = new MatchStartControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(200, 50), + } } } - } - }; - }); + }; + }); + } [TestCase(MultiplayerRoomState.Open)] [TestCase(MultiplayerRoomState.WaitingForLoad)] @@ -97,10 +102,10 @@ namespace osu.Game.Tests.Visual.Multiplayer public void TestToggleWhenIdle(MultiplayerUserState initialState) { ClickButtonWhenEnabled(); - AddUntilStep("user is spectating", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Spectating); + AddUntilStep("user is spectating", () => MultiplayerClient.ClientRoom?.Users[0].State == MultiplayerUserState.Spectating); ClickButtonWhenEnabled(); - AddUntilStep("user is idle", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Idle); + AddUntilStep("user is idle", () => MultiplayerClient.ClientRoom?.Users[0].State == MultiplayerUserState.Idle); } [TestCase(MultiplayerRoomState.Closed)] diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectatorPlayerGrid.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectatorPlayerGrid.cs index c0958c7fe8..8fd05dcaa9 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectatorPlayerGrid.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectatorPlayerGrid.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMultiplayerSpectatorPlayerGrid : OsuManualInputManagerTestScene + public partial class TestSceneMultiplayerSpectatorPlayerGrid : OsuManualInputManagerTestScene { private PlayerGrid grid; @@ -103,7 +105,7 @@ namespace osu.Game.Tests.Visual.Multiplayer bool checkAction() => Precision.AlmostEquals(grid.MaximisedFacade.DrawSize, grid.Content.ElementAt(index).DrawSize, 10) == shouldBeMaximised; } - private class GridContent : Box + private partial class GridContent : Box { public GridContent() { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerTeamResults.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerTeamResults.cs index 0237298fa1..68fd39a066 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerTeamResults.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerTeamResults.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Bindables; @@ -12,7 +14,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMultiplayerTeamResults : ScreenTestScene + public partial class TestSceneMultiplayerTeamResults : ScreenTestScene { [Test] public void TestScaling() diff --git a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsRoomSettingsPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsRoomSettingsPlaylist.cs index 98dc243ab5..ae27db0dd1 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsRoomSettingsPlaylist.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsRoomSettingsPlaylist.cs @@ -1,6 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -23,7 +26,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestScenePlaylistsRoomSettingsPlaylist : OnlinePlayTestScene + public partial class TestScenePlaylistsRoomSettingsPlaylist : OnlinePlayTestScene { private TestPlaylist playlist; @@ -44,10 +47,14 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("item removed", () => !playlist.Items.Contains(selectedItem)); } - [Test] - public void TestNextItemSelectedAfterDeletion() + [TestCase(true)] + [TestCase(false)] + public void TestNextItemSelectedAfterDeletion(bool allowSelection) { - createPlaylist(); + createPlaylist(p => + { + p.AllowSelection = allowSelection; + }); moveToItem(0); AddStep("click", () => InputManager.Click(MouseButton.Left)); @@ -55,7 +62,7 @@ namespace osu.Game.Tests.Visual.Multiplayer moveToDeleteButton(0); AddStep("click delete button", () => InputManager.Click(MouseButton.Left)); - AddAssert("item 0 is selected", () => playlist.SelectedItem.Value == playlist.Items[0]); + AddAssert("item 0 is " + (allowSelection ? "selected" : "not selected"), () => playlist.SelectedItem.Value == (allowSelection ? playlist.Items[0] : null)); } [Test] @@ -115,7 +122,7 @@ namespace osu.Game.Tests.Visual.Multiplayer InputManager.MoveMouseTo(item.ChildrenOfType().ElementAt(0), offset); }); - private void createPlaylist() + private void createPlaylist(Action setupPlaylist = null) { AddStep("create playlist", () => { @@ -152,12 +159,14 @@ namespace osu.Game.Tests.Visual.Multiplayer } }); } + + setupPlaylist?.Invoke(playlist); }); AddUntilStep("wait for items to load", () => playlist.ItemMap.Values.All(i => i.IsLoaded)); } - private class TestPlaylist : PlaylistsRoomSettingsPlaylist + private partial class TestPlaylist : PlaylistsRoomSettingsPlaylist { public new IReadOnlyDictionary> ItemMap => base.ItemMap; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs index f8d62c9840..b0b753fc22 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -22,19 +24,17 @@ using osu.Game.Tests.Visual.OnlinePlay; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestScenePlaylistsSongSelect : OnlinePlayTestScene + public partial class TestScenePlaylistsSongSelect : OnlinePlayTestScene { private BeatmapManager manager; - private RulesetStore rulesets; - private TestPlaylistsSongSelect songSelect; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); var beatmapSet = TestResources.CreateTestBeatmapSetInfo(); @@ -154,7 +154,7 @@ namespace osu.Game.Tests.Visual.Multiplayer }); } - private class TestPlaylistsSongSelect : PlaylistsSongSelect + private partial class TestPlaylistsSongSelect : PlaylistsSongSelect { public new MatchBeatmapDetailArea BeatmapDetails => (MatchBeatmapDetailArea)base.BeatmapDetails; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneRankRangePill.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneRankRangePill.cs index b0a977dcbb..aaf1a850af 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneRankRangePill.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneRankRangePill.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using Moq; @@ -14,7 +16,7 @@ using osu.Game.Screens.OnlinePlay.Lounge.Components; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneRankRangePill : OsuTestScene + public partial class TestSceneRankRangePill : OsuTestScene { private readonly Mock multiplayerClient = new Mock(); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneStarRatingRangeDisplay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneStarRatingRangeDisplay.cs index 5e4013b0f1..e46ae978d7 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneStarRatingRangeDisplay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneStarRatingRangeDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Beatmaps; @@ -10,19 +12,23 @@ using osu.Game.Tests.Visual.OnlinePlay; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneStarRatingRangeDisplay : OnlinePlayTestScene + public partial class TestSceneStarRatingRangeDisplay : OnlinePlayTestScene { - [SetUp] - public new void Setup() => Schedule(() => + public override void SetUpSteps() { - SelectedRoom.Value = new Room(); + base.SetUpSteps(); - Child = new StarRatingRangeDisplay + AddStep("create display", () => { - Anchor = Anchor.Centre, - Origin = Anchor.Centre - }; - }); + SelectedRoom.Value = new Room(); + + Child = new StarRatingRangeDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }; + }); + } [Test] public void TestRange([Values(0, 2, 3, 4, 6, 7)] double min, [Values(0, 2, 3, 4, 6, 7)] double max) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs index 19e1f62fd5..32e90153d8 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -25,10 +27,9 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneTeamVersus : ScreenTestScene + public partial class TestSceneTeamVersus : ScreenTestScene { private BeatmapManager beatmaps; - private RulesetStore rulesets; private BeatmapSetInfo importedSet; private TestMultiplayerComponents multiplayerComponents; @@ -38,8 +39,8 @@ namespace osu.Game.Tests.Visual.Multiplayer [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); } @@ -74,8 +75,8 @@ namespace osu.Game.Tests.Visual.Multiplayer } }); - AddUntilStep("room type is team vs", () => multiplayerClient.Room?.Settings.MatchType == MatchType.TeamVersus); - AddAssert("user state arrived", () => multiplayerClient.Room?.Users.FirstOrDefault()?.MatchState is TeamVersusUserState); + AddUntilStep("room type is team vs", () => multiplayerClient.ClientRoom?.Settings.MatchType == MatchType.TeamVersus); + AddUntilStep("user state arrived", () => multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState is TeamVersusUserState); } [Test] @@ -94,7 +95,7 @@ namespace osu.Game.Tests.Visual.Multiplayer } }); - AddAssert("user on team 0", () => (multiplayerClient.Room?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0); + AddUntilStep("user on team 0", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0); AddStep("add another user", () => multiplayerClient.AddUser(new APIUser { Username = "otheruser", Id = 44 })); AddStep("press own button", () => @@ -102,17 +103,17 @@ namespace osu.Game.Tests.Visual.Multiplayer InputManager.MoveMouseTo(multiplayerComponents.ChildrenOfType().First()); InputManager.Click(MouseButton.Left); }); - AddAssert("user on team 1", () => (multiplayerClient.Room?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 1); + AddUntilStep("user on team 1", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 1); AddStep("press own button again", () => InputManager.Click(MouseButton.Left)); - AddAssert("user on team 0", () => (multiplayerClient.Room?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0); + AddUntilStep("user on team 0", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0); AddStep("press other user's button", () => { InputManager.MoveMouseTo(multiplayerComponents.ChildrenOfType().ElementAt(1)); InputManager.Click(MouseButton.Left); }); - AddAssert("user still on team 0", () => (multiplayerClient.Room?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0); + AddUntilStep("user still on team 0", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0); } [Test] @@ -131,14 +132,14 @@ namespace osu.Game.Tests.Visual.Multiplayer } }); - AddUntilStep("match type head to head", () => multiplayerClient.APIRoom?.Type.Value == MatchType.HeadToHead); + AddUntilStep("match type head to head", () => multiplayerClient.ClientAPIRoom?.Type.Value == MatchType.HeadToHead); AddStep("change match type", () => multiplayerClient.ChangeSettings(new MultiplayerRoomSettings { MatchType = MatchType.TeamVersus }).WaitSafely()); - AddUntilStep("api room updated to team versus", () => multiplayerClient.APIRoom?.Type.Value == MatchType.TeamVersus); + AddUntilStep("api room updated to team versus", () => multiplayerClient.ClientAPIRoom?.Type.Value == MatchType.TeamVersus); } [Test] @@ -156,13 +157,13 @@ namespace osu.Game.Tests.Visual.Multiplayer } }); - AddUntilStep("room type is head to head", () => multiplayerClient.Room?.Settings.MatchType == MatchType.HeadToHead); + AddUntilStep("room type is head to head", () => multiplayerClient.ClientRoom?.Settings.MatchType == MatchType.HeadToHead); AddUntilStep("team displays are not displaying teams", () => multiplayerComponents.ChildrenOfType().All(d => d.DisplayedTeam == null)); AddStep("change to team vs", () => multiplayerClient.ChangeSettings(matchType: MatchType.TeamVersus)); - AddUntilStep("room type is team vs", () => multiplayerClient.Room?.Settings.MatchType == MatchType.TeamVersus); + AddUntilStep("room type is team vs", () => multiplayerClient.ClientRoom?.Settings.MatchType == MatchType.TeamVersus); AddUntilStep("team displays are displaying teams", () => multiplayerComponents.ChildrenOfType().All(d => d.DisplayedTeam != null)); } diff --git a/osu.Game.Tests/Visual/Navigation/TestEFToRealmMigration.cs b/osu.Game.Tests/Visual/Navigation/TestEFToRealmMigration.cs deleted file mode 100644 index 2ea768b878..0000000000 --- a/osu.Game.Tests/Visual/Navigation/TestEFToRealmMigration.cs +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Linq; -using System.Runtime.InteropServices; -using NUnit.Framework; -using osu.Framework; -using osu.Framework.Allocation; -using osu.Game.Beatmaps; -using osu.Game.Database; -using osu.Game.Models; -using osu.Game.Scoring; -using osu.Game.Skinning; -using osu.Game.Tests.Resources; - -namespace osu.Game.Tests.Visual.Navigation -{ - public class TestEFToRealmMigration : OsuGameTestScene - { - public override void RecycleLocalStorage(bool isDisposing) - { - base.RecycleLocalStorage(isDisposing); - - if (isDisposing) - return; - - using (var outStream = LocalStorage.CreateFileSafely(DatabaseContextFactory.DATABASE_NAME)) - using (var stream = TestResources.OpenResource(DatabaseContextFactory.DATABASE_NAME)) - stream.CopyTo(outStream); - } - - [SetUp] - public void SetUp() - { - if (RuntimeInfo.OS == RuntimeInfo.Platform.macOS && RuntimeInformation.OSArchitecture == Architecture.Arm64) - Assert.Ignore("EF-to-realm migrations are not supported on M1 ARM architectures."); - } - - public override void SetUpSteps() - { - // base SetUpSteps are executed before the above SetUp, therefore early-return to allow ignoring test properly. - // attempting to ignore here would yield a TargetInvocationException instead. - if (RuntimeInfo.OS == RuntimeInfo.Platform.macOS && RuntimeInformation.OSArchitecture == Architecture.Arm64) - return; - - base.SetUpSteps(); - } - - [Test] - public void TestMigration() - { - // Numbers are taken from the test database (see commit f03de16ee5a46deac3b5f2ca1edfba5c4c5dca7d). - AddAssert("Check beatmaps", () => Game.Dependencies.Get().Run(r => r.All().Count(s => !s.Protected) == 1)); - AddAssert("Check skins", () => Game.Dependencies.Get().Run(r => r.All().Count(s => !s.Protected) == 1)); - AddAssert("Check scores", () => Game.Dependencies.Get().Run(r => r.All().Count() == 1)); - - // One extra file is created during realm migration / startup due to the circles intro import. - AddAssert("Check files", () => Game.Dependencies.Get().Run(r => r.All().Count() == 271)); - } - } -} diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneButtonSystemNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneButtonSystemNavigation.cs index 8c96ec699f..64ea6003bc 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneButtonSystemNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneButtonSystemNavigation.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Testing; @@ -10,7 +12,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Navigation { - public class TestSceneButtonSystemNavigation : OsuGameTestScene + public partial class TestSceneButtonSystemNavigation : OsuGameTestScene { private ButtonSystem buttons => ((MainMenu)Game.ScreenStack.CurrentScreen).ChildrenOfType().Single(); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs b/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs index b7a74dcd27..224e7e411e 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneChangeAndUseGameplayBindings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -12,13 +14,14 @@ 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; namespace osu.Game.Tests.Visual.Navigation { - public class TestSceneChangeAndUseGameplayBindings : OsuGameTestScene + public partial class TestSceneChangeAndUseGameplayBindings : OsuGameTestScene { [Test] public void TestGameplayKeyBindings() @@ -54,6 +57,7 @@ namespace osu.Game.Tests.Visual.Navigation PushAndConfirm(() => new PlaySongSelect()); AddUntilStep("wait for selection", () => !Game.Beatmap.IsDefault); + AddUntilStep("wait for carousel load", () => songSelect.BeatmapSetsLoaded); AddStep("enter gameplay", () => InputManager.Key(Key.Enter)); @@ -66,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 @@ -90,6 +94,8 @@ namespace osu.Game.Tests.Visual.Navigation .AsEnumerable() .First(k => k.RulesetName == "osu" && k.ActionInt == 0); + private Screens.Select.SongSelect songSelect => Game.ScreenStack.CurrentScreen as Screens.Select.SongSelect; + private Player player => Game.ScreenStack.CurrentScreen as Player; private KeyCounter keyCounter => player.ChildrenOfType().First(); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneEditDefaultSkin.cs b/osu.Game.Tests/Visual/Navigation/TestSceneEditDefaultSkin.cs index 06306ad197..bd75825da2 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneEditDefaultSkin.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneEditDefaultSkin.cs @@ -1,17 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Testing; using osu.Game.Overlays.Settings.Sections; +using osu.Game.Overlays.SkinEditor; using osu.Game.Skinning; -using osu.Game.Skinning.Editor; namespace osu.Game.Tests.Visual.Navigation { - public class TestSceneEditDefaultSkin : OsuGameTestScene + public partial class TestSceneEditDefaultSkin : OsuGameTestScene { private SkinManager skinManager => Game.Dependencies.Get(); private SkinEditorOverlay skinEditor => Game.Dependencies.Get(); @@ -19,7 +21,7 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestEditDefaultSkin() { - AddAssert("is default skin", () => skinManager.CurrentSkinInfo.Value.ID == SkinInfo.DEFAULT_SKIN); + AddAssert("is default skin", () => skinManager.CurrentSkinInfo.Value.ID == SkinInfo.ARGON_SKIN); AddStep("open settings", () => { Game.Settings.Show(); }); @@ -30,7 +32,7 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("open skin editor", () => skinEditor.Show()); // Until step required as the skin editor may take time to load (and an extra scheduled frame for the mutable part). - AddUntilStep("is modified default skin", () => skinManager.CurrentSkinInfo.Value.ID != SkinInfo.DEFAULT_SKIN); + AddUntilStep("is modified default skin", () => skinManager.CurrentSkinInfo.Value.ID != SkinInfo.ARGON_SKIN); AddAssert("is not protected", () => skinManager.CurrentSkinInfo.Value.PerformRead(s => !s.Protected)); AddUntilStep("export button enabled", () => Game.Settings.ChildrenOfType().SingleOrDefault()?.Enabled.Value == true); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneFirstRunGame.cs b/osu.Game.Tests/Visual/Navigation/TestSceneFirstRunGame.cs new file mode 100644 index 0000000000..7f7a81d787 --- /dev/null +++ b/osu.Game.Tests/Visual/Navigation/TestSceneFirstRunGame.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. + +#nullable disable + +using NUnit.Framework; +using osu.Framework.Graphics.Containers; +using osu.Framework.Platform; +using osu.Game.Configuration; +using osu.Game.Online.API; +using osu.Game.Overlays.Notifications; + +namespace osu.Game.Tests.Visual.Navigation +{ + [System.ComponentModel.Description("game with first-run setup overlay")] + public partial class TestSceneFirstRunGame : OsuGameTestScene + { + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddUntilStep("Wait for first-run setup", () => Game.FirstRunOverlay.State.Value == Visibility.Visible); + } + + [Test] + public void TestImportantNotificationDoesntInterruptSetup() + { + AddStep("post important notification", () => Game.Notifications.Post(new SimpleNotification { Text = "Important notification" })); + AddAssert("first-run setup still visible", () => Game.FirstRunOverlay.State.Value == Visibility.Visible); + AddAssert("notification posted", () => Game.Notifications.UnreadCount.Value == 1); + } + + protected override TestOsuGame CreateTestGame() => new FirstRunGame(LocalStorage, API); + + private partial class FirstRunGame : TestOsuGame + { + public FirstRunGame(Storage storage, IAPIProvider api, string[] args = null) + : base(storage, api, args) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + LocalConfig.SetValue(OsuSetting.ShowFirstRunSetup, true); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneInterProcessCommunication.cs b/osu.Game.Tests/Visual/Navigation/TestSceneInterProcessCommunication.cs new file mode 100644 index 0000000000..1ecd38e1d3 --- /dev/null +++ b/osu.Game.Tests/Visual/Navigation/TestSceneInterProcessCommunication.cs @@ -0,0 +1,126 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using System.Linq; +using NUnit.Framework; +using osu.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics.Containers; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.IPC; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Visual.Navigation +{ + [TestFixture] + [Ignore("This test cannot be run headless, as it requires the game host running the nested game to have IPC bound.")] + public partial class TestSceneInterProcessCommunication : OsuGameTestScene + { + private HeadlessGameHost ipcSenderHost = null!; + + private OsuSchemeLinkIPCChannel osuSchemeLinkIPCSender = null!; + private ArchiveImportIPCChannel archiveImportIPCSender = null!; + + private const int requested_beatmap_set_id = 1; + + protected override TestOsuGame CreateTestGame() => new IpcGame(LocalStorage, API); + + [Resolved] + private GameHost gameHost { get; set; } = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + AddStep("set up request handling", () => + { + ((DummyAPIAccess)API).HandleRequest = request => + { + switch (request) + { + case GetBeatmapSetRequest gbr: + + var apiBeatmapSet = CreateAPIBeatmapSet(); + apiBeatmapSet.OnlineID = requested_beatmap_set_id; + apiBeatmapSet.Beatmaps = apiBeatmapSet.Beatmaps.Append(new APIBeatmap + { + DifficultyName = "Target difficulty", + OnlineID = 75, + }).ToArray(); + gbr.TriggerSuccess(apiBeatmapSet); + return true; + } + + return false; + }; + }); + AddStep("create IPC sender channels", () => + { + ipcSenderHost = new HeadlessGameHost(gameHost.Name, new HostOptions { BindIPC = true }); + osuSchemeLinkIPCSender = new OsuSchemeLinkIPCChannel(ipcSenderHost); + archiveImportIPCSender = new ArchiveImportIPCChannel(ipcSenderHost); + }); + } + + [Test] + public void TestOsuSchemeLinkIPCChannel() + { + AddStep("open beatmap via IPC", () => osuSchemeLinkIPCSender.HandleLinkAsync($@"osu://s/{requested_beatmap_set_id}").WaitSafely()); + AddUntilStep("beatmap overlay displayed", () => Game.ChildrenOfType().FirstOrDefault()?.State.Value == Visibility.Visible); + AddUntilStep("beatmap overlay showing content", () => Game.ChildrenOfType().FirstOrDefault()?.Header.BeatmapSet.Value.OnlineID == requested_beatmap_set_id); + } + + [Test] + public void TestArchiveImportLinkIPCChannel() + { + string? beatmapFilepath = null; + + AddStep("import beatmap via IPC", () => archiveImportIPCSender.ImportAsync(beatmapFilepath = TestResources.GetQuickTestBeatmapForImport()).WaitSafely()); + AddUntilStep("import complete notification was presented", () => Game.Notifications.ChildrenOfType().Count(), () => Is.EqualTo(1)); + AddAssert("original file deleted", () => File.Exists(beatmapFilepath), () => Is.False); + } + + public override void TearDownSteps() + { + AddStep("dispose IPC senders", () => + { + osuSchemeLinkIPCSender.Dispose(); + archiveImportIPCSender.Dispose(); + ipcSenderHost.Dispose(); + }); + base.TearDownSteps(); + } + + private partial class IpcGame : TestOsuGame + { + private OsuSchemeLinkIPCChannel? osuSchemeLinkIPCChannel; + private ArchiveImportIPCChannel? archiveImportIPCChannel; + + public IpcGame(Storage storage, IAPIProvider api, string[]? args = null) + : base(storage, api, args) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + osuSchemeLinkIPCChannel = new OsuSchemeLinkIPCChannel(Host, this); + archiveImportIPCChannel = new ArchiveImportIPCChannel(Host, this); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + osuSchemeLinkIPCChannel?.Dispose(); + archiveImportIPCChannel?.Dispose(); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneMouseWheelVolumeAdjust.cs b/osu.Game.Tests/Visual/Navigation/TestSceneMouseWheelVolumeAdjust.cs index 2662b3930c..a89f5fb647 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneMouseWheelVolumeAdjust.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneMouseWheelVolumeAdjust.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Extensions; using osu.Game.Configuration; @@ -10,7 +12,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Navigation { - public class TestSceneMouseWheelVolumeAdjust : OsuGameTestScene + public partial class TestSceneMouseWheelVolumeAdjust : OsuGameTestScene { public override void SetUpSteps() { diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs b/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs index e4871f611e..de303fe074 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using NUnit.Framework; @@ -23,11 +25,12 @@ using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Screens.Menu; using osu.Game.Skinning; +using osuTK.Input; namespace osu.Game.Tests.Visual.Navigation { [TestFixture] - public class TestSceneOsuGame : OsuGameTestScene + public partial class TestSceneOsuGame : OsuGameTestScene { private IReadOnlyList requiredGameDependencies => new[] { @@ -77,6 +80,16 @@ namespace osu.Game.Tests.Visual.Navigation [Resolved] private OsuGameBase gameBase { get; set; } + [Test] + public void TestCursorHidesWhenIdle() + { + AddStep("click mouse", () => InputManager.Click(MouseButton.Left)); + AddUntilStep("wait until idle", () => Game.IsIdle.Value); + AddUntilStep("menu cursor hidden", () => Game.GlobalCursorDisplay.MenuCursor.ActiveCursor.Alpha == 0); + AddStep("click mouse", () => InputManager.Click(MouseButton.Left)); + AddUntilStep("menu cursor shown", () => Game.GlobalCursorDisplay.MenuCursor.ActiveCursor.Alpha == 1); + } + [Test] public void TestNullRulesetHandled() { diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs b/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs index 2ce914ba3d..1c8fa775b9 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs @@ -1,7 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; +using System.Threading; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions; @@ -19,7 +22,7 @@ using static osu.Game.Tests.Visual.Navigation.TestSceneScreenNavigation; namespace osu.Game.Tests.Visual.Navigation { - public class TestScenePerformFromScreen : OsuGameTestScene + public partial class TestScenePerformFromScreen : OsuGameTestScene { private bool actionPerformed; @@ -83,6 +86,19 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert("did perform", () => actionPerformed); } + [Test] + public void TestPerformEnsuresScreenIsLoaded() + { + TestLoadBlockingScreen screen = null; + + AddStep("push blocking screen", () => Game.ScreenStack.Push(screen = new TestLoadBlockingScreen())); + AddStep("perform", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(TestLoadBlockingScreen) })); + AddAssert("action not performed", () => !actionPerformed); + + AddStep("allow load", () => screen.LoadEvent.Set()); + AddUntilStep("action performed", () => actionPerformed); + } + [Test] public void TestOverlaysAlwaysClosed() { @@ -221,7 +237,7 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("beatmap updated", () => Game.Beatmap.Value.BeatmapSetInfo.OnlineID == 241526); } - public class DialogBlockingScreen : OsuScreen + public partial class DialogBlockingScreen : OsuScreen { [Resolved] private IDialogOverlay dialogOverlay { get; set; } @@ -244,7 +260,7 @@ namespace osu.Game.Tests.Visual.Navigation } } - public class TestScreenWithNestedStack : OsuScreen, IHasSubScreenStack + public partial class TestScreenWithNestedStack : OsuScreen, IHasSubScreenStack { public DialogBlockingScreen Blocker { get; private set; } @@ -268,5 +284,16 @@ namespace osu.Game.Tests.Visual.Navigation return base.OnExiting(e); } } + + public partial class TestLoadBlockingScreen : OsuScreen + { + public readonly ManualResetEventSlim LoadEvent = new ManualResetEventSlim(); + + [BackgroundDependencyLoader] + private void load() + { + LoadEvent.Wait(10000); + } + } } } diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs b/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs index 63226de750..c054792168 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs @@ -1,20 +1,26 @@ // 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 NUnit.Framework; using osu.Framework.Screens; +using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Extensions; using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Menu; +using osu.Game.Screens.Select; namespace osu.Game.Tests.Visual.Navigation { - public class TestScenePresentBeatmap : OsuGameTestScene + public partial class TestScenePresentBeatmap : OsuGameTestScene { [Test] public void TestFromMainMenu() @@ -53,6 +59,7 @@ namespace osu.Game.Tests.Visual.Navigation presentAndConfirm(firstImport); var secondImport = importBeatmap(3); + confirmBeatmapInSongSelect(secondImport); presentAndConfirm(secondImport); // Test presenting same beatmap more than once @@ -66,16 +73,51 @@ namespace osu.Game.Tests.Visual.Navigation } [Test] - public void TestFromSongSelectDifferentRuleset() + public void TestFromSongSelectDifferentRulesetWithConvertDisallowed() { - var firstImport = importBeatmap(1); - presentAndConfirm(firstImport); + AddStep("Set converts disallowed", () => Game.LocalConfig.SetValue(OsuSetting.ShowConvertedBeatmaps, false)); - var secondImport = importBeatmap(3, new ManiaRuleset().RulesetInfo); - presentAndConfirm(secondImport); + var osuImport = importBeatmap(1); + presentAndConfirm(osuImport); - presentSecondDifficultyAndConfirm(firstImport, 1); - presentSecondDifficultyAndConfirm(secondImport, 3); + var maniaImport = importBeatmap(2, new ManiaRuleset().RulesetInfo); + confirmBeatmapInSongSelect(maniaImport); + presentAndConfirm(maniaImport); + + var catchImport = importBeatmap(3, new CatchRuleset().RulesetInfo); + confirmBeatmapInSongSelect(catchImport); + presentAndConfirm(catchImport); + + // Ruleset is always changed. + presentSecondDifficultyAndConfirm(maniaImport, 2); + presentSecondDifficultyAndConfirm(osuImport, 1); + presentSecondDifficultyAndConfirm(catchImport, 3); + } + + [Test] + public void TestFromSongSelectDifferentRulesetWithConvertAllowed() + { + AddStep("Set converts allowed", () => Game.LocalConfig.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); + + var osuImport = importBeatmap(1); + presentAndConfirm(osuImport); + + var maniaImport = importBeatmap(2, new ManiaRuleset().RulesetInfo); + confirmBeatmapInSongSelect(maniaImport); + presentAndConfirm(maniaImport); + + var catchImport = importBeatmap(3, new CatchRuleset().RulesetInfo); + confirmBeatmapInSongSelect(catchImport); + presentAndConfirm(catchImport); + + // force ruleset to osu!mania + presentSecondDifficultyAndConfirm(maniaImport, 2); + + // ruleset is not changed as we can convert osu! beatmap. + presentSecondDifficultyAndConfirm(osuImport, 1, expectedRulesetOnlineID: 3); + + // ruleset is changed as we cannot convert. + presentSecondDifficultyAndConfirm(catchImport, 3); } private void returnToMenu() @@ -106,19 +148,19 @@ namespace osu.Game.Tests.Visual.Navigation imported = Game.BeatmapManager.Import(new BeatmapSetInfo { Hash = Guid.NewGuid().ToString(), - OnlineID = i, + OnlineID = i * 1024, Beatmaps = { new BeatmapInfo { - OnlineID = i * 1024, + OnlineID = i * 1024 + 1, Metadata = metadata, Difficulty = new BeatmapDifficulty(), Ruleset = ruleset ?? new OsuRuleset().RulesetInfo }, new BeatmapInfo { - OnlineID = i * 2048, + OnlineID = i * 1024 + 2, Metadata = metadata, Difficulty = new BeatmapDifficulty(), Ruleset = ruleset ?? new OsuRuleset().RulesetInfo @@ -132,23 +174,32 @@ namespace osu.Game.Tests.Visual.Navigation return () => imported; } + private void confirmBeatmapInSongSelect(Func getImport) + { + AddUntilStep("beatmap in song select", () => + { + var songSelect = (Screens.Select.SongSelect)Game.ScreenStack.CurrentScreen; + return songSelect.ChildrenOfType().Single().BeatmapSets.Any(b => b.MatchesOnlineID(getImport())); + }); + } + private void presentAndConfirm(Func getImport) { AddStep("present beatmap", () => Game.PresentBeatmap(getImport())); - AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect); - AddUntilStep("correct beatmap displayed", () => Game.Beatmap.Value.BeatmapSetInfo.MatchesOnlineID(getImport())); - AddAssert("correct ruleset selected", () => Game.Ruleset.Value.Equals(getImport().Beatmaps.First().Ruleset)); + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect songSelect && songSelect.IsLoaded); + AddUntilStep("correct beatmap displayed", () => Game.Beatmap.Value.BeatmapSetInfo.OnlineID, () => Is.EqualTo(getImport().OnlineID)); + AddAssert("correct ruleset selected", () => Game.Ruleset.Value, () => Is.EqualTo(getImport().Beatmaps.First().Ruleset)); } - private void presentSecondDifficultyAndConfirm(Func getImport, int importedID) + private void presentSecondDifficultyAndConfirm(Func getImport, int importedID, int? expectedRulesetOnlineID = null) { - Predicate pred = b => b.OnlineID == importedID * 2048; + Predicate pred = b => b.OnlineID == importedID * 1024 + 2; AddStep("present difficulty", () => Game.PresentBeatmap(getImport(), pred)); - AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect); - AddUntilStep("correct beatmap displayed", () => Game.Beatmap.Value.BeatmapInfo.OnlineID == importedID * 2048); - AddAssert("correct ruleset selected", () => Game.Ruleset.Value.Equals(getImport().Beatmaps.First().Ruleset)); + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect songSelect && songSelect.IsLoaded); + AddUntilStep("correct beatmap displayed", () => Game.Beatmap.Value.BeatmapInfo.OnlineID, () => Is.EqualTo(importedID * 1024 + 2)); + AddAssert("correct ruleset selected", () => Game.Ruleset.Value.OnlineID, () => Is.EqualTo(expectedRulesetOnlineID ?? getImport().Beatmaps.First().Ruleset.OnlineID)); } } } diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs index 6c32171b29..4bcd6b100a 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs @@ -1,14 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Online.API; using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Osu; using osu.Game.Scoring; @@ -20,7 +24,7 @@ using osu.Game.Screens.Select; namespace osu.Game.Tests.Visual.Navigation { - public class TestScenePresentScore : OsuGameTestScene + public partial class TestScenePresentScore : OsuGameTestScene { private BeatmapSetInfo beatmap; @@ -90,6 +94,31 @@ namespace osu.Game.Tests.Visual.Navigation returnToMenu(); } + [Test] + public void TestFromSongSelectWithFilter([Values] ScorePresentType type) + { + AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo.Invoke()); + AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded); + + AddStep("filter to nothing", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).FilterControl.CurrentTextSearch.Value = "fdsajkl;fgewq"); + AddUntilStep("wait for no results", () => Beatmap.IsDefault); + + var firstImport = importScore(1, new CatchRuleset().RulesetInfo); + presentAndConfirm(firstImport, type); + } + + [Test] + public void TestFromSongSelectWithConvertRulesetChange([Values] ScorePresentType type) + { + AddStep("enter song select", () => Game.ChildrenOfType().Single().OnSolo.Invoke()); + AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded); + + AddStep("set convert to false", () => Game.LocalConfig.SetValue(OsuSetting.ShowConvertedBeatmaps, false)); + + var firstImport = importScore(1, new CatchRuleset().RulesetInfo); + presentAndConfirm(firstImport, type); + } + [Test] public void TestFromSongSelect([Values] ScorePresentType type) { diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 51bb27f93e..0d081e8138 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -17,6 +19,7 @@ using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Online.Leaderboards; using osu.Game.Overlays; +using osu.Game.Overlays.BeatmapListing; using osu.Game.Overlays.Mods; using osu.Game.Overlays.Toolbar; using osu.Game.Rulesets.Mods; @@ -24,6 +27,8 @@ using osu.Game.Rulesets.Osu.Mods; using osu.Game.Scoring; using osu.Game.Screens.Menu; using osu.Game.Screens.OnlinePlay.Lounge; +using osu.Game.Screens.OnlinePlay.Match.Components; +using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; using osu.Game.Screens.Select; @@ -35,7 +40,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Navigation { - public class TestSceneScreenNavigation : OsuGameTestScene + public partial class TestSceneScreenNavigation : OsuGameTestScene { private const float click_padding = 25; @@ -43,6 +48,75 @@ namespace osu.Game.Tests.Visual.Navigation private Vector2 optionsButtonPosition => Game.ToScreenSpace(new Vector2(click_padding, click_padding)); + [TestCase(false)] + [TestCase(true)] + public void TestConfirmationRequiredToDiscardPlaylist(bool withPlaylistItemAdded) + { + Screens.OnlinePlay.Playlists.Playlists playlistScreen = null; + + AddUntilStep("wait for dialog overlay", () => Game.ChildrenOfType().SingleOrDefault() != null); + + PushAndConfirm(() => playlistScreen = new Screens.OnlinePlay.Playlists.Playlists()); + + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + + AddStep("open create screen", () => + { + InputManager.MoveMouseTo(playlistScreen.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + if (withPlaylistItemAdded) + { + AddUntilStep("wait for settings displayed", + () => (playlistScreen.CurrentSubScreen as PlaylistsRoomSubScreen)?.ChildrenOfType().SingleOrDefault()?.State.Value == Visibility.Visible); + + AddStep("edit playlist", () => InputManager.Key(Key.Enter)); + + AddUntilStep("wait for song select", () => (playlistScreen.CurrentSubScreen as PlaylistsSongSelect)?.BeatmapSetsLoaded == true); + + AddUntilStep("wait for selection", () => !Game.Beatmap.IsDefault); + + AddStep("add item", () => InputManager.Key(Key.Enter)); + + AddUntilStep("wait for return to playlist screen", () => playlistScreen.CurrentSubScreen is PlaylistsRoomSubScreen); + + AddStep("go back to song select", () => + { + InputManager.MoveMouseTo(playlistScreen.ChildrenOfType().Single(b => b.Text == "Edit playlist")); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("wait for song select", () => (playlistScreen.CurrentSubScreen as PlaylistsSongSelect)?.BeatmapSetsLoaded == true); + + AddStep("press home button", () => + { + InputManager.MoveMouseTo(Game.Toolbar.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("confirmation dialog shown", () => Game.ChildrenOfType().Single().CurrentDialog is not null); + + pushEscape(); + pushEscape(); + + AddAssert("confirmation dialog shown", () => Game.ChildrenOfType().Single().CurrentDialog is not null); + + AddStep("confirm exit", () => InputManager.Key(Key.Enter)); + + AddAssert("dialog dismissed", () => Game.ChildrenOfType().Single().CurrentDialog == null); + + exitViaEscapeAndConfirm(); + } + else + { + pushEscape(); + AddAssert("confirmation dialog not shown", () => Game.ChildrenOfType().Single().CurrentDialog == null); + + exitViaEscapeAndConfirm(); + } + } + [Test] public void TestExitSongSelectWithEscape() { @@ -72,14 +146,14 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("set filter again", () => songSelect.ChildrenOfType().Single().Current.Value = "test"); AddStep("open collections dropdown", () => { - InputManager.MoveMouseTo(songSelect.ChildrenOfType().Single()); + InputManager.MoveMouseTo(songSelect.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); AddStep("press back once", () => InputManager.Click(MouseButton.Button1)); AddAssert("still at song select", () => Game.ScreenStack.CurrentScreen == songSelect); AddAssert("collections dropdown closed", () => songSelect - .ChildrenOfType().Single() + .ChildrenOfType().Single() .ChildrenOfType.DropdownMenu>().Single().State == MenuState.Closed); AddStep("press back a second time", () => InputManager.Click(MouseButton.Button1)); @@ -121,11 +195,17 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("wait for player", () => { DismissAnyNotifications(); - return (player = Game.ScreenStack.CurrentScreen as Player) != null; + player = Game.ScreenStack.CurrentScreen as Player; + return player?.IsLoaded == true; }); AddAssert("retry count is 0", () => player.RestartCount == 0); + // todo: see https://github.com/ppy/osu/issues/22220 + // tests are supposed to be immune to this edge case by the logic in TestPlayer, + // but we're running a full game instance here, so we have to work around it manually. + AddStep("end spectator before retry", () => Game.SpectatorClient.EndPlaying(player.GameplayState)); + AddStep("attempt to retry", () => player.ChildrenOfType().First().Action()); AddUntilStep("wait for old player gone", () => Game.ScreenStack.CurrentScreen != player); @@ -382,6 +462,8 @@ namespace osu.Game.Tests.Visual.Navigation { AddUntilStep("Wait for toolbar to load", () => Game.Toolbar.IsLoaded); + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + TestPlaySongSelect songSelect = null; PushAndConfirm(() => songSelect = new TestPlaySongSelect()); @@ -459,6 +541,74 @@ namespace osu.Game.Tests.Visual.Navigation AddWaitStep("wait two frames", 2); } + [Test] + public void TestFeaturedArtistDisclaimerDialog() + { + BeatmapListingOverlay getBeatmapListingOverlay() => Game.ChildrenOfType().FirstOrDefault(); + + AddStep("Wait for notifications to load", () => Game.SearchBeatmapSet(string.Empty)); + AddUntilStep("wait for dialog overlay", () => Game.ChildrenOfType().SingleOrDefault() != null); + + AddUntilStep("Wait for beatmap overlay to load", () => getBeatmapListingOverlay()?.State.Value == Visibility.Visible); + AddAssert("featured artist filter is on", () => getBeatmapListingOverlay().ChildrenOfType().First().Current.Contains(SearchGeneral.FeaturedArtists)); + AddStep("toggle featured artist filter", + () => getBeatmapListingOverlay().ChildrenOfType>().First(i => i.Value == SearchGeneral.FeaturedArtists).TriggerClick()); + + AddAssert("disclaimer dialog is shown", () => Game.ChildrenOfType().Single().CurrentDialog != null); + AddAssert("featured artist filter is still on", () => getBeatmapListingOverlay().ChildrenOfType().First().Current.Contains(SearchGeneral.FeaturedArtists)); + + AddStep("confirm", () => InputManager.Key(Key.Enter)); + AddAssert("dialog dismissed", () => Game.ChildrenOfType().Single().CurrentDialog == null); + + AddUntilStep("featured artist filter is off", () => !getBeatmapListingOverlay().ChildrenOfType().First().Current.Contains(SearchGeneral.FeaturedArtists)); + } + + [Test] + public void TestBeatmapListingLinkSearchOnInitialOpen() + { + BeatmapListingOverlay getBeatmapListingOverlay() => Game.ChildrenOfType().FirstOrDefault(); + + AddStep("open beatmap overlay with test query", () => Game.SearchBeatmapSet("test")); + + AddUntilStep("wait for beatmap overlay to load", () => getBeatmapListingOverlay()?.State.Value == Visibility.Visible); + + AddAssert("beatmap overlay sorted by relevance", () => getBeatmapListingOverlay().ChildrenOfType().Single().Current.Value == SortCriteria.Relevance); + } + + [Test] + public void TestMainOverlaysClosesNotificationOverlay() + { + ChangelogOverlay getChangelogOverlay() => Game.ChildrenOfType().FirstOrDefault(); + + AddUntilStep("Wait for notifications to load", () => Game.Notifications.IsLoaded); + AddStep("Show notifications", () => Game.Notifications.Show()); + AddUntilStep("wait for notifications shown", () => Game.Notifications.IsPresent && Game.Notifications.State.Value == Visibility.Visible); + AddStep("Show changelog listing", () => Game.ShowChangelogListing()); + AddUntilStep("wait for changelog shown", () => getChangelogOverlay()?.IsPresent == true && getChangelogOverlay()?.State.Value == Visibility.Visible); + AddAssert("Notifications is hidden", () => Game.Notifications.State.Value == Visibility.Hidden); + + AddStep("Show notifications", () => Game.Notifications.Show()); + AddUntilStep("wait for notifications shown", () => Game.Notifications.State.Value == Visibility.Visible); + AddUntilStep("changelog still visible", () => getChangelogOverlay().State.Value == Visibility.Visible); + } + + [Test] + public void TestMainOverlaysClosesSettingsOverlay() + { + ChangelogOverlay getChangelogOverlay() => Game.ChildrenOfType().FirstOrDefault(); + + AddUntilStep("Wait for settings to load", () => Game.Settings.IsLoaded); + AddStep("Show settings", () => Game.Settings.Show()); + AddUntilStep("wait for settings shown", () => Game.Settings.IsPresent && Game.Settings.State.Value == Visibility.Visible); + AddStep("Show changelog listing", () => Game.ShowChangelogListing()); + AddUntilStep("wait for changelog shown", () => getChangelogOverlay()?.IsPresent == true && getChangelogOverlay()?.State.Value == Visibility.Visible); + AddAssert("Settings is hidden", () => Game.Settings.State.Value == Visibility.Hidden); + + AddStep("Show settings", () => Game.Settings.Show()); + AddUntilStep("wait for settings shown", () => Game.Settings.State.Value == Visibility.Visible); + AddUntilStep("changelog still visible", () => getChangelogOverlay().State.Value == Visibility.Visible); + } + [Test] public void TestOverlayClosing() { @@ -604,7 +754,7 @@ namespace osu.Game.Tests.Visual.Navigation ConfirmAtMainMenu(); } - public class TestPlaySongSelect : PlaySongSelect + public partial class TestPlaySongSelect : PlaySongSelect { public ModSelectOverlay ModSelectOverlay => ModSelect; diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs index 68c44f49cc..bedb2ceaa1 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs @@ -1,52 +1,35 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Extensions; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Screens; using osu.Framework.Testing; +using osu.Framework.Threading; using osu.Game.Overlays.Settings; +using osu.Game.Overlays.SkinEditor; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD.HitErrorMeters; -using osu.Game.Skinning.Editor; using osu.Game.Tests.Beatmaps.IO; +using osuTK; using osuTK.Input; using static osu.Game.Tests.Visual.Navigation.TestSceneScreenNavigation; namespace osu.Game.Tests.Visual.Navigation { - public class TestSceneSkinEditorNavigation : OsuGameTestScene + public partial class TestSceneSkinEditorNavigation : OsuGameTestScene { private TestPlaySongSelect songSelect; private SkinEditor skinEditor => Game.ChildrenOfType().FirstOrDefault(); - private void advanceToSongSelect() - { - PushAndConfirm(() => songSelect = new TestPlaySongSelect()); - AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); - - AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); - - AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); - } - - private void openSkinEditor() - { - AddStep("open skin editor", () => - { - InputManager.PressKey(Key.ControlLeft); - InputManager.PressKey(Key.ShiftLeft); - InputManager.Key(Key.S); - InputManager.ReleaseKey(Key.ControlLeft); - InputManager.ReleaseKey(Key.ShiftLeft); - }); - AddUntilStep("skin editor loaded", () => skinEditor != null); - } - [Test] public void TestEditComponentDuringGameplay() { @@ -86,6 +69,68 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert("value is less than default", () => hitErrorMeter.JudgementLineThickness.Value < hitErrorMeter.JudgementLineThickness.Default); } + [Test] + public void TestComponentsDeselectedOnSkinEditorHide() + { + advanceToSongSelect(); + openSkinEditor(); + switchToGameplayScene(); + + AddUntilStep("wait for components", () => skinEditor.ChildrenOfType().Any()); + + AddStep("select all components", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.A); + InputManager.ReleaseKey(Key.ControlLeft); + }); + + AddUntilStep("components selected", () => skinEditor.SelectedComponents.Count > 0); + + toggleSkinEditor(); + + AddUntilStep("no components selected", () => skinEditor.SelectedComponents.Count == 0); + } + + [Test] + public void TestSwitchScreenWhileDraggingComponent() + { + Vector2 firstBlueprintCentre = Vector2.Zero; + ScheduledDelegate movementDelegate = null; + + advanceToSongSelect(); + + openSkinEditor(); + + AddStep("add skinnable component", () => + { + skinEditor.ChildrenOfType().First().TriggerClick(); + }); + + AddUntilStep("newly added component selected", () => skinEditor.SelectedComponents.Count == 1); + + AddStep("start drag", () => + { + firstBlueprintCentre = skinEditor.ChildrenOfType().First().ScreenSpaceDrawQuad.Centre; + + InputManager.MoveMouseTo(firstBlueprintCentre); + InputManager.PressButton(MouseButton.Left); + }); + + AddStep("start movement", () => movementDelegate = Scheduler.AddDelayed(() => { InputManager.MoveMouseTo(firstBlueprintCentre += new Vector2(1)); }, 10, true)); + + toggleSkinEditor(); + AddStep("exit song select", () => songSelect.Exit()); + + AddUntilStep("wait for blueprints removed", () => !skinEditor.ChildrenOfType().Any()); + + AddStep("stop drag", () => + { + InputManager.ReleaseButton(MouseButton.Left); + movementDelegate?.Cancel(); + }); + } + [Test] public void TestAutoplayCompatibleModsRetainedOnEnteringGameplay() { @@ -144,9 +189,67 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("mod overlay closed", () => songSelect.ModSelectOverlay.State.Value == Visibility.Hidden); } + [Test] + public void TestChangeToNonSkinnableScreen() + { + advanceToSongSelect(); + openSkinEditor(); + AddAssert("blueprint container present", () => skinEditor.ChildrenOfType().Count(), () => Is.EqualTo(1)); + AddAssert("placeholder not present", () => skinEditor.ChildrenOfType().Count(), () => Is.Zero); + AddAssert("editor sidebars not empty", () => skinEditor.ChildrenOfType().SelectMany(sidebar => sidebar.Children).Count(), () => Is.GreaterThan(0)); + + AddStep("add skinnable component", () => + { + skinEditor.ChildrenOfType().First().TriggerClick(); + }); + AddUntilStep("newly added component selected", () => skinEditor.SelectedComponents, () => Has.Count.EqualTo(1)); + + AddStep("exit to main menu", () => Game.ScreenStack.CurrentScreen.Exit()); + AddAssert("selection cleared", () => skinEditor.SelectedComponents, () => Has.Count.Zero); + AddAssert("blueprint container not present", () => skinEditor.ChildrenOfType().Count(), () => Is.Zero); + AddAssert("placeholder present", () => skinEditor.ChildrenOfType().Count(), () => Is.EqualTo(1)); + AddAssert("editor sidebars empty", () => skinEditor.ChildrenOfType().SelectMany(sidebar => sidebar.Children).Count(), () => Is.Zero); + + advanceToSongSelect(); + AddAssert("blueprint container present", () => skinEditor.ChildrenOfType().Count(), () => Is.EqualTo(1)); + AddAssert("placeholder not present", () => skinEditor.ChildrenOfType().Count(), () => Is.Zero); + AddAssert("editor sidebars not empty", () => skinEditor.ChildrenOfType().SelectMany(sidebar => sidebar.Children).Count(), () => Is.GreaterThan(0)); + } + + private void advanceToSongSelect() + { + PushAndConfirm(() => songSelect = new TestPlaySongSelect()); + AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + } + + private void openSkinEditor() + { + toggleSkinEditor(); + AddUntilStep("skin editor loaded", () => skinEditor != null); + } + + private void toggleSkinEditor() + { + AddStep("toggle skin editor", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.S); + InputManager.ReleaseKey(Key.ControlLeft); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + } + private void switchToGameplayScene() { - AddStep("Click gameplay scene button", () => skinEditor.ChildrenOfType().First(b => b.Text == "Gameplay").TriggerClick()); + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + + AddStep("Click gameplay scene button", () => + { + InputManager.MoveMouseTo(skinEditor.ChildrenOfType().First(b => b.Text.ToString() == "Gameplay")); + InputManager.Click(MouseButton.Left); + }); AddUntilStep("wait for player", () => { diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneStartupBeatmapDisplay.cs b/osu.Game.Tests/Visual/Navigation/TestSceneStartupBeatmapDisplay.cs index 961b7dedc3..25cef8440a 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneStartupBeatmapDisplay.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneStartupBeatmapDisplay.cs @@ -13,7 +13,7 @@ using osu.Game.Overlays.BeatmapSet; namespace osu.Game.Tests.Visual.Navigation { - public class TestSceneStartupBeatmapDisplay : OsuGameTestScene + public partial class TestSceneStartupBeatmapDisplay : OsuGameTestScene { private const int requested_beatmap_id = 75; private const int requested_beatmap_set_id = 1; @@ -48,7 +48,7 @@ namespace osu.Game.Tests.Visual.Navigation public void TestBeatmapLink() { AddUntilStep("Beatmap overlay displayed", () => Game.ChildrenOfType().FirstOrDefault()?.State.Value == Visibility.Visible); - AddUntilStep("Beatmap overlay showing content", () => Game.ChildrenOfType().FirstOrDefault()?.Beatmap.Value.OnlineID == requested_beatmap_id); + AddUntilStep("Beatmap overlay showing content", () => Game.ChildrenOfType().FirstOrDefault()?.Beatmap.Value?.OnlineID == requested_beatmap_id); } } } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneStartupBeatmapSetDisplay.cs b/osu.Game.Tests/Visual/Navigation/TestSceneStartupBeatmapSetDisplay.cs index 1aa56896d3..f885c2f44c 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneStartupBeatmapSetDisplay.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneStartupBeatmapSetDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Graphics.Containers; @@ -12,7 +14,7 @@ using osu.Game.Overlays; namespace osu.Game.Tests.Visual.Navigation { - public class TestSceneStartupBeatmapSetDisplay : OsuGameTestScene + public partial class TestSceneStartupBeatmapSetDisplay : OsuGameTestScene { private const int requested_beatmap_set_id = 1; diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.cs b/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.cs index bd723eeed6..e795166d3e 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.cs @@ -9,9 +9,9 @@ using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.Navigation { - public class TestSceneStartupImport : OsuGameTestScene + public partial class TestSceneStartupImport : OsuGameTestScene { - private string importFilename; + private string? importFilename; protected override TestOsuGame CreateTestGame() => new TestOsuGame(LocalStorage, API, new[] { importFilename }); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneStartupRuleset.cs b/osu.Game.Tests/Visual/Navigation/TestSceneStartupRuleset.cs index 85dd501fd3..621dabe869 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneStartupRuleset.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneStartupRuleset.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Development; using osu.Game.Configuration; @@ -8,7 +10,7 @@ using osu.Game.Configuration; namespace osu.Game.Tests.Visual.Navigation { [TestFixture] - public class TestSceneStartupRuleset : OsuGameTestScene + public partial class TestSceneStartupRuleset : OsuGameTestScene { protected override TestOsuGame CreateTestGame() { diff --git a/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs b/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs index 7e3d8290be..c32aa7f5f9 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Utils; using osu.Game.Configuration; namespace osu.Game.Tests.Visual.Navigation { - public class TestSettingsMigration : OsuGameTestScene + public partial class TestSettingsMigration : OsuGameTestScene { public override void RecycleLocalStorage(bool isDisposing) { @@ -32,7 +34,7 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("force save config", () => Game.LocalConfig.Save()); - AddStep("remove game", () => Remove(Game)); + AddStep("remove game", () => Remove(Game, true)); AddStep("create game again", CreateGame); diff --git a/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs index 8f0da8d182..0f920643f0 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -16,7 +18,7 @@ using osu.Game.Users; namespace osu.Game.Tests.Visual.Online { - public class TestSceneAccountCreationOverlay : OsuTestScene + public partial class TestSceneAccountCreationOverlay : OsuTestScene { private readonly Container userPanelArea; private readonly AccountCreationOverlay accountCreation; diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapAvailability.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapAvailability.cs index f94ae24a14..36f8d2d9bd 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapAvailability.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapAvailability.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Online.API.Requests.Responses; @@ -9,7 +11,7 @@ using osu.Game.Overlays.BeatmapSet; namespace osu.Game.Tests.Visual.Online { [TestFixture] - public class TestSceneBeatmapAvailability : OsuTestScene + public partial class TestSceneBeatmapAvailability : OsuTestScene { private readonly BeatmapAvailability container; diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapDownloadButton.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapDownloadButton.cs index 8d8879490d..40e34a52b6 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapDownloadButton.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapDownloadButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -14,7 +16,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Online { - public class TestSceneBeatmapDownloadButton : OsuTestScene + public partial class TestSceneBeatmapDownloadButton : OsuTestScene { private TestDownloadButton downloadButton; @@ -137,7 +139,7 @@ namespace osu.Game.Tests.Visual.Online return apiBeatmapSet; } - private class TestDownloadButton : BeatmapDownloadButton + private partial class TestDownloadButton : BeatmapDownloadButton { public new bool DownloadEnabled => base.DownloadEnabled; diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs index 5999125013..5e49cb633e 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -23,7 +25,7 @@ using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Tests.Visual.Online { - public class TestSceneBeatmapListingOverlay : OsuManualInputManagerTestScene + public partial class TestSceneBeatmapListingOverlay : OsuManualInputManagerTestScene { private readonly List setsForResponse = new List(); @@ -33,6 +35,8 @@ namespace osu.Game.Tests.Visual.Online private OsuConfigManager localConfig; + private bool returnCursorOnResponse; + [BackgroundDependencyLoader] private void load() { @@ -59,6 +63,7 @@ namespace osu.Game.Tests.Visual.Online searchBeatmapSetsRequest.TriggerSuccess(new SearchBeatmapSetsResponse { BeatmapSets = setsForResponse, + Cursor = returnCursorOnResponse ? new Cursor() : null, }); return true; @@ -75,6 +80,15 @@ namespace osu.Game.Tests.Visual.Online AddStep("reset size", () => localConfig.SetValue(OsuSetting.BeatmapListingCardSize, BeatmapCardSize.Normal)); } + [Test] + public void TestFeaturedArtistFilter() + { + AddAssert("is visible", () => overlay.State.Value == Visibility.Visible); + AddAssert("featured artist filter is on", () => overlay.ChildrenOfType().First().Current.Contains(SearchGeneral.FeaturedArtists)); + AddStep("toggle featured artist filter", () => overlay.ChildrenOfType>().First(i => i.Value == SearchGeneral.FeaturedArtists).TriggerClick()); + AddAssert("featured artist filter is off", () => !overlay.ChildrenOfType().First().Current.Contains(SearchGeneral.FeaturedArtists)); + } + [Test] public void TestHideViaBack() { @@ -104,7 +118,7 @@ namespace osu.Game.Tests.Visual.Online { AddAssert("is visible", () => overlay.State.Value == Visibility.Visible); - AddStep("show many results", () => fetchFor(Enumerable.Repeat(CreateAPIBeatmapSet(Ruleset.Value), 100).ToArray())); + AddStep("show many results", () => fetchFor(getManyBeatmaps(100).ToArray())); AddUntilStep("placeholder hidden", () => !overlay.ChildrenOfType().Any(d => d.IsPresent)); @@ -125,10 +139,10 @@ namespace osu.Game.Tests.Visual.Online { AddAssert("is visible", () => overlay.State.Value == Visibility.Visible); - AddStep("show many results", () => fetchFor(Enumerable.Repeat(CreateAPIBeatmapSet(Ruleset.Value), 100).ToArray())); + AddStep("show many results", () => fetchFor(getManyBeatmaps(100).ToArray())); assertAllCardsOfType(100); - AddStep("show more results", () => fetchFor(Enumerable.Repeat(CreateAPIBeatmapSet(Ruleset.Value), 30).ToArray())); + AddStep("show more results", () => fetchFor(getManyBeatmaps(30).ToArray())); assertAllCardsOfType(30); } @@ -137,7 +151,7 @@ namespace osu.Game.Tests.Visual.Online { AddAssert("is visible", () => overlay.State.Value == Visibility.Visible); - AddStep("show many results", () => fetchFor(Enumerable.Repeat(CreateAPIBeatmapSet(Ruleset.Value), 100).ToArray())); + AddStep("show many results", () => fetchFor(getManyBeatmaps(100).ToArray())); assertAllCardsOfType(100); setCardSize(BeatmapCardSize.Extra, viaConfig); @@ -159,7 +173,7 @@ namespace osu.Game.Tests.Visual.Online AddStep("fetch for 0 beatmaps", () => fetchFor()); placeholderShown(); - AddStep("show many results", () => fetchFor(Enumerable.Repeat(CreateAPIBeatmapSet(Ruleset.Value), 100).ToArray())); + AddStep("show many results", () => fetchFor(getManyBeatmaps(100).ToArray())); AddUntilStep("wait for loaded", () => this.ChildrenOfType().Count() == 100); AddUntilStep("placeholder hidden", () => !overlay.ChildrenOfType().Any(d => d.IsPresent)); @@ -178,6 +192,32 @@ namespace osu.Game.Tests.Visual.Online }); } + /// + /// During pagination, the first beatmap of the second page may be a duplicate of the last beatmap from the previous page. + /// This is currently the case with osu!web API due to ES relevance score's presence in the response cursor. + /// See: https://github.com/ppy/osu-web/issues/9270 + /// + [Test] + public void TestDuplicatedBeatmapOnlyShowsOnce() + { + APIBeatmapSet beatmapSet = null; + + AddStep("show many results", () => + { + beatmapSet = CreateAPIBeatmapSet(Ruleset.Value); + beatmapSet.Title = "last beatmap of first page"; + + fetchFor(getManyBeatmaps(49).Append(new APIBeatmapSet { Title = "last beatmap of first page", OnlineID = beatmapSet.OnlineID }).ToArray(), true); + }); + AddUntilStep("wait for loaded", () => this.ChildrenOfType().Count() == 50); + + AddStep("set next page", () => setSearchResponse(getManyBeatmaps(49).Prepend(new APIBeatmapSet { Title = "this shouldn't show up", OnlineID = beatmapSet.OnlineID }).ToArray(), false)); + AddStep("scroll to end", () => overlay.ChildrenOfType().Single().ScrollToEnd()); + AddUntilStep("wait for loaded", () => this.ChildrenOfType().Count() >= 99); + + AddAssert("beatmap not duplicated", () => overlay.ChildrenOfType().Count(c => c.BeatmapSet.Equals(beatmapSet)) == 1); + } + [Test] public void TestUserWithoutSupporterUsesSupporterOnlyFiltersWithoutResults() { @@ -334,15 +374,25 @@ namespace osu.Game.Tests.Visual.Online private static int searchCount; - private void fetchFor(params APIBeatmapSet[] beatmaps) + private APIBeatmapSet[] getManyBeatmaps(int count) => Enumerable.Range(0, count).Select(_ => CreateAPIBeatmapSet(Ruleset.Value)).ToArray(); + + private void fetchFor(params APIBeatmapSet[] beatmaps) => fetchFor(beatmaps, false); + + private void fetchFor(APIBeatmapSet[] beatmaps, bool hasNextPage) { - setsForResponse.Clear(); - setsForResponse.AddRange(beatmaps); + setSearchResponse(beatmaps, hasNextPage); // trigger arbitrary change for fetching. searchControl.Query.Value = $"search {searchCount++}"; } + private void setSearchResponse(APIBeatmapSet[] beatmaps, bool hasNextPage) + { + setsForResponse.Clear(); + setsForResponse.AddRange(beatmaps); + returnCursorOnResponse = hasNextPage; + } + private void setRankAchievedFilter(ScoreRank[] ranks) { AddStep($"set Rank Achieved filter to [{string.Join(',', ranks)}]", () => diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapRulesetSelector.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapRulesetSelector.cs index c550c9afda..36c3576da6 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapRulesetSelector.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapRulesetSelector.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -12,7 +14,7 @@ using osu.Game.Overlays.BeatmapSet; namespace osu.Game.Tests.Visual.Online { - public class TestSceneBeatmapRulesetSelector : OsuTestScene + public partial class TestSceneBeatmapRulesetSelector : OsuTestScene { [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs index 9d206af40e..a27c4ddad2 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Game.Beatmaps; @@ -12,19 +14,23 @@ 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; using osu.Game.Overlays.BeatmapSet.Scores; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.Select.Details; using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Tests.Visual.Online { - [TestFixture] - public class TestSceneBeatmapSetOverlay : OsuTestScene + public partial class TestSceneBeatmapSetOverlay : OsuManualInputManagerTestScene { private readonly TestBeatmapSetOverlay overlay; - protected override bool UseOnlineAPI => true; - private int nextBeatmapSetId = 1; public TestSceneBeatmapSetOverlay() @@ -35,18 +41,15 @@ namespace osu.Game.Tests.Visual.Online [Resolved] private IRulesetStore rulesets { get; set; } + [SetUp] + public void SetUp() => Schedule(() => SelectedMods.Value = Array.Empty()); + [Test] public void TestLoading() { AddStep(@"show loading", () => overlay.ShowBeatmapSet(null)); } - [Test] - public void TestOnline() - { - AddStep(@"show online", () => overlay.FetchAndShowBeatmapSet(55)); - } - [Test] public void TestLocalBeatmaps() { @@ -54,6 +57,8 @@ namespace osu.Game.Tests.Visual.Online { overlay.ShowBeatmapSet(new APIBeatmapSet { + Genre = new BeatmapSetOnlineGenre { Id = 15, Name = "Future genre" }, + Language = new BeatmapSetOnlineLanguage { Id = 15, Name = "Future language" }, OnlineID = 1235, Title = @"an awesome beatmap", Artist = @"naru narusegawa", @@ -107,6 +112,7 @@ namespace osu.Game.Tests.Visual.Online AddAssert("status is loved", () => overlay.ChildrenOfType().Single().Status == BeatmapOnlineStatus.Loved); AddAssert("scores container is visible", () => overlay.ChildrenOfType().Single().Alpha == 1); + AddAssert("mod selector is visible", () => overlay.ChildrenOfType().Single().Alpha == 1); AddStep("go to second beatmap", () => overlay.ChildrenOfType().ElementAt(1).TriggerClick()); @@ -211,6 +217,21 @@ namespace osu.Game.Tests.Visual.Online }); } + [Test] + public void TestSelectedModsDontAffectStatistics() + { + AddStep("show map", () => overlay.ShowBeatmapSet(getBeatmapSet())); + AddAssert("AR displayed as 0", () => overlay.ChildrenOfType().Single(s => s.Title == BeatmapsetsStrings.ShowStatsAr).Value == (0, null)); + AddStep("set AR10 diff adjust", () => SelectedMods.Value = new[] + { + new OsuModDifficultyAdjust + { + ApproachRate = { Value = 10 } + } + }); + AddAssert("AR still displayed as 0", () => overlay.ChildrenOfType().Single(s => s.Title == BeatmapsetsStrings.ShowStatsAr).Value == (0, null)); + } + [Test] public void TestHide() { @@ -223,6 +244,60 @@ namespace osu.Game.Tests.Visual.Online AddStep(@"show without reload", overlay.Show); } + [TestCase(BeatmapSetLookupType.BeatmapId)] + [TestCase(BeatmapSetLookupType.SetId)] + public void TestFetchLookupType(BeatmapSetLookupType lookupType) + { + string type = string.Empty; + + AddStep("register request handling", () => + { + ((DummyAPIAccess)API).HandleRequest = req => + { + switch (req) + { + case GetBeatmapSetRequest getBeatmapSet: + type = getBeatmapSet.Type.ToString(); + return true; + } + + return false; + }; + }); + + AddStep(@"fetch", () => + { + switch (lookupType) + { + case BeatmapSetLookupType.BeatmapId: + overlay.FetchAndShowBeatmap(55); + break; + + case BeatmapSetLookupType.SetId: + overlay.FetchAndShowBeatmapSet(55); + break; + } + }); + + 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(); @@ -262,12 +337,66 @@ 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); } - private class TestBeatmapSetOverlay : BeatmapSetOverlay + private partial class TestBeatmapSetOverlay : BeatmapSetOverlay { public new BeatmapSetHeader Header => base.Header; } diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs index 629c413d85..69c9faa9d3 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -14,7 +16,7 @@ using osu.Game.Screens.Select.Details; namespace osu.Game.Tests.Visual.Online { - public class TestSceneBeatmapSetOverlayDetails : OsuTestScene + public partial class TestSceneBeatmapSetOverlayDetails : OsuTestScene { private RatingsExposingDetails details; @@ -61,7 +63,7 @@ namespace osu.Game.Tests.Visual.Online }; } - private class RatingsExposingDetails : Details + private partial class RatingsExposingDetails : Details { public new UserRatings Ratings => base.Ratings; } diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs index 82b34c50c2..59c96ec719 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -20,7 +22,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Online { - public class TestSceneBeatmapSetOverlaySuccessRate : OsuTestScene + public partial class TestSceneBeatmapSetOverlaySuccessRate : OsuTestScene { private GraphExposingSuccessRate successRate; @@ -102,7 +104,7 @@ namespace osu.Game.Tests.Visual.Online AddAssert("graph max values correct", () => successRate.ChildrenOfType().All(graph => graph.MaxValue == 0)); } - private class GraphExposingSuccessRate : SuccessRate + private partial class GraphExposingSuccessRate : SuccessRate { public new FailRetryGraph Graph => base.Graph; } diff --git a/osu.Game.Tests/Visual/Online/TestSceneBundledBeatmapDownloader.cs b/osu.Game.Tests/Visual/Online/TestSceneBundledBeatmapDownloader.cs index 2af1c9a0f0..0f2786f9ef 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBundledBeatmapDownloader.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBundledBeatmapDownloader.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Beatmaps.Drawables; namespace osu.Game.Tests.Visual.Online { [Ignore("Only for visual testing")] - public class TestSceneBundledBeatmapDownloader : OsuTestScene + public partial class TestSceneBundledBeatmapDownloader : OsuTestScene { private BundledBeatmapDownloader downloader; diff --git a/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs index 8f000afb91..8d61c5df9f 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -16,7 +18,7 @@ using osu.Game.Overlays.Changelog; namespace osu.Game.Tests.Visual.Online { [TestFixture] - public class TestSceneChangelogOverlay : OsuTestScene + public partial class TestSceneChangelogOverlay : OsuTestScene { private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; @@ -106,6 +108,7 @@ namespace osu.Game.Tests.Visual.Online Version = "2018.712.0", DisplayVersion = "2018.712.0", UpdateStream = streams[OsuGameBase.CLIENT_STREAM_NAME], + CreatedAt = new DateTime(2018, 7, 12), ChangelogEntries = new List { new APIChangelogEntry @@ -169,6 +172,7 @@ namespace osu.Game.Tests.Visual.Online { Version = "2019.920.0", DisplayVersion = "2019.920.0", + CreatedAt = new DateTime(2019, 9, 20), UpdateStream = new APIUpdateStream { Name = "Test", @@ -197,7 +201,7 @@ namespace osu.Game.Tests.Visual.Online AddStep("show build", () => changelog.ShowBuild(requestedBuild)); } - private class TestChangelogOverlay : ChangelogOverlay + private partial class TestChangelogOverlay : ChangelogOverlay { public new List Streams => base.Streams; diff --git a/osu.Game.Tests/Visual/Online/TestSceneChangelogSupporterPromo.cs b/osu.Game.Tests/Visual/Online/TestSceneChangelogSupporterPromo.cs index 22220a7d9c..96996db940 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChangelogSupporterPromo.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChangelogSupporterPromo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,7 +12,7 @@ using osu.Game.Overlays.Changelog; namespace osu.Game.Tests.Visual.Online { - public class TestSceneChangelogSupporterPromo : OsuTestScene + public partial class TestSceneChangelogSupporterPromo : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); diff --git a/osu.Game.Tests/Visual/Online/TestSceneChannelList.cs b/osu.Game.Tests/Visual/Online/TestSceneChannelList.cs index 39a4f1a8a1..a0cca5f53d 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChannelList.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChannelList.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -18,7 +20,7 @@ using osu.Game.Overlays.Chat.Listing; namespace osu.Game.Tests.Visual.Online { [TestFixture] - public class TestSceneChannelList : OsuTestScene + public partial class TestSceneChannelList : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); diff --git a/osu.Game.Tests/Visual/Online/TestSceneChannelListing.cs b/osu.Game.Tests/Visual/Online/TestSceneChannelListing.cs index e521db1c9d..2d2d7fc0ff 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChannelListing.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChannelListing.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -17,7 +19,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Online { [TestFixture] - public class TestSceneChannelListing : OsuTestScene + public partial class TestSceneChannelListing : OsuTestScene { [Cached] private readonly OverlayColourProvider overlayColours = new OverlayColourProvider(OverlayColourScheme.Pink); diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatLineTruncation.cs b/osu.Game.Tests/Visual/Online/TestSceneChatLineTruncation.cs index 444bd7e5fb..32d95ec8dc 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatLineTruncation.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatLineTruncation.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Framework.Allocation; @@ -13,7 +15,7 @@ using osu.Game.Overlays.Chat; namespace osu.Game.Tests.Visual.Online { [TestFixture] - public class TestSceneChatLineTruncation : OsuTestScene + public partial class TestSceneChatLineTruncation : OsuTestScene { private readonly TestChatLineContainer textContainer; @@ -84,7 +86,7 @@ namespace osu.Game.Tests.Visual.Online } } - private class TestChatLineContainer : FillFlowContainer + private partial class TestChatLineContainer : FillFlowContainer { protected override int Compare(Drawable x, Drawable y) { diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs b/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs index a28de3be1e..f5cf4c1ff2 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -18,7 +20,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Online { [TestFixture] - public class TestSceneChatLink : OsuTestScene + public partial class TestSceneChatLink : OsuTestScene { private readonly TestChatLineContainer textContainer; private Color4 linkColour; @@ -39,11 +41,13 @@ namespace osu.Game.Tests.Visual.Online { linkColour = colours.Blue; - var chatManager = new ChannelManager(); + var chatManager = new ChannelManager(API); BindableList availableChannels = (BindableList)chatManager.AvailableChannels; availableChannels.Add(new Channel { Name = "#english" }); availableChannels.Add(new Channel { Name = "#japanese" }); Dependencies.Cache(chatManager); + + Add(chatManager); } [SetUp] @@ -126,11 +130,11 @@ namespace osu.Game.Tests.Visual.Online Color4 textColour = isAction && hasBackground ? Color4Extensions.FromHex(newLine.Message.Sender.Colour) : Color4.White; - var linkCompilers = newLine.ContentFlow.Where(d => d is DrawableLinkCompiler).ToList(); + var linkCompilers = newLine.DrawableContentFlow.Where(d => d is DrawableLinkCompiler).ToList(); var linkSprites = linkCompilers.SelectMany(comp => ((DrawableLinkCompiler)comp).Parts); return linkSprites.All(d => d.Colour == linkColour) - && newLine.ContentFlow.Except(linkSprites.Concat(linkCompilers)).All(d => d.Colour == textColour); + && newLine.DrawableContentFlow.Except(linkSprites.Concat(linkCompilers)).All(d => d.Colour == textColour); } } } @@ -203,7 +207,7 @@ namespace osu.Game.Tests.Visual.Online } } - private class TestChatLineContainer : FillFlowContainer + private partial class TestChatLineContainer : FillFlowContainer { protected override int Compare(Drawable x, Drawable y) { diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs index 97221329f5..a8369dd6d9 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using System.Collections.Generic; @@ -32,14 +34,16 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Online { [TestFixture] - public class TestSceneChatOverlay : OsuManualInputManagerTestScene + public partial class TestSceneChatOverlay : OsuManualInputManagerTestScene { private TestChatOverlay chatOverlay; private ChannelManager channelManager; - private APIUser testUser; - private Channel testPMChannel; + private readonly APIUser testUser = new APIUser { Username = "test user", Id = 5071479 }; + private readonly APIUser testUser1 = new APIUser { Username = "test user", Id = 5071480 }; + private Channel[] testChannels; + private Message[] initialMessages; private Channel testChannel1 => testChannels[0]; private Channel testChannel2 => testChannels[1]; @@ -47,19 +51,21 @@ namespace osu.Game.Tests.Visual.Online [Resolved] private OsuConfigManager config { get; set; } = null!; + private int currentMessageId; + [SetUp] public void SetUp() => Schedule(() => { - testUser = new APIUser { Username = "test user", Id = 5071479 }; - testPMChannel = new Channel(testUser); + currentMessageId = 0; testChannels = Enumerable.Range(1, 10).Select(createPublicChannel).ToArray(); + initialMessages = testChannels.SelectMany(createChannelMessages).ToArray(); Child = new DependencyProvidingContainer { RelativeSizeAxes = Axes.Both, CachedDependencies = new (Type, object)[] { - (typeof(ChannelManager), channelManager = new ChannelManager()), + (typeof(ChannelManager), channelManager = new ChannelManager(API)), }, Children = new Drawable[] { @@ -78,6 +84,14 @@ namespace osu.Game.Tests.Visual.Online { switch (req) { + case CreateChannelRequest createRequest: + createRequest.TriggerSuccess(new APIChatChannel + { + ChannelID = ((int)createRequest.Channel.Id), + RecentMessages = new List() + }); + return true; + case GetUpdatesRequest getUpdates: getUpdates.TriggerFailure(new WebException()); return true; @@ -91,7 +105,7 @@ namespace osu.Game.Tests.Visual.Online return true; case GetMessagesRequest getMessages: - getMessages.TriggerSuccess(createChannelMessages(getMessages.Channel)); + getMessages.TriggerSuccess(initialMessages.ToList()); return true; case GetUserRequest getUser: @@ -179,7 +193,7 @@ namespace osu.Game.Tests.Visual.Online { AddStep("Show overlay", () => chatOverlay.Show()); AddAssert("Listing is visible", () => listingIsVisible); - AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1)); + joinTestChannel(0); AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1))); waitForChannel1Visible(); } @@ -201,12 +215,11 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestChannelCloseButton() { + var testPMChannel = new Channel(testUser); + AddStep("Show overlay", () => chatOverlay.Show()); - AddStep("Join PM and public channels", () => - { - channelManager.JoinChannel(testChannel1); - channelManager.JoinChannel(testPMChannel); - }); + joinTestChannel(0); + joinChannel(testPMChannel); AddStep("Select PM channel", () => clickDrawable(getChannelListItem(testPMChannel))); AddStep("Click close button", () => { @@ -227,7 +240,7 @@ namespace osu.Game.Tests.Visual.Online public void TestChatCommand() { AddStep("Show overlay", () => chatOverlay.Show()); - AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1)); + joinTestChannel(0); AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1))); AddStep("Open chat with user", () => channelManager.PostCommand($"chat {testUser.Username}")); AddAssert("PM channel is selected", () => @@ -246,14 +259,16 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestMultiplayerChannelIsNotShown() { - Channel multiplayerChannel = null; + Channel multiplayerChannel; AddStep("Show overlay", () => chatOverlay.Show()); - AddStep("Join multiplayer channel", () => channelManager.JoinChannel(multiplayerChannel = new Channel(new APIUser()) + + joinChannel(multiplayerChannel = new Channel(new APIUser()) { Name = "#mp_1", Type = ChannelType.Multiplayer, - })); + }); + AddAssert("Channel is joined", () => channelManager.JoinedChannels.Contains(multiplayerChannel)); AddUntilStep("Channel not present in listing", () => !chatOverlay.ChildrenOfType() .Where(item => item.IsPresent) @@ -267,7 +282,7 @@ namespace osu.Game.Tests.Visual.Online Message message = null; AddStep("Show overlay", () => chatOverlay.Show()); - AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1)); + joinTestChannel(0); AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1))); AddStep("Send message in channel 1", () => { @@ -289,8 +304,8 @@ namespace osu.Game.Tests.Visual.Online Message message = null; AddStep("Show overlay", () => chatOverlay.Show()); - AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1)); - AddStep("Join channel 2", () => channelManager.JoinChannel(testChannel2)); + joinTestChannel(0); + joinTestChannel(1); AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1))); AddStep("Send message in channel 2", () => { @@ -312,8 +327,8 @@ namespace osu.Game.Tests.Visual.Online Message message = null; AddStep("Show overlay", () => chatOverlay.Show()); - AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1)); - AddStep("Join channel 2", () => channelManager.JoinChannel(testChannel2)); + joinTestChannel(0); + joinTestChannel(1); AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1))); AddStep("Send message in channel 2", () => { @@ -335,7 +350,7 @@ namespace osu.Game.Tests.Visual.Online { Message message = null; - AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1)); + joinTestChannel(0); AddStep("Send message in channel 1", () => { testChannel1.AddNewMessages(message = new Message @@ -355,7 +370,7 @@ namespace osu.Game.Tests.Visual.Online { Message message = null; - AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1)); + joinTestChannel(0); AddStep("Send message in channel 1", () => { testChannel1.AddNewMessages(message = new Message @@ -376,7 +391,7 @@ namespace osu.Game.Tests.Visual.Online { AddStep("Show overlay", () => chatOverlay.Show()); AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox); - AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1)); + joinTestChannel(0); AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1))); waitForChannel1Visible(); AddAssert("TextBox is focused", () => InputManager.FocusedDrawable == chatOverlayTextBox); @@ -402,11 +417,11 @@ namespace osu.Game.Tests.Visual.Online chatOverlay.Show(); chatOverlay.SlowLoading = true; }); - AddStep("Join channel 1", () => channelManager.JoinChannel(testChannel1)); + joinTestChannel(0); AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1))); AddUntilStep("Channel 1 loading", () => !channelIsVisible && chatOverlay.GetSlowLoadingChannel(testChannel1).LoadState == LoadState.Loading); - AddStep("Join channel 2", () => channelManager.JoinChannel(testChannel2)); + joinTestChannel(1); AddStep("Select channel 2", () => clickDrawable(getChannelListItem(testChannel2))); AddUntilStep("Channel 2 loading", () => !channelIsVisible && chatOverlay.GetSlowLoadingChannel(testChannel2).LoadState == LoadState.Loading); @@ -459,19 +474,17 @@ namespace osu.Game.Tests.Visual.Online Channel pmChannel1 = createPrivateChannel(); Channel pmChannel2 = createPrivateChannel(); - AddStep("Show overlay with channels", () => - { - channelManager.JoinChannel(testChannel1); - channelManager.JoinChannel(testChannel2); - channelManager.JoinChannel(pmChannel1); - channelManager.JoinChannel(pmChannel2); - channelManager.JoinChannel(announceChannel); - chatOverlay.Show(); - }); + joinTestChannel(0); + joinTestChannel(1); + joinChannel(pmChannel1); + joinChannel(pmChannel2); + joinChannel(announceChannel); + + AddStep("Show overlay", () => chatOverlay.Show()); AddStep("Select channel 1", () => clickDrawable(getChannelListItem(testChannel1))); - waitForChannel1Visible(); + AddStep("Press document next keys", () => InputManager.Keys(PlatformAction.DocumentNext)); waitForChannel2Visible(); @@ -488,6 +501,93 @@ namespace osu.Game.Tests.Visual.Online waitForChannel1Visible(); } + [Test] + public void TestRemoveMessages() + { + AddStep("Show overlay with channel", () => + { + chatOverlay.Show(); + channelManager.CurrentChannel.Value = channelManager.JoinChannel(testChannel1); + }); + + AddAssert("Overlay is visible", () => chatOverlay.State.Value == Visibility.Visible); + waitForChannel1Visible(); + + AddStep("Send message from another user", () => + { + testChannel1.AddNewMessages(new Message + { + ChannelId = testChannel1.Id, + Content = "Message from another user", + Timestamp = DateTimeOffset.Now, + Sender = testUser1, + }); + }); + + AddStep("Remove messages from other user", () => + { + testChannel1.RemoveMessagesFromUser(testUser.Id); + }); + } + + [Test] + public void TestTextBoxSavePerChannel() + { + var testPMChannel = new Channel(testUser); + + AddStep("show overlay", () => chatOverlay.Show()); + joinTestChannel(0); + joinChannel(testPMChannel); + + AddAssert("listing is visible", () => listingIsVisible); + AddStep("search for 'number 2'", () => chatOverlayTextBox.Text = "number 2"); + AddAssert("'number 2' saved to selector", () => channelManager.CurrentChannel.Value.TextBoxMessage.Value == "number 2"); + + AddStep("select normal channel", () => clickDrawable(getChannelListItem(testChannel1))); + AddAssert("text box cleared on normal channel", () => chatOverlayTextBox.Text == string.Empty); + AddAssert("nothing saved on normal channel", () => channelManager.CurrentChannel.Value.TextBoxMessage.Value == string.Empty); + AddStep("type '727'", () => chatOverlayTextBox.Text = "727"); + AddAssert("'727' saved to normal channel", () => channelManager.CurrentChannel.Value.TextBoxMessage.Value == "727"); + + AddStep("select PM channel", () => clickDrawable(getChannelListItem(testPMChannel))); + AddAssert("text box cleared on PM channel", () => chatOverlayTextBox.Text == string.Empty); + AddAssert("nothing saved on PM channel", () => channelManager.CurrentChannel.Value.TextBoxMessage.Value == string.Empty); + AddStep("type 'hello'", () => chatOverlayTextBox.Text = "hello"); + AddAssert("'hello' saved to PM channel", () => channelManager.CurrentChannel.Value.TextBoxMessage.Value == "hello"); + + AddStep("select normal channel", () => clickDrawable(getChannelListItem(testChannel1))); + AddAssert("text box contains '727'", () => chatOverlayTextBox.Text == "727"); + + AddStep("select PM channel", () => clickDrawable(getChannelListItem(testPMChannel))); + AddAssert("text box contains 'hello'", () => chatOverlayTextBox.Text == "hello"); + AddStep("click close button", () => + { + ChannelListItemCloseButton closeButton = getChannelListItem(testPMChannel).ChildrenOfType().Single(); + clickDrawable(closeButton); + }); + + AddAssert("listing is visible", () => listingIsVisible); + AddAssert("text box contains 'channel 2'", () => chatOverlayTextBox.Text == "number 2"); + AddUntilStep("only channel 2 visible", () => + { + IEnumerable listingItems = chatOverlay.ChildrenOfType() + .Where(item => item.IsPresent); + return listingItems.Count() == 1 && listingItems.Single().Channel == testChannel2; + }); + } + + private void joinTestChannel(int i) + { + AddStep($"Join test channel {i}", () => channelManager.JoinChannel(testChannels[i])); + AddUntilStep("wait for join completed", () => testChannels[i].Joined.Value); + } + + private void joinChannel(Channel channel) + { + AddStep($"Join channel {channel}", () => channelManager.JoinChannel(channel)); + AddUntilStep("wait for join completed", () => channel.Joined.Value); + } + private void waitForChannel1Visible() => AddUntilStep("Channel 1 is visible", () => channelIsVisible && currentDrawableChannel?.Channel == testChannel1); @@ -527,7 +627,7 @@ namespace osu.Game.Tests.Visual.Online private List createChannelMessages(Channel channel) { - var message = new Message + var message = new Message(currentMessageId++) { ChannelId = channel.Id, Content = $"Hello, this is a message in {channel.Name}", @@ -547,7 +647,7 @@ namespace osu.Game.Tests.Visual.Online private Channel createPrivateChannel() { - int id = RNG.Next(0, 10000); + int id = RNG.Next(0, DummyAPIAccess.DUMMY_USER_ID - 1); return new Channel(new APIUser { Id = id, @@ -557,16 +657,17 @@ namespace osu.Game.Tests.Visual.Online private Channel createAnnounceChannel() { - int id = RNG.Next(0, 10000); + const int announce_channel_id = 133337; + return new Channel { - Name = $"Announce {id}", + Name = $"Announce {announce_channel_id}", Type = ChannelType.Announce, - Id = id, + Id = announce_channel_id, }; } - private class TestChatOverlay : ChatOverlay + private partial class TestChatOverlay : ChatOverlay { public bool SlowLoading { get; set; } @@ -580,7 +681,7 @@ namespace osu.Game.Tests.Visual.Online } } - private class SlowLoadingDrawableChannel : DrawableChannel + private partial class SlowLoadingDrawableChannel : DrawableChannel { public readonly ManualResetEventSlim LoadEvent = new ManualResetEventSlim(); diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs b/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs index a241aa0517..1e80acd56b 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatTextBox.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -16,7 +18,7 @@ using osu.Game.Overlays.Chat; namespace osu.Game.Tests.Visual.Online { [TestFixture] - public class TestSceneChatTextBox : OsuTestScene + public partial class TestSceneChatTextBox : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); diff --git a/osu.Game.Tests/Visual/Online/TestSceneCommentActions.cs b/osu.Game.Tests/Visual/Online/TestSceneCommentActions.cs new file mode 100644 index 0000000000..dbf3b52572 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneCommentActions.cs @@ -0,0 +1,421 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Testing; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Overlays.Comments; +using osu.Game.Overlays.Comments.Buttons; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Online +{ + public partial class TestSceneCommentActions : OsuManualInputManagerTestScene + { + private Container content = null!; + protected override Container Content => content; + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + + [Cached(typeof(IDialogOverlay))] + private readonly DialogOverlay dialogOverlay = new DialogOverlay(); + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + private CommentsContainer commentsContainer = null!; + + private readonly ManualResetEventSlim requestLock = new ManualResetEventSlim(); + + [BackgroundDependencyLoader] + private void load() + { + base.Content.AddRange(new Drawable[] + { + new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = content = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both + } + }, + dialogOverlay + }); + } + + [SetUpSteps] + public void SetUp() + { + Schedule(() => + { + API.Login("test", "test"); + Child = commentsContainer = new CommentsContainer(); + }); + } + + [Test] + public void TestNonOwnCommentCantBeDeleted() + { + addTestComments(); + + AddUntilStep("First comment has button", () => + { + var comments = this.ChildrenOfType(); + var ourComment = comments.SingleOrDefault(x => x.Comment.Id == 1); + return ourComment != null && ourComment.ChildrenOfType().Any(x => x.Text == "delete"); + }); + + AddAssert("Second doesn't", () => + { + var comments = this.ChildrenOfType(); + var ourComment = comments.Single(x => x.Comment.Id == 2); + return ourComment.ChildrenOfType().All(x => x.Text != "delete"); + }); + } + + [Test] + public void TestDeletion() + { + DrawableComment? ourComment = null; + + addTestComments(); + AddUntilStep("Comment exists", () => + { + var comments = this.ChildrenOfType(); + ourComment = comments.SingleOrDefault(x => x.Comment.Id == 1); + return ourComment != null; + }); + AddStep("It has delete button", () => + { + var btn = ourComment.ChildrenOfType().Single(x => x.Text == "delete"); + InputManager.MoveMouseTo(btn); + }); + AddStep("Click delete button", () => + { + InputManager.Click(MouseButton.Left); + }); + AddStep("Setup request handling", () => + { + requestLock.Reset(); + + dummyAPI.HandleRequest = request => + { + if (!(request is CommentDeleteRequest req)) + return false; + + if (req.CommentId != 1) + return false; + + CommentBundle cb = new CommentBundle + { + Comments = new List + { + new Comment + { + Id = 2, + Message = "This is a comment by another user", + UserId = API.LocalUser.Value.Id + 1, + CreatedAt = DateTimeOffset.Now, + User = new APIUser + { + Id = API.LocalUser.Value.Id + 1, + Username = "Another user" + } + }, + }, + IncludedComments = new List(), + PinnedComments = new List(), + }; + + Task.Run(() => + { + requestLock.Wait(10000); + req.TriggerSuccess(cb); + }); + + return true; + }; + }); + AddStep("Confirm dialog", () => InputManager.Key(Key.Number1)); + + AddAssert("Loading spinner shown", () => commentsContainer.ChildrenOfType().Any(d => d.IsPresent)); + + AddStep("Complete request", () => requestLock.Set()); + + AddUntilStep("Comment is deleted locally", () => this.ChildrenOfType().Single(x => x.Comment.Id == 1).WasDeleted); + } + + [Test] + public void TestDeletionFail() + { + DrawableComment? ourComment = null; + bool delete = false; + + addTestComments(); + AddUntilStep("Comment exists", () => + { + var comments = this.ChildrenOfType(); + ourComment = comments.SingleOrDefault(x => x.Comment.Id == 1); + return ourComment != null; + }); + AddStep("It has delete button", () => + { + var btn = ourComment.ChildrenOfType().Single(x => x.Text == "delete"); + InputManager.MoveMouseTo(btn); + }); + AddStep("Click delete button", () => + { + InputManager.Click(MouseButton.Left); + }); + AddStep("Setup request handling", () => + { + dummyAPI.HandleRequest = request => + { + if (request is not CommentDeleteRequest req) + return false; + + req.TriggerFailure(new InvalidOperationException()); + delete = true; + return false; + }; + }); + AddStep("Confirm dialog", () => InputManager.Key(Key.Number1)); + AddUntilStep("Deletion requested", () => delete); + AddUntilStep("Comment is available", () => + { + return !this.ChildrenOfType().Single(x => x.Comment.Id == 1).WasDeleted; + }); + AddAssert("Loading spinner hidden", () => + { + return ourComment.ChildrenOfType().All(d => !d.IsPresent); + }); + AddAssert("Actions available", () => + { + return ourComment.ChildrenOfType().Single(x => x.Name == @"Actions buttons").IsPresent; + }); + } + + [Test] + public void TestReport() + { + const string report_text = "I don't like this comment"; + DrawableComment? targetComment = null; + CommentReportRequest? request = null; + + addTestComments(); + AddUntilStep("Comment exists", () => + { + var comments = this.ChildrenOfType(); + targetComment = comments.SingleOrDefault(x => x.Comment.Id == 2); + return targetComment != null; + }); + AddStep("Setup request handling", () => + { + requestLock.Reset(); + + dummyAPI.HandleRequest = r => + { + if (!(r is CommentReportRequest req)) + return false; + + Task.Run(() => + { + request = req; + requestLock.Wait(10000); + req.TriggerSuccess(); + }); + + return true; + }; + }); + AddStep("Click the button", () => + { + var btn = targetComment.ChildrenOfType().Single(x => x.Text == "report"); + InputManager.MoveMouseTo(btn); + InputManager.Click(MouseButton.Left); + }); + AddStep("Try to report", () => + { + var btn = this.ChildrenOfType().Single().ChildrenOfType().Single(); + InputManager.MoveMouseTo(btn); + InputManager.Click(MouseButton.Left); + }); + AddWaitStep("Wait", 3); + AddAssert("Nothing happened", () => this.ChildrenOfType().Any()); + AddStep("Set report data", () => + { + var field = this.ChildrenOfType().Single().ChildrenOfType().Single(); + field.Current.Value = report_text; + var reason = this.ChildrenOfType>().Single(); + reason.Current.Value = CommentReportReason.Other; + }); + AddStep("Try to report", () => + { + var btn = this.ChildrenOfType().Single().ChildrenOfType().Single(); + InputManager.MoveMouseTo(btn); + InputManager.Click(MouseButton.Left); + }); + AddWaitStep("Wait", 3); + AddAssert("Overlay closed", () => !this.ChildrenOfType().Any()); + AddAssert("Loading spinner shown", () => targetComment.ChildrenOfType().Any(d => d.IsPresent)); + AddStep("Complete request", () => requestLock.Set()); + AddUntilStep("Request sent", () => request != null); + AddAssert("Request is correct", () => request != null && request.CommentID == 2 && request.Comment == report_text && request.Reason == CommentReportReason.Other); + } + + [Test] + public void TestReply() + { + addTestComments(); + DrawableComment? targetComment = null; + AddUntilStep("Comment exists", () => + { + var comments = this.ChildrenOfType(); + targetComment = comments.SingleOrDefault(x => x.Comment.Id == 2); + return targetComment != null; + }); + AddStep("Setup request handling", () => + { + requestLock.Reset(); + + dummyAPI.HandleRequest = r => + { + if (!(r is CommentPostRequest req)) + return false; + + if (req.ParentCommentId != 2) + throw new ArgumentException("Wrong parent ID in request!"); + + if (req.CommentableId != 123 || req.Commentable != CommentableType.Beatmapset) + throw new ArgumentException("Wrong commentable data in request!"); + + Task.Run(() => + { + requestLock.Wait(10000); + req.TriggerSuccess(new CommentBundle + { + Comments = new List + { + new Comment + { + Id = 98, + Message = req.Message, + LegacyName = "FirstUser", + CreatedAt = DateTimeOffset.Now, + VotesCount = 98, + ParentId = req.ParentCommentId, + } + } + }); + }); + + return true; + }; + }); + AddStep("Click reply button", () => + { + var btn = targetComment.ChildrenOfType().Skip(1).First(); + var texts = btn.ChildrenOfType(); + InputManager.MoveMouseTo(texts.Skip(1).First()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("There is 0 replies", () => + { + var replLabel = targetComment.ChildrenOfType().First().ChildrenOfType().First(); + return replLabel.Text.ToString().Contains('0') && targetComment!.Comment.RepliesCount == 0; + }); + AddStep("Focus field", () => + { + InputManager.MoveMouseTo(targetComment.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + AddStep("Enter text", () => + { + targetComment.ChildrenOfType().First().Current.Value = "random reply"; + }); + AddStep("Submit", () => + { + InputManager.Key(Key.Enter); + }); + AddStep("Complete request", () => requestLock.Set()); + AddUntilStep("There is 1 reply", () => + { + var replLabel = targetComment.ChildrenOfType().First().ChildrenOfType().First(); + return replLabel.Text.ToString().Contains('1') && targetComment!.Comment.RepliesCount == 1; + }); + AddUntilStep("Submitted comment shown", () => + { + var r = targetComment.ChildrenOfType().Skip(1).FirstOrDefault(); + return r != null && r.Comment.Message == "random reply"; + }); + } + + private void addTestComments() + { + AddStep("set up response", () => + { + CommentBundle cb = new CommentBundle + { + Comments = new List + { + new Comment + { + Id = 1, + Message = "This is our comment", + UserId = API.LocalUser.Value.Id, + CreatedAt = DateTimeOffset.Now, + User = API.LocalUser.Value, + }, + new Comment + { + Id = 2, + Message = "This is a comment by another user", + UserId = API.LocalUser.Value.Id + 1, + CreatedAt = DateTimeOffset.Now, + User = new APIUser + { + Id = API.LocalUser.Value.Id + 1, + Username = "Another user" + } + }, + }, + IncludedComments = new List(), + PinnedComments = new List(), + }; + setUpCommentsResponse(cb); + }); + + AddStep("show comments", () => commentsContainer.ShowComments(CommentableType.Beatmapset, 123)); + } + + private void setUpCommentsResponse(CommentBundle commentBundle) + { + dummyAPI.HandleRequest = request => + { + if (!(request is GetCommentsRequest getCommentsRequest)) + return false; + + getCommentsRequest.TriggerSuccess(commentBundle); + return true; + }; + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneCommentReportButton.cs b/osu.Game.Tests/Visual/Online/TestSceneCommentReportButton.cs new file mode 100644 index 0000000000..d2e73b8673 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneCommentReportButton.cs @@ -0,0 +1,46 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Testing; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays.Comments; +using osu.Game.Tests.Visual.UserInterface; +using osuTK; + +namespace osu.Game.Tests.Visual.Online +{ + public partial class TestSceneCommentReportButton : ThemeComparisonTestScene + { + [SetUpSteps] + public void SetUpSteps() + { + AddStep("setup API", () => ((DummyAPIAccess)API).HandleRequest += req => + { + switch (req) + { + case CommentReportRequest report: + Scheduler.AddDelayed(report.TriggerSuccess, 1000); + return true; + } + + return false; + }); + } + + protected override Drawable CreateContent() => new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new CommentReportButton(new Comment { User = new APIUser { Username = "Someone" } }) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(2f), + }.With(b => Schedule(b.ShowPopover)), + }; + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs index 31e5a9b86c..3d8781d902 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs @@ -9,7 +9,10 @@ using osu.Game.Overlays; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Testing; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; @@ -18,22 +21,27 @@ using osu.Game.Overlays.Comments; namespace osu.Game.Tests.Visual.Online { [TestFixture] - public class TestSceneCommentsContainer : OsuTestScene + public partial class TestSceneCommentsContainer : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; - private CommentsContainer commentsContainer; + private CommentsContainer commentsContainer = null!; + + private TextBox editorTextBox = null!; [SetUp] public void SetUp() => Schedule(() => + { Child = new BasicScrollContainer { RelativeSizeAxes = Axes.Both, Child = commentsContainer = new CommentsContainer() - }); + }; + editorTextBox = commentsContainer.ChildrenOfType().First(); + }); [Test] public void TestIdleState() @@ -124,6 +132,44 @@ namespace osu.Game.Tests.Visual.Online commentsContainer.ChildrenOfType().Count(d => d.Comment.Pinned == withPinned) == 1); } + [Test] + public void TestPost() + { + setUpCommentsResponse(new CommentBundle { Comments = new List() }); + AddStep("show comments", () => commentsContainer.ShowComments(CommentableType.Beatmapset, 123)); + AddAssert("no comments placeholder shown", () => commentsContainer.ChildrenOfType().Any()); + + setUpPostResponse(); + AddStep("enter text", () => editorTextBox.Current.Value = "comm"); + AddStep("submit", () => commentsContainer.ChildrenOfType().First().TriggerClick()); + + AddUntilStep("comment sent", () => + { + string writtenText = editorTextBox.Current.Value; + var comment = commentsContainer.ChildrenOfType().LastOrDefault(); + return comment != null && comment.ChildrenOfType().Any(y => y.Text == writtenText); + }); + AddAssert("no comments placeholder removed", () => !commentsContainer.ChildrenOfType().Any()); + } + + [Test] + public void TestPostWithExistingComments() + { + setUpCommentsResponse(getExampleComments()); + AddStep("show comments", () => commentsContainer.ShowComments(CommentableType.Beatmapset, 123)); + + setUpPostResponse(); + AddStep("enter text", () => editorTextBox.Current.Value = "comm"); + AddStep("submit", () => commentsContainer.ChildrenOfType().Single().ChildrenOfType().First().TriggerClick()); + + AddUntilStep("comment sent", () => + { + string writtenText = editorTextBox.Current.Value; + var comment = commentsContainer.ChildrenOfType().LastOrDefault(); + return comment != null && comment.ChildrenOfType().Any(y => y.Text == writtenText); + }); + } + private void setUpCommentsResponse(CommentBundle commentBundle) => AddStep("set up response", () => { @@ -137,7 +183,33 @@ namespace osu.Game.Tests.Visual.Online }; }); - private CommentBundle getExampleComments(bool withPinned = false) + private void setUpPostResponse() + => AddStep("set up response", () => + { + dummyAPI.HandleRequest = request => + { + if (!(request is CommentPostRequest req)) + return false; + + req.TriggerSuccess(new CommentBundle + { + Comments = new List + { + new Comment + { + Id = 98, + Message = req.Message, + LegacyName = "FirstUser", + CreatedAt = DateTimeOffset.Now, + VotesCount = 98, + } + } + }); + return true; + }; + }); + + private static CommentBundle getExampleComments(bool withPinned = false) { var bundle = new CommentBundle { diff --git a/osu.Game.Tests/Visual/Online/TestSceneCommentsHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneCommentsHeader.cs index 03eac5d85b..43d80ee0ac 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCommentsHeader.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneCommentsHeader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -10,7 +12,7 @@ using osu.Game.Overlays.Comments; namespace osu.Game.Tests.Visual.Online { [TestFixture] - public class TestSceneCommentsHeader : OsuTestScene + public partial class TestSceneCommentsHeader : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); diff --git a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs index edee26c081..4f825e1191 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using System.Threading; @@ -18,7 +20,7 @@ using osu.Game.Users; namespace osu.Game.Tests.Visual.Online { - public class TestSceneCurrentlyPlayingDisplay : OsuTestScene + public partial class TestSceneCurrentlyPlayingDisplay : OsuTestScene { private readonly APIUser streamingUser = new APIUser { Id = 2, Username = "Test user" }; @@ -64,7 +66,7 @@ namespace osu.Game.Tests.Visual.Online AddUntilStep("Panel no longer present", () => !currentlyPlaying.ChildrenOfType().Any()); } - internal class TestUserLookupCache : UserLookupCache + internal partial class TestUserLookupCache : UserLookupCache { private static readonly string[] usernames = { diff --git a/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.cs index 960d3fa248..504be45b44 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Overlays; namespace osu.Game.Tests.Visual.Online { - public class TestSceneDashboardOverlay : OsuTestScene + public partial class TestSceneDashboardOverlay : OsuTestScene { protected override bool UseOnlineAPI => true; diff --git a/osu.Game.Tests/Visual/Online/TestSceneDrawableChannel.cs b/osu.Game.Tests/Visual/Online/TestSceneDrawableChannel.cs new file mode 100644 index 0000000000..4830c7b856 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneDrawableChannel.cs @@ -0,0 +1,87 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; +using osu.Game.Overlays.Chat; + +namespace osu.Game.Tests.Visual.Online +{ + [TestFixture] + public partial class TestSceneDrawableChannel : OsuTestScene + { + private Channel channel = null!; + private DrawableChannel drawableChannel = null!; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create channel", () => channel = new Channel + { + Id = 1, + Name = "Test channel" + }); + AddStep("create drawable channel", () => Child = drawableChannel = new DrawableChannel(channel) + { + RelativeSizeAxes = Axes.Both + }); + } + + [Test] + public void TestDaySeparators() + { + var localUser = new APIUser + { + Id = 3, + Username = "LocalUser" + }; + string uuid = Guid.NewGuid().ToString(); + AddStep("add local echo message", () => channel.AddLocalEcho(new LocalEchoMessage + { + Sender = localUser, + Content = "Hi there all!", + Timestamp = new DateTimeOffset(2022, 11, 21, 20, 11, 13, TimeSpan.Zero), + Uuid = uuid + })); + AddUntilStep("one day separator present", () => drawableChannel.ChildrenOfType().Count() == 1); + + AddStep("add two prior messages to channel", () => channel.AddNewMessages( + new Message(1) + { + Sender = new APIUser + { + Id = 1, + Username = "TestUser" + }, + Content = "This is a message", + Timestamp = new DateTimeOffset(2021, 10, 10, 13, 33, 23, TimeSpan.Zero), + }, + new Message(2) + { + Sender = new APIUser + { + Id = 2, + Username = "TestUser2" + }, + Content = "This is another message", + Timestamp = new DateTimeOffset(2021, 10, 11, 13, 33, 23, TimeSpan.Zero) + })); + AddUntilStep("three day separators present", () => drawableChannel.ChildrenOfType().Count() == 3); + + AddStep("resolve pending message", () => channel.ReplaceMessage(channel.Messages.OfType().Single(), new Message(3) + { + Sender = localUser, + Content = "Hi there all!", + Timestamp = new DateTimeOffset(2022, 11, 22, 20, 11, 16, TimeSpan.Zero), + Uuid = uuid + })); + AddUntilStep("three day separators present", () => drawableChannel.ChildrenOfType().Count() == 3); + AddAssert("last day separator is from correct day", () => drawableChannel.ChildrenOfType().Last().Date.Date == new DateTime(2022, 11, 22)); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneDrawableComment.cs b/osu.Game.Tests/Visual/Online/TestSceneDrawableComment.cs index b26850feb2..ac80463d3a 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneDrawableComment.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneDrawableComment.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Framework.Allocation; @@ -13,7 +15,7 @@ using osu.Game.Overlays.Comments; namespace osu.Game.Tests.Visual.Online { - public class TestSceneDrawableComment : OsuTestScene + public partial class TestSceneDrawableComment : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); diff --git a/osu.Game.Tests/Visual/Online/TestSceneExternalLinkButton.cs b/osu.Game.Tests/Visual/Online/TestSceneExternalLinkButton.cs index 31bb276cd4..90ec3160d8 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneExternalLinkButton.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneExternalLinkButton.cs @@ -1,18 +1,28 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using osu.Framework.Graphics; +using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osuTK; namespace osu.Game.Tests.Visual.Online { - public class TestSceneExternalLinkButton : OsuTestScene + public partial class TestSceneExternalLinkButton : OsuTestScene { public TestSceneExternalLinkButton() { - Child = new ExternalLinkButton("https://osu.ppy.sh/home") + Child = new OsuContextMenuContainer { - Size = new Vector2(50) + RelativeSizeAxes = Axes.Both, + Child = new ExternalLinkButton("https://osu.ppy.sh/home") + { + Size = new Vector2(50), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } }; } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneFavouriteButton.cs b/osu.Game.Tests/Visual/Online/TestSceneFavouriteButton.cs index 87458da578..3954fd5cff 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneFavouriteButton.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneFavouriteButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Testing; @@ -10,7 +12,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Online { - public class TestSceneFavouriteButton : OsuTestScene + public partial class TestSceneFavouriteButton : OsuTestScene { private FavouriteButton favourite; diff --git a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs index 471d361c4e..7925b252b6 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using NUnit.Framework; @@ -14,7 +16,7 @@ using osu.Game.Users; namespace osu.Game.Tests.Visual.Online { - public class TestSceneFriendDisplay : OsuTestScene + public partial class TestSceneFriendDisplay : OsuTestScene { protected override bool UseOnlineAPI => true; @@ -53,7 +55,7 @@ namespace osu.Game.Tests.Visual.Online Id = 3103765, IsOnline = true, Statistics = new UserStatistics { GlobalRank = 1111 }, - Country = new Country { FlagName = "JP" }, + CountryCode = CountryCode.JP, CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c6.jpg" }, new APIUser @@ -62,7 +64,7 @@ namespace osu.Game.Tests.Visual.Online Id = 2, IsOnline = false, Statistics = new UserStatistics { GlobalRank = 2222 }, - Country = new Country { FlagName = "AU" }, + CountryCode = CountryCode.AU, CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", IsSupporter = true, SupportLevel = 3, @@ -71,7 +73,7 @@ namespace osu.Game.Tests.Visual.Online { Username = "Evast", Id = 8195163, - Country = new Country { FlagName = "BY" }, + CountryCode = CountryCode.BY, CoverUrl = "https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", IsOnline = false, LastVisit = DateTimeOffset.Now diff --git a/osu.Game.Tests/Visual/Online/TestSceneFullscreenOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneFullscreenOverlay.cs index dc468bb62d..16d31c916e 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneFullscreenOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneFullscreenOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; @@ -10,7 +12,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Online { [TestFixture] - public class TestSceneFullscreenOverlay : OsuTestScene + public partial class TestSceneFullscreenOverlay : OsuTestScene { private FullscreenOverlay overlay; @@ -38,7 +40,7 @@ namespace osu.Game.Tests.Visual.Online AddAssert("fire count 3", () => fireCount == 3); } - private class TestFullscreenOverlay : FullscreenOverlay + private partial class TestFullscreenOverlay : FullscreenOverlay { public TestFullscreenOverlay() : base(OverlayColourScheme.Pink) @@ -55,11 +57,11 @@ namespace osu.Game.Tests.Visual.Online protected override OverlayHeader CreateHeader() => new TestHeader(); - internal class TestHeader : OverlayHeader + internal partial class TestHeader : OverlayHeader { protected override OverlayTitle CreateTitle() => new TestTitle(); - internal class TestTitle : OverlayTitle + internal partial class TestTitle : OverlayTitle { } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneGraph.cs b/osu.Game.Tests/Visual/Online/TestSceneGraph.cs index fa433571cf..357ed7548c 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneGraph.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneGraph.cs @@ -1,6 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System; using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -10,7 +13,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Online { [TestFixture] - public class TestSceneGraph : OsuTestScene + public partial class TestSceneGraph : OsuTestScene { public TestSceneGraph() { @@ -30,6 +33,7 @@ namespace osu.Game.Tests.Visual.Online AddStep("values from 1-10", () => graph.Values = Enumerable.Range(1, 10).Select(i => (float)i)); AddStep("values from 1-100", () => graph.Values = Enumerable.Range(1, 100).Select(i => (float)i)); AddStep("reversed values from 1-10", () => graph.Values = Enumerable.Range(1, 10).Reverse().Select(i => (float)i)); + AddStep("empty values", () => graph.Values = Array.Empty()); AddStep("Bottom to top", () => graph.Direction = BarDirection.BottomToTop); AddStep("Top to bottom", () => graph.Direction = BarDirection.TopToBottom); AddStep("Left to right", () => graph.Direction = BarDirection.LeftToRight); diff --git a/osu.Game.Tests/Visual/Online/TestSceneGroupBadges.cs b/osu.Game.Tests/Visual/Online/TestSceneGroupBadges.cs new file mode 100644 index 0000000000..cbf9c33b78 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneGroupBadges.cs @@ -0,0 +1,92 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays.Profile.Header.Components; +using osuTK; + +namespace osu.Game.Tests.Visual.Online +{ + [TestFixture] + public partial class TestSceneGroupBadges : OsuTestScene + { + public TestSceneGroupBadges() + { + var groups = new[] + { + new APIUser(), + new APIUser + { + Groups = new[] + { + new APIUserGroup { Colour = "#EB47D0", ShortName = "DEV", Name = "Developers" }, + } + }, + new APIUser + { + Groups = new[] + { + new APIUserGroup { Colour = "#EB47D0", ShortName = "DEV", Name = "Developers" }, + new APIUserGroup { Colour = "#A347EB", ShortName = "BN", Name = "Beatmap Nominators", Playmodes = new[] { "osu", "taiko" } } + } + }, + new APIUser + { + Groups = new[] + { + new APIUserGroup { Colour = "#0066FF", ShortName = "PPY", Name = "peppy" }, + new APIUserGroup { Colour = "#EB47D0", ShortName = "DEV", Name = "Developers" }, + new APIUserGroup { Colour = "#A347EB", ShortName = "BN", Name = "Beatmap Nominators", Playmodes = new[] { "osu", "taiko" } } + } + }, + new APIUser + { + Groups = new[] + { + new APIUserGroup { Colour = "#0066FF", ShortName = "PPY", Name = "peppy" }, + new APIUserGroup { Colour = "#EB47D0", ShortName = "DEV", Name = "Developers" }, + new APIUserGroup { Colour = "#999999", ShortName = "ALM", Name = "osu! Alumni" }, + new APIUserGroup { Colour = "#A347EB", ShortName = "BN", Name = "Beatmap Nominators", Playmodes = new[] { "osu", "taiko" } }, + new APIUserGroup { Colour = "#A347EB", ShortName = "BN", Name = "Beatmap Nominators (Probationary)", Playmodes = new[] { "osu", "taiko" }, IsProbationary = true } + } + } + }; + + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.DarkGray + }, + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(40), + Children = new[] + { + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + ChildrenEnumerable = groups.Select(g => new GroupBadgeFlow { User = { Value = g } }) + }, + } + } + }; + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneHistoricalSection.cs b/osu.Game.Tests/Visual/Online/TestSceneHistoricalSection.cs index 2639a6506c..fdc567d4ad 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneHistoricalSection.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneHistoricalSection.cs @@ -9,12 +9,14 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; +using osu.Game.Overlays.Profile; using osu.Game.Overlays.Profile.Sections; +using osu.Game.Rulesets.Osu; namespace osu.Game.Tests.Visual.Online { [TestFixture] - public class TestSceneHistoricalSection : OsuTestScene + public partial class TestSceneHistoricalSection : OsuTestScene { protected override bool UseOnlineAPI => true; @@ -37,8 +39,8 @@ namespace osu.Game.Tests.Visual.Online Child = section = new HistoricalSection(), }); - AddStep("Show peppy", () => section.User.Value = new APIUser { Id = 2 }); - AddStep("Show WubWoofWolf", () => section.User.Value = new APIUser { Id = 39828 }); + AddStep("Show peppy", () => section.User.Value = new UserProfileData(new APIUser { Id = 2 }, new OsuRuleset().RulesetInfo)); + AddStep("Show WubWoofWolf", () => section.User.Value = new UserProfileData(new APIUser { Id = 39828 }, new OsuRuleset().RulesetInfo)); } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneHomeNewsPanel.cs b/osu.Game.Tests/Visual/Online/TestSceneHomeNewsPanel.cs index a1251ca793..a58845ca7e 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneHomeNewsPanel.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneHomeNewsPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Containers; using osu.Framework.Graphics; using osu.Game.Online.API.Requests.Responses; @@ -13,7 +15,7 @@ using System.Collections.Generic; namespace osu.Game.Tests.Visual.Online { - public class TestSceneHomeNewsPanel : OsuTestScene + public partial class TestSceneHomeNewsPanel : OsuTestScene { [Cached] private readonly OverlayColourProvider overlayColour = new OverlayColourProvider(OverlayColourScheme.Purple); diff --git a/osu.Game.Tests/Visual/Online/TestSceneKudosuHistory.cs b/osu.Game.Tests/Visual/Online/TestSceneKudosuHistory.cs index 8bf2ef34f2..25a56196eb 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneKudosuHistory.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneKudosuHistory.cs @@ -5,19 +5,22 @@ using osu.Game.Overlays.Profile.Sections.Kudosu; using System.Collections.Generic; using System; using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; using osu.Framework.Allocation; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics; using osu.Game.Online.API.Requests.Responses; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Game.Overlays; namespace osu.Game.Tests.Visual.Online { - public class TestSceneKudosuHistory : OsuTestScene + public partial class TestSceneKudosuHistory : OsuTestScene { private readonly Box background; + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); + public TestSceneKudosuHistory() { FillFlowContainer content; @@ -42,9 +45,9 @@ namespace osu.Game.Tests.Visual.Online } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { - background.Colour = colours.GreySeaFoam; + background.Colour = colourProvider.Background4; } private readonly IEnumerable items = new[] diff --git a/osu.Game.Tests/Visual/Online/TestSceneLeaderboardModSelector.cs b/osu.Game.Tests/Visual/Online/TestSceneLeaderboardModSelector.cs index aa442ded02..0a6bab468a 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneLeaderboardModSelector.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneLeaderboardModSelector.cs @@ -20,14 +20,14 @@ using osu.Game.Rulesets.Mods; namespace osu.Game.Tests.Visual.Online { - public class TestSceneLeaderboardModSelector : OsuTestScene + public partial class TestSceneLeaderboardModSelector : OsuTestScene { public TestSceneLeaderboardModSelector() { LeaderboardModSelector modSelector; FillFlowContainer selectedMods; - var ruleset = new Bindable(); + var ruleset = new Bindable(); Add(selectedMods = new FillFlowContainer { @@ -60,7 +60,7 @@ namespace osu.Game.Tests.Visual.Online { if (selected.Text == mod.Acronym) { - selectedMods.Remove(selected); + selectedMods.Remove(selected, true); break; } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneLeaderboardScopeSelector.cs b/osu.Game.Tests/Visual/Online/TestSceneLeaderboardScopeSelector.cs index afa559280c..0231775189 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneLeaderboardScopeSelector.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneLeaderboardScopeSelector.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Overlays.BeatmapSet; using osu.Framework.Graphics; using osu.Framework.Bindables; @@ -10,7 +12,7 @@ using osu.Game.Overlays; namespace osu.Game.Tests.Visual.Online { - public class TestSceneLeaderboardScopeSelector : OsuTestScene + public partial class TestSceneLeaderboardScopeSelector : OsuTestScene { [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); diff --git a/osu.Game.Tests/Visual/Online/TestSceneLevelBadge.cs b/osu.Game.Tests/Visual/Online/TestSceneLevelBadge.cs new file mode 100644 index 0000000000..1fd54ca50f --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneLevelBadge.cs @@ -0,0 +1,54 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Overlays.Profile.Header.Components; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Tests.Visual.Online +{ + [TestFixture] + public partial class TestSceneLevelBadge : OsuTestScene + { + public TestSceneLevelBadge() + { + var levels = new List(); + + for (int i = 0; i < 11; i++) + { + levels.Add(new UserStatistics.LevelInfo + { + Current = i * 10 + }); + } + + levels.Add(new UserStatistics.LevelInfo { Current = 101 }); + levels.Add(new UserStatistics.LevelInfo { Current = 105 }); + levels.Add(new UserStatistics.LevelInfo { Current = 110 }); + levels.Add(new UserStatistics.LevelInfo { Current = 115 }); + levels.Add(new UserStatistics.LevelInfo { Current = 120 }); + + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Full, + Spacing = new Vector2(5), + ChildrenEnumerable = levels.Select(level => new LevelBadge + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(60), + LevelInfo = { Value = level } + }) + } + }; + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneMessageNotifier.cs b/osu.Game.Tests/Visual/Online/TestSceneMessageNotifier.cs index 5f7c8b3c51..ba2b160fd1 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneMessageNotifier.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneMessageNotifier.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -21,7 +23,7 @@ using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Tests.Visual.Online { - public class TestSceneMessageNotifier : OsuManualInputManagerTestScene + public partial class TestSceneMessageNotifier : OsuManualInputManagerTestScene { private APIUser friend; private Channel publicChannel; @@ -44,7 +46,7 @@ namespace osu.Game.Tests.Visual.Online Schedule(() => { - Child = testContainer = new TestContainer(new[] { publicChannel, privateMessageChannel }) + Child = testContainer = new TestContainer(API, new[] { publicChannel, privateMessageChannel }) { RelativeSizeAxes = Axes.Both, }; @@ -226,10 +228,10 @@ namespace osu.Game.Tests.Visual.Online InputManager.Click(MouseButton.Left); } - private class TestContainer : Container + private partial class TestContainer : Container { [Cached] - public ChannelManager ChannelManager { get; } = new ChannelManager(); + public ChannelManager ChannelManager { get; } [Cached(typeof(INotificationOverlay))] public NotificationOverlay NotificationOverlay { get; } = new NotificationOverlay @@ -245,9 +247,10 @@ namespace osu.Game.Tests.Visual.Online private readonly Channel[] channels; - public TestContainer(Channel[] channels) + public TestContainer(IAPIProvider api, Channel[] channels) { this.channels = channels; + ChannelManager = new ChannelManager(api); } [BackgroundDependencyLoader] diff --git a/osu.Game.Tests/Visual/Online/TestSceneNewsCard.cs b/osu.Game.Tests/Visual/Online/TestSceneNewsCard.cs index 17675bfbc0..001e6d925e 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneNewsCard.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneNewsCard.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Containers; using osu.Framework.Graphics; using osu.Game.Overlays.News; @@ -12,7 +14,7 @@ using System; namespace osu.Game.Tests.Visual.Online { - public class TestSceneNewsCard : OsuTestScene + public partial class TestSceneNewsCard : OsuTestScene { [Cached] private readonly OverlayColourProvider overlayColour = new OverlayColourProvider(OverlayColourScheme.Purple); diff --git a/osu.Game.Tests/Visual/Online/TestSceneNewsHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneNewsHeader.cs index 994c4fce53..2413c32d61 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneNewsHeader.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneNewsHeader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Overlays.News; using osu.Framework.Graphics; @@ -9,7 +11,7 @@ using osu.Framework.Allocation; namespace osu.Game.Tests.Visual.Online { - public class TestSceneNewsHeader : OsuTestScene + public partial class TestSceneNewsHeader : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); @@ -45,7 +47,7 @@ namespace osu.Game.Tests.Visual.Online AddAssert("1 tab total", () => header.TabCount == 1); } - private class TestHeader : NewsHeader + private partial class TestHeader : NewsHeader { public int TabCount => TabControl.Items.Count; } diff --git a/osu.Game.Tests/Visual/Online/TestSceneNewsOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneNewsOverlay.cs index f94c018b27..0e272c9cc8 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneNewsOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneNewsOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -13,7 +15,7 @@ using osu.Game.Overlays; namespace osu.Game.Tests.Visual.Online { - public class TestSceneNewsOverlay : OsuTestScene + public partial class TestSceneNewsOverlay : OsuTestScene { private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; diff --git a/osu.Game.Tests/Visual/Online/TestSceneNewsSidebar.cs b/osu.Game.Tests/Visual/Online/TestSceneNewsSidebar.cs index 382d76676a..ce5fc888aa 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneNewsSidebar.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneNewsSidebar.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -14,7 +16,7 @@ using static osu.Game.Overlays.News.Sidebar.YearsPanel; namespace osu.Game.Tests.Visual.Online { - public class TestSceneNewsSidebar : OsuTestScene + public partial class TestSceneNewsSidebar : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); @@ -209,7 +211,7 @@ namespace osu.Game.Tests.Visual.Online } }; - private class TestNewsSidebar : NewsSidebar + private partial class TestNewsSidebar : NewsSidebar { public Action YearChanged; @@ -217,7 +219,7 @@ namespace osu.Game.Tests.Visual.Online { base.LoadComplete(); - Metadata.BindValueChanged(metadata => + Metadata.BindValueChanged(_ => { foreach (var b in this.ChildrenOfType()) b.Action = () => YearChanged?.Invoke(b.Year); diff --git a/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs b/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs index b689b85490..10c2b2b9e1 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -9,12 +11,13 @@ using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Online.Chat; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; using osu.Game.Users; namespace osu.Game.Tests.Visual.Online { [HeadlessTest] - public class TestSceneNowPlayingCommand : OsuTestScene + public partial class TestSceneNowPlayingCommand : OsuTestScene { [Cached(typeof(IChannelPostTarget))] private PostTarget postTarget { get; set; } @@ -31,7 +34,7 @@ namespace osu.Game.Tests.Visual.Online { AddStep("Set activity", () => api.Activity.Value = new UserActivity.InLobby(null)); - AddStep("Run command", () => Add(new NowPlayingCommand())); + AddStep("Run command", () => Add(new NowPlayingCommand(new Channel()))); AddAssert("Check correct response", () => postTarget.LastMessage.Contains("is listening")); } @@ -39,9 +42,9 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestEditActivity() { - AddStep("Set activity", () => api.Activity.Value = new UserActivity.Editing(new BeatmapInfo())); + AddStep("Set activity", () => api.Activity.Value = new UserActivity.EditingBeatmap(new BeatmapInfo())); - AddStep("Run command", () => Add(new NowPlayingCommand())); + AddStep("Run command", () => Add(new NowPlayingCommand(new Channel()))); AddAssert("Check correct response", () => postTarget.LastMessage.Contains("is editing")); } @@ -51,7 +54,7 @@ namespace osu.Game.Tests.Visual.Online { AddStep("Set activity", () => api.Activity.Value = new UserActivity.InSoloGame(new BeatmapInfo(), new RulesetInfo())); - AddStep("Run command", () => Add(new NowPlayingCommand())); + AddStep("Run command", () => Add(new NowPlayingCommand(new Channel()))); AddAssert("Check correct response", () => postTarget.LastMessage.Contains("is playing")); } @@ -67,7 +70,7 @@ namespace osu.Game.Tests.Visual.Online BeatmapInfo = { OnlineID = hasOnlineId ? 1234 : -1 } }); - AddStep("Run command", () => Add(new NowPlayingCommand())); + AddStep("Run command", () => Add(new NowPlayingCommand(new Channel()))); if (hasOnlineId) AddAssert("Check link presence", () => postTarget.LastMessage.Contains("/b/1234")); @@ -75,7 +78,19 @@ namespace osu.Game.Tests.Visual.Online AddAssert("Check link not present", () => !postTarget.LastMessage.Contains("https://")); } - public class PostTarget : Component, IChannelPostTarget + [Test] + public void TestModPresence() + { + AddStep("Set activity", () => api.Activity.Value = new UserActivity.InSoloGame(new BeatmapInfo(), new RulesetInfo())); + + AddStep("Add Hidden mod", () => SelectedMods.Value = new[] { Ruleset.Value.CreateInstance().CreateMod() }); + + AddStep("Run command", () => Add(new NowPlayingCommand(new Channel()))); + + AddAssert("Check mod is present", () => postTarget.LastMessage.Contains("+HD")); + } + + public partial class PostTarget : Component, IChannelPostTarget { public void PostMessage(string text, bool isAction = false, Channel target = null) { diff --git a/osu.Game.Tests/Visual/Online/TestSceneOfflineCommentsContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneOfflineCommentsContainer.cs index 0587e8884f..4016fa7b68 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneOfflineCommentsContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneOfflineCommentsContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -17,7 +19,7 @@ using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Tests.Visual.Online { - public class TestSceneOfflineCommentsContainer : OsuTestScene + public partial class TestSceneOfflineCommentsContainer : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); @@ -182,7 +184,7 @@ namespace osu.Game.Tests.Visual.Online PinnedComments = new List(), }; - private class TestCommentsContainer : CommentsContainer + private partial class TestCommentsContainer : CommentsContainer { public new void AppendComments([NotNull] CommentBundle bundle) => base.AppendComments(bundle); diff --git a/osu.Game.Tests/Visual/Online/TestSceneOnlineBeatmapListingOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneOnlineBeatmapListingOverlay.cs index fe1701a554..ecfa76f395 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneOnlineBeatmapListingOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneOnlineBeatmapListingOverlay.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Overlays; using NUnit.Framework; namespace osu.Game.Tests.Visual.Online { [Description("uses online API")] - public class TestSceneOnlineBeatmapListingOverlay : OsuTestScene + public partial class TestSceneOnlineBeatmapListingOverlay : OsuTestScene { protected override bool UseOnlineAPI => true; diff --git a/osu.Game.Tests/Visual/Online/TestSceneOnlineBeatmapSetOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneOnlineBeatmapSetOverlay.cs new file mode 100644 index 0000000000..01b0b39661 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneOnlineBeatmapSetOverlay.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using NUnit.Framework; +using osu.Game.Overlays; + +namespace osu.Game.Tests.Visual.Online +{ + public partial class TestSceneOnlineBeatmapSetOverlay : OsuTestScene + { + private readonly BeatmapSetOverlay overlay; + + protected override bool UseOnlineAPI => true; + + public TestSceneOnlineBeatmapSetOverlay() + { + Add(overlay = new BeatmapSetOverlay()); + } + + [Test] + public void TestOnline() + { + AddStep(@"show online", () => overlay.FetchAndShowBeatmapSet(55)); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneOnlineViewContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneOnlineViewContainer.cs index ec183adbbc..6c8430e955 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneOnlineViewContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneOnlineViewContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -16,7 +18,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Online { [TestFixture] - public class TestSceneOnlineViewContainer : OsuTestScene + public partial class TestSceneOnlineViewContainer : OsuTestScene { private readonly TestOnlineViewContainer onlineView; @@ -65,7 +67,7 @@ namespace osu.Game.Tests.Visual.Online AddUntilStep("loading animation is visible", () => onlineView.LoadingSpinner.IsPresent); } - private class TestOnlineViewContainer : OnlineViewContainer + private partial class TestOnlineViewContainer : OnlineViewContainer { public new LoadingSpinner LoadingSpinner => base.LoadingSpinner; diff --git a/osu.Game.Tests/Visual/Online/TestScenePlayHistorySubsection.cs b/osu.Game.Tests/Visual/Online/TestScenePlayHistorySubsection.cs index a96fde6c20..106433d7ce 100644 --- a/osu.Game.Tests/Visual/Online/TestScenePlayHistorySubsection.cs +++ b/osu.Game.Tests/Visual/Online/TestScenePlayHistorySubsection.cs @@ -12,15 +12,17 @@ using System.Linq; using osu.Framework.Testing; using osu.Framework.Graphics.Shapes; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays.Profile; +using osu.Game.Rulesets.Osu; namespace osu.Game.Tests.Visual.Online { - public class TestScenePlayHistorySubsection : OsuTestScene + public partial class TestScenePlayHistorySubsection : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Red); - private readonly Bindable user = new Bindable(); + private readonly Bindable user = new Bindable(); private readonly PlayHistorySubsection section; public TestScenePlayHistorySubsection() @@ -43,49 +45,49 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestNullValues() { - AddStep("Load user", () => user.Value = user_with_null_values); + AddStep("Load user", () => user.Value = new UserProfileData(user_with_null_values, new OsuRuleset().RulesetInfo)); AddAssert("Section is hidden", () => section.Alpha == 0); } [Test] public void TestEmptyValues() { - AddStep("Load user", () => user.Value = user_with_empty_values); + AddStep("Load user", () => user.Value = new UserProfileData(user_with_empty_values, new OsuRuleset().RulesetInfo)); AddAssert("Section is hidden", () => section.Alpha == 0); } [Test] public void TestOneValue() { - AddStep("Load user", () => user.Value = user_with_one_value); + AddStep("Load user", () => user.Value = new UserProfileData(user_with_one_value, new OsuRuleset().RulesetInfo)); AddAssert("Section is hidden", () => section.Alpha == 0); } [Test] public void TestTwoValues() { - AddStep("Load user", () => user.Value = user_with_two_values); + AddStep("Load user", () => user.Value = new UserProfileData(user_with_two_values, new OsuRuleset().RulesetInfo)); AddAssert("Section is visible", () => section.Alpha == 1); } [Test] public void TestConstantValues() { - AddStep("Load user", () => user.Value = user_with_constant_values); + AddStep("Load user", () => user.Value = new UserProfileData(user_with_constant_values, new OsuRuleset().RulesetInfo)); AddAssert("Section is visible", () => section.Alpha == 1); } [Test] public void TestConstantZeroValues() { - AddStep("Load user", () => user.Value = user_with_zero_values); + AddStep("Load user", () => user.Value = new UserProfileData(user_with_zero_values, new OsuRuleset().RulesetInfo)); AddAssert("Section is visible", () => section.Alpha == 1); } [Test] public void TestFilledValues() { - AddStep("Load user", () => user.Value = user_with_filled_values); + AddStep("Load user", () => user.Value = new UserProfileData(user_with_filled_values, new OsuRuleset().RulesetInfo)); AddAssert("Section is visible", () => section.Alpha == 1); AddAssert("Array length is the same", () => user_with_filled_values.MonthlyPlayCounts.Length == getChartValuesLength()); } @@ -93,7 +95,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestMissingValues() { - AddStep("Load user", () => user.Value = user_with_missing_values); + AddStep("Load user", () => user.Value = new UserProfileData(user_with_missing_values, new OsuRuleset().RulesetInfo)); AddAssert("Section is visible", () => section.Alpha == 1); AddAssert("Array length is 7", () => getChartValuesLength() == 7); } diff --git a/osu.Game.Tests/Visual/Online/TestSceneProfileRulesetSelector.cs b/osu.Game.Tests/Visual/Online/TestSceneProfileRulesetSelector.cs index ae90872439..bb2ef1c1b0 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneProfileRulesetSelector.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneProfileRulesetSelector.cs @@ -11,35 +11,35 @@ using osu.Framework.Bindables; using osu.Game.Overlays; using osu.Framework.Allocation; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays.Profile; namespace osu.Game.Tests.Visual.Online { - public class TestSceneProfileRulesetSelector : OsuTestScene + public partial class TestSceneProfileRulesetSelector : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); public TestSceneProfileRulesetSelector() { - ProfileRulesetSelector selector; - var user = new Bindable(); + var user = new Bindable(); - Child = selector = new ProfileRulesetSelector + Child = new ProfileRulesetSelector { Anchor = Anchor.Centre, Origin = Anchor.Centre, User = { BindTarget = user } }; + AddStep("User on osu ruleset", () => user.Value = new UserProfileData(new APIUser { Id = 0, PlayMode = "osu" }, new OsuRuleset().RulesetInfo)); + AddStep("User on taiko ruleset", () => user.Value = new UserProfileData(new APIUser { Id = 1, PlayMode = "osu" }, new TaikoRuleset().RulesetInfo)); + AddStep("User on catch ruleset", () => user.Value = new UserProfileData(new APIUser { Id = 2, PlayMode = "osu" }, new CatchRuleset().RulesetInfo)); + AddStep("User on mania ruleset", () => user.Value = new UserProfileData(new APIUser { Id = 3, PlayMode = "osu" }, new ManiaRuleset().RulesetInfo)); - AddStep("set osu! as default", () => selector.SetDefaultRuleset(new OsuRuleset().RulesetInfo)); - AddStep("set taiko as default", () => selector.SetDefaultRuleset(new TaikoRuleset().RulesetInfo)); - AddStep("set catch as default", () => selector.SetDefaultRuleset(new CatchRuleset().RulesetInfo)); - AddStep("set mania as default", () => selector.SetDefaultRuleset(new ManiaRuleset().RulesetInfo)); + AddStep("User with osu as default", () => user.Value = new UserProfileData(new APIUser { Id = 0, PlayMode = "osu" }, new OsuRuleset().RulesetInfo)); + AddStep("User with taiko as default", () => user.Value = new UserProfileData(new APIUser { Id = 1, PlayMode = "taiko" }, new OsuRuleset().RulesetInfo)); + AddStep("User with catch as default", () => user.Value = new UserProfileData(new APIUser { Id = 2, PlayMode = "fruits" }, new OsuRuleset().RulesetInfo)); + AddStep("User with mania as default", () => user.Value = new UserProfileData(new APIUser { Id = 3, PlayMode = "mania" }, new OsuRuleset().RulesetInfo)); - AddStep("User with osu as default", () => user.Value = new APIUser { Id = 0, PlayMode = "osu" }); - AddStep("User with taiko as default", () => user.Value = new APIUser { Id = 1, PlayMode = "taiko" }); - AddStep("User with catch as default", () => user.Value = new APIUser { Id = 2, PlayMode = "fruits" }); - AddStep("User with mania as default", () => user.Value = new APIUser { Id = 3, PlayMode = "mania" }); AddStep("null user", () => user.Value = null); } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankGraph.cs b/osu.Game.Tests/Visual/Online/TestSceneRankGraph.cs index 61574a7f23..55817e38a6 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneRankGraph.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneRankGraph.cs @@ -1,12 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.Profile.Header.Components; @@ -16,38 +19,19 @@ using osuTK; namespace osu.Game.Tests.Visual.Online { [TestFixture] - public class TestSceneRankGraph : OsuTestScene + public partial class TestSceneRankGraph : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); - public TestSceneRankGraph() + private RankGraph graph = null!; + + private const int history_length = 89; + + [SetUpSteps] + public void SetUpSteps() { - RankGraph graph; - - int[] data = new int[89]; - int[] dataWithZeros = new int[89]; - int[] smallData = new int[89]; - int[] edgyData = new int[89]; - - for (int i = 0; i < 89; i++) - data[i] = dataWithZeros[i] = (i + 1) * 1000; - - for (int i = 20; i < 60; i++) - dataWithZeros[i] = 0; - - for (int i = 79; i < 89; i++) - smallData[i] = 100000 - i * 1000; - - bool edge = true; - - for (int i = 0; i < 20; i++) - { - edgyData[i] = 100000 + (edge ? 1000 : -1000) * (i + 1); - edge = !edge; - } - - Add(new Container + AddStep("create graph", () => Child = new Container { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -65,34 +49,70 @@ namespace osu.Game.Tests.Visual.Online } } }); + } + [Test] + public void TestNullUser() + { AddStep("null user", () => graph.Statistics.Value = null); + AddAssert("line graph hidden", () => this.ChildrenOfType().All(graph => graph.Alpha == 0)); + } + + [Test] + public void TestRankOnly() + { AddStep("rank only", () => { graph.Statistics.Value = new UserStatistics { + IsRanked = true, GlobalRank = 123456, PP = 12345, }; }); + AddAssert("line graph hidden", () => this.ChildrenOfType().All(graph => graph.Alpha == 0)); + } + + [Test] + public void TestWithRankHistory() + { + int[] data = new int[history_length]; + + for (int i = 0; i < history_length; i++) + data[i] = (i + 1) * 1000; AddStep("with rank history", () => { graph.Statistics.Value = new UserStatistics { + IsRanked = true, GlobalRank = 89000, PP = 12345, RankHistory = new APIRankHistory { - Data = data, + Data = data } }; }); + AddAssert("line graph shown", () => this.ChildrenOfType().All(graph => graph.Alpha == 1)); + } + + [Test] + public void TestRanksWithZeroValues() + { + int[] dataWithZeros = new int[history_length]; + + for (int i = 0; i < history_length; i++) + { + if (i < 20 || i >= 60) + dataWithZeros[i] = (i + 1) * 1000; + } AddStep("with zero values", () => { graph.Statistics.Value = new UserStatistics { + IsRanked = true, GlobalRank = 89000, PP = 12345, RankHistory = new APIRankHistory @@ -101,11 +121,22 @@ namespace osu.Game.Tests.Visual.Online } }; }); + AddAssert("line graph shown", () => this.ChildrenOfType().All(graph => graph.Alpha == 1)); + } + + [Test] + public void TestSmallAmountOfData() + { + int[] smallData = new int[history_length]; + + for (int i = history_length - 10; i < history_length; i++) + smallData[i] = 100000 - i * 1000; AddStep("small amount of data", () => { graph.Statistics.Value = new UserStatistics { + IsRanked = true, GlobalRank = 12000, PP = 12345, RankHistory = new APIRankHistory @@ -114,11 +145,27 @@ namespace osu.Game.Tests.Visual.Online } }; }); + AddAssert("line graph shown", () => this.ChildrenOfType().All(graph => graph.Alpha == 1)); + } + + [Test] + public void TestHistoryWithEdges() + { + int[] edgyData = new int[89]; + + bool edge = true; + + for (int i = 0; i < 20; i++) + { + edgyData[i] = 100000 + (edge ? 1000 : -1000) * (i + 1); + edge = !edge; + } AddStep("graph with edges", () => { graph.Statistics.Value = new UserStatistics { + IsRanked = true, GlobalRank = 12000, PP = 12345, RankHistory = new APIRankHistory @@ -127,6 +174,7 @@ namespace osu.Game.Tests.Visual.Online } }; }); + AddAssert("line graph shown", () => this.ChildrenOfType().All(graph => graph.Alpha == 1)); } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsCountryFilter.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsCountryFilter.cs index 458ba80712..dfefcd735e 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneRankingsCountryFilter.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsCountryFilter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Game.Overlays.Rankings; @@ -14,14 +16,14 @@ using osu.Framework.Allocation; namespace osu.Game.Tests.Visual.Online { - public class TestSceneRankingsCountryFilter : OsuTestScene + public partial class TestSceneRankingsCountryFilter : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); public TestSceneRankingsCountryFilter() { - var countryBindable = new Bindable(); + var countryBindable = new Bindable(); AddRange(new Drawable[] { @@ -54,20 +56,12 @@ namespace osu.Game.Tests.Visual.Online } }); - var country = new Country - { - FlagName = "BY", - FullName = "Belarus" - }; - var unknownCountry = new Country - { - FlagName = "CK", - FullName = "Cook Islands" - }; + const CountryCode country = CountryCode.BY; + const CountryCode unknown_country = CountryCode.CK; AddStep("Set country", () => countryBindable.Value = country); - AddStep("Set null country", () => countryBindable.Value = null); - AddStep("Set country with no flag", () => countryBindable.Value = unknownCountry); + AddStep("Set default country", () => countryBindable.Value = default); + AddStep("Set country with no flag", () => countryBindable.Value = unknown_country); } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsHeader.cs index 677952681c..5aef91bef1 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneRankingsHeader.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsHeader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Overlays; @@ -10,14 +12,14 @@ using osu.Game.Users; namespace osu.Game.Tests.Visual.Online { - public class TestSceneRankingsHeader : OsuTestScene + public partial class TestSceneRankingsHeader : OsuTestScene { [Cached] private readonly OverlayColourProvider overlayColour = new OverlayColourProvider(OverlayColourScheme.Green); public TestSceneRankingsHeader() { - var countryBindable = new Bindable(); + var countryBindable = new Bindable(); var ruleset = new Bindable(); var scope = new Bindable(); @@ -28,21 +30,12 @@ namespace osu.Game.Tests.Visual.Online Ruleset = { BindTarget = ruleset } }); - var country = new Country - { - FlagName = "BY", - FullName = "Belarus" - }; - - var unknownCountry = new Country - { - FlagName = "CK", - FullName = "Cook Islands" - }; + const CountryCode country = CountryCode.BY; + const CountryCode unknown_country = CountryCode.CK; AddStep("Set country", () => countryBindable.Value = country); AddStep("Set scope to Score", () => scope.Value = RankingsScope.Score); - AddStep("Set country with no flag", () => countryBindable.Value = unknownCountry); + AddStep("Set country with no flag", () => countryBindable.Value = unknown_country); } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs index 027f17fff4..d1c1164768 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; @@ -13,13 +15,13 @@ using osu.Game.Users; namespace osu.Game.Tests.Visual.Online { - public class TestSceneRankingsOverlay : OsuTestScene + public partial class TestSceneRankingsOverlay : OsuTestScene { protected override bool UseOnlineAPI => true; private TestRankingsOverlay rankingsOverlay; - private readonly Bindable countryBindable = new Bindable(); + private readonly Bindable countryBindable = new Bindable(); private readonly Bindable scope = new Bindable(); [SetUp] @@ -46,15 +48,15 @@ namespace osu.Game.Tests.Visual.Online public void TestFlagScopeDependency() { AddStep("Set scope to Score", () => scope.Value = RankingsScope.Score); - AddAssert("Check country is Null", () => countryBindable.Value == null); - AddStep("Set country", () => countryBindable.Value = us_country); + AddAssert("Check country is default", () => countryBindable.IsDefault); + AddStep("Set country", () => countryBindable.Value = CountryCode.US); AddAssert("Check scope is Performance", () => scope.Value == RankingsScope.Performance); } [Test] public void TestShowCountry() { - AddStep("Show US", () => rankingsOverlay.ShowCountry(us_country)); + AddStep("Show US", () => rankingsOverlay.ShowCountry(CountryCode.US)); } private void loadRankingsOverlay() @@ -67,15 +69,9 @@ namespace osu.Game.Tests.Visual.Online }; } - private static readonly Country us_country = new Country + private partial class TestRankingsOverlay : RankingsOverlay { - FlagName = "US", - FullName = "United States" - }; - - private class TestRankingsOverlay : RankingsOverlay - { - public new Bindable Country => base.Country; + public new Bindable Country => base.Country; } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsSpotlightSelector.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsSpotlightSelector.cs index d60222fa0b..119d79e2a9 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneRankingsSpotlightSelector.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsSpotlightSelector.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using NUnit.Framework; @@ -13,7 +15,7 @@ using osu.Game.Overlays.Rankings; namespace osu.Game.Tests.Visual.Online { - public class TestSceneRankingsSpotlightSelector : OsuTestScene + public partial class TestSceneRankingsSpotlightSelector : OsuTestScene { protected override bool UseOnlineAPI => true; diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsTables.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsTables.cs index 35e219f839..cd5e1cef08 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneRankingsTables.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsTables.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Graphics.Containers; using osu.Game.Overlays.Rankings.Tables; @@ -14,7 +16,7 @@ using osu.Game.Users; namespace osu.Game.Tests.Visual.Online { - public class TestSceneRankingsTables : OsuTestScene + public partial class TestSceneRankingsTables : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); @@ -55,8 +57,7 @@ namespace osu.Game.Tests.Visual.Online { new CountryStatistics { - Country = new Country { FlagName = "US", FullName = "United States" }, - FlagName = "US", + Code = CountryCode.US, ActiveUsers = 2_972_623, PlayCount = 3_086_515_743, RankedScore = 449_407_643_332_546, @@ -64,8 +65,7 @@ namespace osu.Game.Tests.Visual.Online }, new CountryStatistics { - Country = new Country { FlagName = "RU", FullName = "Russian Federation" }, - FlagName = "RU", + Code = CountryCode.RU, ActiveUsers = 1_609_989, PlayCount = 1_637_052_841, RankedScore = 221_660_827_473_004, @@ -84,7 +84,7 @@ namespace osu.Game.Tests.Visual.Online User = new APIUser { Username = "first active user", - Country = new Country { FlagName = "JP" }, + CountryCode = CountryCode.JP, Active = true, }, Accuracy = 0.9972, @@ -104,7 +104,7 @@ namespace osu.Game.Tests.Visual.Online User = new APIUser { Username = "inactive user", - Country = new Country { FlagName = "AU" }, + CountryCode = CountryCode.AU, Active = false, }, Accuracy = 0.9831, @@ -124,7 +124,7 @@ namespace osu.Game.Tests.Visual.Online User = new APIUser { Username = "second active user", - Country = new Country { FlagName = "PL" }, + CountryCode = CountryCode.PL, Active = true, }, Accuracy = 0.9584, diff --git a/osu.Game.Tests/Visual/Online/TestSceneScoreboardTime.cs b/osu.Game.Tests/Visual/Online/TestSceneScoreboardTime.cs index 7e33b5240c..ab85cd4a15 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneScoreboardTime.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneScoreboardTime.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -12,7 +14,7 @@ using osu.Game.Overlays.BeatmapSet.Scores; namespace osu.Game.Tests.Visual.Online { - public class TestSceneScoreboardTime : OsuTestScene + public partial class TestSceneScoreboardTime : OsuTestScene { private StopwatchClock stopwatch; diff --git a/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs index 8a304110dd..2bfbf76c10 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -16,6 +18,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapSet.Scores; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Users; using osuTK.Graphics; @@ -23,7 +26,7 @@ using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Tests.Visual.Online { - public class TestSceneScoresContainer : OsuTestScene + public partial class TestSceneScoresContainer : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); @@ -138,27 +141,36 @@ namespace osu.Game.Tests.Visual.Online AddUntilStep("best score not displayed", () => scoresContainer.ChildrenOfType().Count() == 1); } - private int onlineID = 1; + [Test] + public void TestUnprocessedPP() + { + AddStep("Load scores with unprocessed PP", () => + { + var allScores = createScores(); + allScores.Scores[0].PP = null; + allScores.UserScore = createUserBest(); + allScores.UserScore.Score.PP = null; + scoresContainer.Scores = allScores; + }); + } + + private ulong onlineID = 1; private APIScoresCollection createScores() { var scores = new APIScoresCollection { - Scores = new List + Scores = new List { - new APIScore + new SoloScoreInfo { - Date = DateTimeOffset.Now, - OnlineID = onlineID++, + EndedAt = DateTimeOffset.Now, + ID = onlineID++, User = new APIUser { Id = 6602580, Username = @"waaiiru", - Country = new Country - { - FullName = @"Spain", - FlagName = @"ES", - }, + CountryCode = CountryCode.ES, }, Mods = new[] { @@ -173,19 +185,15 @@ namespace osu.Game.Tests.Visual.Online TotalScore = 1234567890, Accuracy = 1, }, - new APIScore + new SoloScoreInfo { - Date = DateTimeOffset.Now, - OnlineID = onlineID++, + EndedAt = DateTimeOffset.Now, + ID = onlineID++, User = new APIUser { Id = 4608074, Username = @"Skycries", - Country = new Country - { - FullName = @"Brazil", - FlagName = @"BR", - }, + CountryCode = CountryCode.BR, }, Mods = new[] { @@ -199,19 +207,15 @@ namespace osu.Game.Tests.Visual.Online TotalScore = 1234789, Accuracy = 0.9997, }, - new APIScore + new SoloScoreInfo { - Date = DateTimeOffset.Now, - OnlineID = onlineID++, + EndedAt = DateTimeOffset.Now, + ID = onlineID++, User = new APIUser { Id = 1014222, Username = @"eLy", - Country = new Country - { - FullName = @"Japan", - FlagName = @"JP", - }, + CountryCode = CountryCode.JP, }, Mods = new[] { @@ -224,19 +228,15 @@ namespace osu.Game.Tests.Visual.Online TotalScore = 12345678, Accuracy = 0.9854, }, - new APIScore + new SoloScoreInfo { - Date = DateTimeOffset.Now, - OnlineID = onlineID++, + EndedAt = DateTimeOffset.Now, + ID = onlineID++, User = new APIUser { Id = 1541390, Username = @"Toukai", - Country = new Country - { - FullName = @"Canada", - FlagName = @"CA", - }, + CountryCode = CountryCode.CA, }, Mods = new[] { @@ -248,19 +248,15 @@ namespace osu.Game.Tests.Visual.Online TotalScore = 1234567, Accuracy = 0.8765, }, - new APIScore + new SoloScoreInfo { - Date = DateTimeOffset.Now, - OnlineID = onlineID++, + EndedAt = DateTimeOffset.Now, + ID = onlineID++, User = new APIUser { Id = 7151382, Username = @"Mayuri Hana", - Country = new Country - { - FullName = @"Thailand", - FlagName = @"TH", - }, + CountryCode = CountryCode.TH, }, Rank = ScoreRank.D, PP = 160, @@ -271,15 +267,26 @@ namespace osu.Game.Tests.Visual.Online } }; + const int initial_great_count = 2000; + const int initial_tick_count = 100; + + int greatCount = initial_great_count; + int tickCount = initial_tick_count; + foreach (var s in scores.Scores) { - s.Statistics = new Dictionary + s.Statistics = new Dictionary { - { "count_300", RNG.Next(2000) }, - { "count_100", RNG.Next(2000) }, - { "count_50", RNG.Next(2000) }, - { "count_miss", RNG.Next(2000) } + { HitResult.Great, greatCount }, + { HitResult.LargeTickHit, tickCount }, + { HitResult.Ok, RNG.Next(100) }, + { HitResult.Meh, RNG.Next(100) }, + { HitResult.Miss, initial_great_count - greatCount }, + { HitResult.LargeTickMiss, initial_tick_count - tickCount }, }; + + greatCount -= 100; + tickCount -= RNG.Next(1, 5); } return scores; @@ -287,19 +294,15 @@ namespace osu.Game.Tests.Visual.Online private APIScoreWithPosition createUserBest() => new APIScoreWithPosition { - Score = new APIScore + Score = new SoloScoreInfo { - Date = DateTimeOffset.Now, - OnlineID = onlineID++, + EndedAt = DateTimeOffset.Now, + ID = onlineID++, User = new APIUser { Id = 7151382, Username = @"Mayuri Hana", - Country = new Country - { - FullName = @"Thailand", - FlagName = @"TH", - }, + CountryCode = CountryCode.TH, }, Rank = ScoreRank.D, PP = 160, @@ -310,7 +313,7 @@ namespace osu.Game.Tests.Visual.Online Position = 1337, }; - private class TestScoresContainer : ScoresContainer + private partial class TestScoresContainer : ScoresContainer { public new APIScoresCollection Scores { diff --git a/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs b/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs index d7fa5a1f6d..905a085386 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Graphics.UserInterface; using osu.Framework.Allocation; @@ -8,7 +10,7 @@ using osu.Game.Overlays; namespace osu.Game.Tests.Visual.Online { - public class TestSceneShowMoreButton : OsuTestScene + public partial class TestSceneShowMoreButton : OsuTestScene { [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); diff --git a/osu.Game.Tests/Visual/Online/TestSceneSoloStatisticsWatcher.cs b/osu.Game.Tests/Visual/Online/TestSceneSoloStatisticsWatcher.cs new file mode 100644 index 0000000000..e62e53bd02 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneSoloStatisticsWatcher.cs @@ -0,0 +1,302 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Testing; +using osu.Game.Models; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Solo; +using osu.Game.Online.Spectator; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Scoring; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Online +{ + [HeadlessTest] + public partial class TestSceneSoloStatisticsWatcher : OsuTestScene + { + protected override bool UseOnlineAPI => false; + + private SoloStatisticsWatcher watcher = null!; + + [Resolved] + private SpectatorClient spectatorClient { get; set; } = null!; + + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + + private Action? handleGetUsersRequest; + private Action? handleGetUserRequest; + + private IDisposable? subscription; + + private readonly Dictionary<(int userId, string rulesetName), UserStatistics> serverSideStatistics = new Dictionary<(int userId, string rulesetName), UserStatistics>(); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("clear server-side stats", () => serverSideStatistics.Clear()); + AddStep("set up request handling", () => + { + handleGetUserRequest = null; + handleGetUsersRequest = null; + + dummyAPI.HandleRequest = request => + { + switch (request) + { + case GetUsersRequest getUsersRequest: + if (handleGetUsersRequest != null) + { + handleGetUsersRequest?.Invoke(getUsersRequest); + } + else + { + int userId = getUsersRequest.UserIds.Single(); + var response = new GetUsersResponse + { + Users = new List + { + new APIUser + { + Id = userId, + RulesetsStatistics = new Dictionary + { + ["osu"] = tryGetStatistics(userId, "osu"), + ["taiko"] = tryGetStatistics(userId, "taiko"), + ["fruits"] = tryGetStatistics(userId, "fruits"), + ["mania"] = tryGetStatistics(userId, "mania"), + } + } + } + }; + getUsersRequest.TriggerSuccess(response); + } + + return true; + + case GetUserRequest getUserRequest: + if (handleGetUserRequest != null) + { + handleGetUserRequest.Invoke(getUserRequest); + } + else + { + int userId = int.Parse(getUserRequest.Lookup); + string rulesetName = getUserRequest.Ruleset.ShortName; + var response = new APIUser + { + Id = userId, + Statistics = tryGetStatistics(userId, rulesetName) + }; + getUserRequest.TriggerSuccess(response); + } + + return true; + + default: + return false; + } + }; + }); + + AddStep("create watcher", () => + { + Child = watcher = new SoloStatisticsWatcher(); + }); + } + + private UserStatistics tryGetStatistics(int userId, string rulesetName) + => serverSideStatistics.TryGetValue((userId, rulesetName), out var stats) ? stats : new UserStatistics(); + + [Test] + public void TestStatisticsUpdateFiredAfterRegistrationAddedAndScoreProcessed() + { + int userId = getUserId(); + long scoreId = getScoreId(); + setUpUser(userId); + + var ruleset = new OsuRuleset().RulesetInfo; + + SoloStatisticsUpdate? update = null; + registerForUpdates(scoreId, ruleset, receivedUpdate => update = receivedUpdate); + + feignScoreProcessing(userId, ruleset, 5_000_000); + + AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(userId, scoreId)); + AddUntilStep("update received", () => update != null); + AddAssert("values before are correct", () => update!.Before.TotalScore, () => Is.EqualTo(4_000_000)); + AddAssert("values after are correct", () => update!.After.TotalScore, () => Is.EqualTo(5_000_000)); + } + + [Test] + public void TestStatisticsUpdateFiredAfterScoreProcessedAndRegistrationAdded() + { + int userId = getUserId(); + setUpUser(userId); + + long scoreId = getScoreId(); + var ruleset = new OsuRuleset().RulesetInfo; + + // note ordering - in this test processing completes *before* the registration is added. + feignScoreProcessing(userId, ruleset, 5_000_000); + + SoloStatisticsUpdate? update = null; + registerForUpdates(scoreId, ruleset, receivedUpdate => update = receivedUpdate); + + AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(userId, scoreId)); + AddUntilStep("update received", () => update != null); + AddAssert("values before are correct", () => update!.Before.TotalScore, () => Is.EqualTo(4_000_000)); + AddAssert("values after are correct", () => update!.After.TotalScore, () => Is.EqualTo(5_000_000)); + } + + [Test] + public void TestStatisticsUpdateNotFiredIfUserLoggedOut() + { + int userId = getUserId(); + setUpUser(userId); + + long scoreId = getScoreId(); + var ruleset = new OsuRuleset().RulesetInfo; + + SoloStatisticsUpdate? update = null; + registerForUpdates(scoreId, ruleset, receivedUpdate => update = receivedUpdate); + + feignScoreProcessing(userId, ruleset, 5_000_000); + + AddStep("log out user", () => dummyAPI.Logout()); + + AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(userId, scoreId)); + AddWaitStep("wait a bit", 5); + AddAssert("update not received", () => update == null); + + AddStep("log in user", () => dummyAPI.Login("user", "password")); + } + + [Test] + public void TestStatisticsUpdateNotFiredIfAnotherUserLoggedIn() + { + int userId = getUserId(); + setUpUser(userId); + + long scoreId = getScoreId(); + var ruleset = new OsuRuleset().RulesetInfo; + + SoloStatisticsUpdate? update = null; + registerForUpdates(scoreId, ruleset, receivedUpdate => update = receivedUpdate); + + feignScoreProcessing(userId, ruleset, 5_000_000); + + AddStep("change user", () => dummyAPI.LocalUser.Value = new APIUser { Id = getUserId() }); + + AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(userId, scoreId)); + AddWaitStep("wait a bit", 5); + AddAssert("update not received", () => update == null); + } + + [Test] + public void TestStatisticsUpdateNotFiredIfScoreIdDoesNotMatch() + { + int userId = getUserId(); + setUpUser(userId); + + long scoreId = getScoreId(); + var ruleset = new OsuRuleset().RulesetInfo; + + SoloStatisticsUpdate? update = null; + registerForUpdates(scoreId, ruleset, receivedUpdate => update = receivedUpdate); + + feignScoreProcessing(userId, ruleset, 5_000_000); + + AddStep("signal another score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(userId, getScoreId())); + AddWaitStep("wait a bit", 5); + AddAssert("update not received", () => update == null); + } + + // the behaviour exercised in this test may not be final, it is mostly assumed for simplicity. + // in the long run we may want each score's update to be entirely isolated from others, rather than have prior unobserved updates merge into the latest. + [Test] + public void TestIgnoredScoreUpdateIsMergedIntoNextOne() + { + int userId = getUserId(); + setUpUser(userId); + + long firstScoreId = getScoreId(); + var ruleset = new OsuRuleset().RulesetInfo; + + feignScoreProcessing(userId, ruleset, 5_000_000); + + AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(userId, firstScoreId)); + + long secondScoreId = getScoreId(); + + feignScoreProcessing(userId, ruleset, 6_000_000); + + SoloStatisticsUpdate? update = null; + registerForUpdates(secondScoreId, ruleset, receivedUpdate => update = receivedUpdate); + + AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(userId, secondScoreId)); + AddUntilStep("update received", () => update != null); + AddAssert("values before are correct", () => update!.Before.TotalScore, () => Is.EqualTo(4_000_000)); + AddAssert("values after are correct", () => update!.After.TotalScore, () => Is.EqualTo(6_000_000)); + } + + [Test] + public void TestStatisticsUpdateNotFiredAfterSubscriptionDisposal() + { + int userId = getUserId(); + setUpUser(userId); + + long scoreId = getScoreId(); + var ruleset = new OsuRuleset().RulesetInfo; + + SoloStatisticsUpdate? update = null; + registerForUpdates(scoreId, ruleset, receivedUpdate => update = receivedUpdate); + AddStep("unsubscribe", () => subscription!.Dispose()); + + feignScoreProcessing(userId, ruleset, 5_000_000); + + AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(userId, scoreId)); + AddWaitStep("wait a bit", 5); + AddAssert("update not received", () => update == null); + } + + private int nextUserId = 2000; + private long nextScoreId = 50000; + + private int getUserId() => ++nextUserId; + private long getScoreId() => ++nextScoreId; + + private void setUpUser(int userId) + { + AddStep("fetch initial stats", () => + { + serverSideStatistics[(userId, "osu")] = new UserStatistics { TotalScore = 4_000_000 }; + serverSideStatistics[(userId, "taiko")] = new UserStatistics { TotalScore = 3_000_000 }; + serverSideStatistics[(userId, "fruits")] = new UserStatistics { TotalScore = 2_000_000 }; + serverSideStatistics[(userId, "mania")] = new UserStatistics { TotalScore = 1_000_000 }; + + dummyAPI.LocalUser.Value = new APIUser { Id = userId }; + }); + } + + private void registerForUpdates(long scoreId, RulesetInfo rulesetInfo, Action onUpdateReady) => + AddStep("register for updates", () => subscription = watcher.RegisterForStatisticsUpdateAfter( + new ScoreInfo(Beatmap.Value.BeatmapInfo, new OsuRuleset().RulesetInfo, new RealmUser()) + { + Ruleset = rulesetInfo, + OnlineID = scoreId + }, + onUpdateReady)); + + private void feignScoreProcessing(int userId, RulesetInfo rulesetInfo, long newTotalScore) + => AddStep("feign score processing", () => serverSideStatistics[(userId, rulesetInfo.ShortName)] = new UserStatistics { TotalScore = newTotalScore }); + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneSpotlightsLayout.cs b/osu.Game.Tests/Visual/Online/TestSceneSpotlightsLayout.cs index 266dcb013b..4cbcaaac85 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneSpotlightsLayout.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneSpotlightsLayout.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -15,7 +17,7 @@ using osu.Game.Rulesets.Taiko; namespace osu.Game.Tests.Visual.Online { - public class TestSceneSpotlightsLayout : OsuTestScene + public partial class TestSceneSpotlightsLayout : OsuTestScene { protected override bool UseOnlineAPI => true; diff --git a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs index cb52f41c33..d7f79d3e30 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Online.Chat; @@ -11,13 +13,14 @@ using NUnit.Framework; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Framework.Utils; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Chat; using osuTK.Input; namespace osu.Game.Tests.Visual.Online { - public class TestSceneStandAloneChatDisplay : OsuManualInputManagerTestScene + public partial class TestSceneStandAloneChatDisplay : OsuManualInputManagerTestScene { private readonly APIUser admin = new APIUser { @@ -44,17 +47,26 @@ namespace osu.Game.Tests.Visual.Online Id = 5, }; - [Cached] - private ChannelManager channelManager = new ChannelManager(); + private ChannelManager channelManager; private TestStandAloneChatDisplay chatDisplay; + private TestStandAloneChatDisplay chatWithTextBox; + private TestStandAloneChatDisplay chatWithTextBox2; private int messageIdSequence; private Channel testChannel; - public TestSceneStandAloneChatDisplay() + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { - Add(channelManager); + var api = parent.Get(); + + Add(channelManager = new ChannelManager(api)); + + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + dependencies.Cache(channelManager); + + return dependencies; } [SetUp] @@ -63,7 +75,12 @@ namespace osu.Game.Tests.Visual.Online messageIdSequence = 0; channelManager.CurrentChannel.Value = testChannel = new Channel(); - Children = new[] + reinitialiseDrawableDisplay(); + }); + + private void reinitialiseDrawableDisplay() + { + Children = new Drawable[] { chatDisplay = new TestStandAloneChatDisplay { @@ -73,22 +90,38 @@ namespace osu.Game.Tests.Visual.Online Size = new Vector2(400, 80), Channel = { Value = testChannel }, }, - new TestStandAloneChatDisplay(true) + new FillFlowContainer { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, Margin = new MarginPadding(20), - Size = new Vector2(400, 150), - Channel = { Value = testChannel }, + Children = new[] + { + chatWithTextBox = new TestStandAloneChatDisplay(true) + { + Margin = new MarginPadding(20), + Size = new Vector2(400, 150), + Channel = { Value = testChannel }, + }, + chatWithTextBox2 = new TestStandAloneChatDisplay(true) + { + Margin = new MarginPadding(20), + Size = new Vector2(400, 150), + Channel = { Value = testChannel }, + }, + } } }; - }); + } [Test] public void TestSystemMessageOrdering() { var standardMessage = new Message(messageIdSequence++) { + Timestamp = DateTimeOffset.Now, Sender = admin, Content = "I am a wang!" }; @@ -96,14 +129,45 @@ namespace osu.Game.Tests.Visual.Online var infoMessage1 = new InfoMessage($"the system is calling {messageIdSequence++}"); var infoMessage2 = new InfoMessage($"the system is calling {messageIdSequence++}"); + var standardMessage2 = new Message(messageIdSequence++) + { + Timestamp = DateTimeOffset.Now, + Sender = admin, + Content = "I am a wang!" + }; + AddStep("message from admin", () => testChannel.AddNewMessages(standardMessage)); AddStep("message from system", () => testChannel.AddNewMessages(infoMessage1)); AddStep("message from system", () => testChannel.AddNewMessages(infoMessage2)); + AddStep("message from admin", () => testChannel.AddNewMessages(standardMessage2)); - AddAssert("message order is correct", () => testChannel.Messages.Count == 3 - && testChannel.Messages[0] == standardMessage - && testChannel.Messages[1] == infoMessage1 - && testChannel.Messages[2] == infoMessage2); + AddAssert("count is correct", () => testChannel.Messages.Count, () => Is.EqualTo(4)); + + AddAssert("message order is correct", () => testChannel.Messages, () => Is.EqualTo(new[] + { + standardMessage, + infoMessage1, + infoMessage2, + standardMessage2 + })); + + AddAssert("displayed order is correct", () => chatDisplay.DrawableChannel.ChildrenOfType().Select(c => c.Message), () => Is.EqualTo(new[] + { + standardMessage, + infoMessage1, + infoMessage2, + standardMessage2 + })); + + AddStep("reinit drawable channel", reinitialiseDrawableDisplay); + + AddAssert("displayed order is still correct", () => chatDisplay.DrawableChannel.ChildrenOfType().Select(c => c.Message), () => Is.EqualTo(new[] + { + standardMessage, + infoMessage1, + infoMessage2, + standardMessage2 + })); } [Test] @@ -304,6 +368,13 @@ namespace osu.Game.Tests.Visual.Online checkScrolledToBottom(); } + [Test] + public void TestTextBoxSync() + { + AddStep("type 'hello' to text box 1", () => chatWithTextBox.ChildrenOfType().Single().Text = "hello"); + AddAssert("text box 2 contains 'hello'", () => chatWithTextBox2.ChildrenOfType().Single().Text == "hello"); + } + private void fillChat(int count = 10) { AddStep("fill chat", () => @@ -391,7 +462,7 @@ namespace osu.Game.Tests.Visual.Online private void checkNotScrolledToBottom() => AddUntilStep("not scrolled to bottom", () => !chatDisplay.ScrolledToBottom); - private class TestStandAloneChatDisplay : StandAloneChatDisplay + private partial class TestStandAloneChatDisplay : StandAloneChatDisplay { public TestStandAloneChatDisplay(bool textBox = false) : base(textBox) diff --git a/osu.Game.Tests/Visual/Online/TestSceneTotalCommentsCounter.cs b/osu.Game.Tests/Visual/Online/TestSceneTotalCommentsCounter.cs index f168ae5035..8af87dd597 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneTotalCommentsCounter.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneTotalCommentsCounter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Bindables; using osu.Game.Overlays.Comments; @@ -10,7 +12,7 @@ using osu.Game.Overlays; namespace osu.Game.Tests.Visual.Online { - public class TestSceneTotalCommentsCounter : OsuTestScene + public partial class TestSceneTotalCommentsCounter : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserHistoryGraph.cs b/osu.Game.Tests/Visual/Online/TestSceneUserHistoryGraph.cs index 3f4a5cd420..454242270d 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserHistoryGraph.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserHistoryGraph.cs @@ -10,7 +10,7 @@ using osu.Game.Overlays.Profile.Sections.Historical; namespace osu.Game.Tests.Visual.Online { - public class TestSceneUserHistoryGraph : OsuTestScene + public partial class TestSceneUserHistoryGraph : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs index 6c3678b0d2..a047e2f0c5 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Framework.Allocation; @@ -9,13 +11,15 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; +using osu.Game.Scoring; +using osu.Game.Tests.Beatmaps; using osu.Game.Users; using osuTK; namespace osu.Game.Tests.Visual.Online { [TestFixture] - public class TestSceneUserPanel : OsuTestScene + public partial class TestSceneUserPanel : OsuTestScene { private readonly Bindable activity = new Bindable(); private readonly Bindable status = new Bindable(); @@ -58,7 +62,7 @@ namespace osu.Game.Tests.Visual.Online { Username = @"flyte", Id = 3103765, - Country = new Country { FlagName = @"JP" }, + CountryCode = CountryCode.JP, CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg", Status = { Value = new UserStatusOnline() } }) { Width = 300 }, @@ -66,7 +70,7 @@ namespace osu.Game.Tests.Visual.Online { Username = @"peppy", Id = 2, - Country = new Country { FlagName = @"AU" }, + CountryCode = CountryCode.AU, CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", IsSupporter = true, SupportLevel = 3, @@ -75,7 +79,7 @@ namespace osu.Game.Tests.Visual.Online { Username = @"Evast", Id = 8195163, - Country = new Country { FlagName = @"BY" }, + CountryCode = CountryCode.BY, CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", IsOnline = false, LastVisit = DateTimeOffset.Now @@ -105,14 +109,16 @@ namespace osu.Game.Tests.Visual.Online AddStep("set online status", () => status.Value = new UserStatusOnline()); AddStep("idle", () => activity.Value = null); - AddStep("spectating", () => activity.Value = new UserActivity.Spectating()); + AddStep("watching replay", () => activity.Value = new UserActivity.WatchingReplay(createScore(@"nats"))); + AddStep("spectating user", () => activity.Value = new UserActivity.SpectatingUser(createScore(@"mrekk"))); AddStep("solo (osu!)", () => activity.Value = soloGameStatusForRuleset(0)); AddStep("solo (osu!taiko)", () => activity.Value = soloGameStatusForRuleset(1)); AddStep("solo (osu!catch)", () => activity.Value = soloGameStatusForRuleset(2)); AddStep("solo (osu!mania)", () => activity.Value = soloGameStatusForRuleset(3)); AddStep("choosing", () => activity.Value = new UserActivity.ChoosingBeatmap()); - AddStep("editing", () => activity.Value = new UserActivity.Editing(null)); - AddStep("modding", () => activity.Value = new UserActivity.Modding()); + AddStep("editing beatmap", () => activity.Value = new UserActivity.EditingBeatmap(null)); + AddStep("modding beatmap", () => activity.Value = new UserActivity.ModdingBeatmap(null)); + AddStep("testing beatmap", () => activity.Value = new UserActivity.TestingBeatmap(null, null)); } [Test] @@ -130,7 +136,15 @@ namespace osu.Game.Tests.Visual.Online private UserActivity soloGameStatusForRuleset(int rulesetId) => new UserActivity.InSoloGame(null, rulesetStore.GetRuleset(rulesetId)); - private class TestUserListPanel : UserListPanel + private ScoreInfo createScore(string name) => new ScoreInfo(new TestBeatmap(Ruleset.Value).BeatmapInfo) + { + User = new APIUser + { + Username = name, + } + }; + + private partial class TestUserListPanel : UserListPanel { public TestUserListPanel(APIUser user) : base(user) diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs index dda9543159..640e895b6c 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs @@ -6,19 +6,24 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Testing; +using osu.Game.Configuration; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.Profile; +using osu.Game.Rulesets.Osu; using osu.Game.Users; namespace osu.Game.Tests.Visual.Online { - public class TestSceneUserProfileHeader : OsuTestScene + public partial class TestSceneUserProfileHeader : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); - private ProfileHeader header; + [Resolved] + private OsuConfigManager configManager { get; set; } = null!; + + private ProfileHeader header = null!; [SetUpSteps] public void SetUpSteps() @@ -29,36 +34,53 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestBasic() { - AddStep("Show example user", () => header.User.Value = TestSceneUserProfileOverlay.TEST_USER); + AddStep("Show example user", () => header.User.Value = new UserProfileData(TestSceneUserProfileOverlay.TEST_USER, new OsuRuleset().RulesetInfo)); + } + + [Test] + public void TestProfileCoverExpanded() + { + AddStep("Set cover to expanded", () => configManager.SetValue(OsuSetting.ProfileCoverExpanded, true)); + AddStep("Show example user", () => header.User.Value = new UserProfileData(TestSceneUserProfileOverlay.TEST_USER, new OsuRuleset().RulesetInfo)); + AddUntilStep("Cover is expanded", () => header.ChildrenOfType().Single().Height, () => Is.GreaterThan(0)); + } + + [Test] + public void TestProfileCoverCollapsed() + { + AddStep("Set cover to collapsed", () => configManager.SetValue(OsuSetting.ProfileCoverExpanded, false)); + AddStep("Show example user", () => header.User.Value = new UserProfileData(TestSceneUserProfileOverlay.TEST_USER, new OsuRuleset().RulesetInfo)); + AddUntilStep("Cover is collapsed", () => header.ChildrenOfType().Single().Height, () => Is.EqualTo(0)); } [Test] public void TestOnlineState() { - AddStep("Show online user", () => header.User.Value = new APIUser + AddStep("Show online user", () => header.User.Value = new UserProfileData(new APIUser { Id = 1001, Username = "IAmOnline", LastVisit = DateTimeOffset.Now, IsOnline = true, - }); + }, new OsuRuleset().RulesetInfo)); - AddStep("Show offline user", () => header.User.Value = new APIUser + AddStep("Show offline user", () => header.User.Value = new UserProfileData(new APIUser { Id = 1002, Username = "IAmOffline", LastVisit = DateTimeOffset.Now.AddDays(-10), IsOnline = false, - }); + }, new OsuRuleset().RulesetInfo)); } [Test] public void TestRankedState() { - AddStep("Show ranked user", () => header.User.Value = new APIUser + AddStep("Show ranked user", () => header.User.Value = new UserProfileData(new APIUser { Id = 2001, Username = "RankedUser", + Groups = new[] { new APIUserGroup { Colour = "#EB47D0", ShortName = "DEV", Name = "Developers" } }, Statistics = new UserStatistics { IsRanked = true, @@ -70,9 +92,9 @@ namespace osu.Game.Tests.Visual.Online Data = Enumerable.Range(2345, 45).Concat(Enumerable.Range(2109, 40)).ToArray() }, } - }); + }, new OsuRuleset().RulesetInfo)); - AddStep("Show unranked user", () => header.User.Value = new APIUser + AddStep("Show unranked user", () => header.User.Value = new UserProfileData(new APIUser { Id = 2002, Username = "UnrankedUser", @@ -86,7 +108,7 @@ namespace osu.Game.Tests.Visual.Online Data = Enumerable.Range(2345, 85).ToArray() }, } - }); + }, new OsuRuleset().RulesetInfo)); } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs index 78e2ceb45b..4278c46d6a 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs @@ -4,29 +4,106 @@ using System; using System.Linq; using NUnit.Framework; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; -using osu.Game.Overlays.Profile; using osu.Game.Users; namespace osu.Game.Tests.Visual.Online { [TestFixture] - public class TestSceneUserProfileOverlay : OsuTestScene + public partial class TestSceneUserProfileOverlay : OsuTestScene { - protected override bool UseOnlineAPI => true; + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; - private readonly TestUserProfileOverlay profile; + private UserProfileOverlay profile = null!; + + [SetUpSteps] + public void SetUp() + { + AddStep("create profile overlay", () => Child = profile = new UserProfileOverlay()); + } + + [Test] + public void TestBlank() + { + AddStep("show overlay", () => profile.Show()); + } + + [Test] + public void TestActualUser() + { + AddStep("set up request handling", () => + { + dummyAPI.HandleRequest = req => + { + if (req is GetUserRequest getUserRequest) + { + getUserRequest.TriggerSuccess(TEST_USER); + return true; + } + + return false; + }; + }); + AddStep("show user", () => profile.ShowUser(new APIUser { Id = 1 })); + AddToggleStep("toggle visibility", visible => profile.State.Value = visible ? Visibility.Visible : Visibility.Hidden); + AddStep("log out", () => dummyAPI.Logout()); + AddStep("log back in", () => dummyAPI.Login("username", "password")); + } + + [Test] + public void TestLoading() + { + GetUserRequest pendingRequest = null!; + + AddStep("set up request handling", () => + { + dummyAPI.HandleRequest = req => + { + if (req is GetUserRequest getUserRequest) + { + pendingRequest = getUserRequest; + return true; + } + + return false; + }; + }); + AddStep("show user", () => profile.ShowUser(new APIUser { Id = 1 })); + AddWaitStep("wait some", 3); + AddStep("complete request", () => pendingRequest.TriggerSuccess(TEST_USER)); + } public static readonly APIUser TEST_USER = new APIUser { Username = @"Somebody", Id = 1, - Country = new Country { FullName = @"Alien" }, + CountryCode = CountryCode.JP, CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c1.jpg", JoinDate = DateTimeOffset.Now.AddDays(-1), LastVisit = DateTimeOffset.Now, - ProfileOrder = new[] { "me" }, + Groups = new[] + { + new APIUserGroup { Colour = "#EB47D0", ShortName = "DEV", Name = "Developers" }, + new APIUserGroup { Colour = "#A347EB", ShortName = "BN", Name = "Beatmap Nominators", Playmodes = new[] { "mania" } }, + new APIUserGroup { Colour = "#A347EB", ShortName = "BN", Name = "Beatmap Nominators", Playmodes = new[] { "osu", "taiko" } }, + new APIUserGroup { Colour = "#A347EB", ShortName = "BN", Name = "Beatmap Nominators", Playmodes = new[] { "osu", "taiko", "fruits", "mania" } }, + new APIUserGroup { Colour = "#A347EB", ShortName = "BN", Name = "Beatmap Nominators (Probationary)", Playmodes = new[] { "osu", "taiko", "fruits", "mania" }, IsProbationary = true } + }, + ProfileOrder = new[] + { + @"me", + @"recent_activity", + @"beatmaps", + @"historical", + @"kudosu", + @"top_ranks", + @"medals" + }, Statistics = new UserStatistics { IsRanked = true, @@ -44,73 +121,39 @@ namespace osu.Game.Tests.Visual.Online Data = Enumerable.Range(2345, 45).Concat(Enumerable.Range(2109, 40)).ToArray() }, }, + TournamentBanner = new TournamentBanner + { + 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[] { new Badge { AwardedAt = DateTimeOffset.FromUnixTimeSeconds(1505741569), Description = "Outstanding help by being a voluntary test subject.", - ImageUrl = "https://assets.ppy.sh/profile-badges/contributor.jpg" - } + ImageUrl = "https://assets.ppy.sh/profile-badges/contributor.jpg", + Url = "https://osu.ppy.sh/wiki/en/People/Community_Contributors", + }, + new Badge + { + AwardedAt = DateTimeOffset.FromUnixTimeSeconds(1505741569), + Description = "Badge without a url.", + ImageUrl = "https://assets.ppy.sh/profile-badges/contributor.jpg", + }, }, Title = "osu!volunteer", Colour = "ff0000", Achievements = Array.Empty(), + PlayMode = "osu", + Kudosu = new APIUser.KudosuCount + { + Available = 10, + Total = 50 + }, + SupportLevel = 2, }; - - public TestSceneUserProfileOverlay() - { - Add(profile = new TestUserProfileOverlay()); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - AddStep("Show offline dummy", () => profile.ShowUser(TEST_USER)); - - AddStep("Show null dummy", () => profile.ShowUser(new APIUser - { - Username = @"Null", - Id = 1, - })); - - AddStep("Show ppy", () => profile.ShowUser(new APIUser - { - Username = @"peppy", - Id = 2, - IsSupporter = true, - Country = new Country { FullName = @"Australia", FlagName = @"AU" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg" - })); - - AddStep("Show flyte", () => profile.ShowUser(new APIUser - { - Username = @"flyte", - Id = 3103765, - Country = new Country { FullName = @"Japan", FlagName = @"JP" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg" - })); - - AddStep("Show bancho", () => profile.ShowUser(new APIUser - { - Username = @"BanchoBot", - Id = 3, - IsBot = true, - Country = new Country { FullName = @"Saint Helena", FlagName = @"SH" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c4.jpg" - })); - - AddStep("Show ppy from username", () => profile.ShowUser(new APIUser { Username = @"peppy" })); - AddStep("Show flyte from username", () => profile.ShowUser(new APIUser { Username = @"flyte" })); - - AddStep("Hide", profile.Hide); - AddStep("Show without reload", profile.Show); - } - - private class TestUserProfileOverlay : UserProfileOverlay - { - public new ProfileHeader Header => base.Header; - } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernames.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernames.cs index 30774689a2..921738d331 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernames.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernames.cs @@ -10,9 +10,9 @@ using osu.Game.Overlays.Profile.Header.Components; namespace osu.Game.Tests.Visual.Online { [TestFixture] - public class TestSceneUserProfilePreviousUsernames : OsuTestScene + public partial class TestSceneUserProfilePreviousUsernames : OsuTestScene { - private PreviousUsernames container; + private PreviousUsernames container = null!; [SetUp] public void SetUp() => Schedule(() => @@ -48,7 +48,7 @@ namespace osu.Game.Tests.Visual.Online AddUntilStep("Is hidden", () => container.Alpha == 0); } - private static readonly APIUser[] users = + private static readonly APIUser?[] users = { new APIUser { Id = 1, PreviousUsernames = new[] { "username1" } }, new APIUser { Id = 2, PreviousUsernames = new[] { "longusername", "longerusername" } }, diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileRecentSection.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileRecentSection.cs index 0973076c40..9d0ea80533 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileRecentSection.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileRecentSection.cs @@ -18,7 +18,7 @@ using osu.Game.Overlays.Profile.Sections.Recent; namespace osu.Game.Tests.Visual.Online { [TestFixture] - public class TestSceneUserProfileRecentSection : OsuTestScene + public partial class TestSceneUserProfileRecentSection : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); @@ -81,6 +81,20 @@ namespace osu.Game.Tests.Visual.Online Beatmap = dummyBeatmap, }, new APIRecentActivity + { + User = dummyUser, + Type = RecentActivityType.BeatmapsetApprove, + Approval = BeatmapApproval.Approved, + Beatmapset = dummyBeatmap, + }, + new APIRecentActivity + { + User = dummyUser, + Type = RecentActivityType.BeatmapsetApprove, + Approval = BeatmapApproval.Loved, + Beatmapset = dummyBeatmap, + }, + new APIRecentActivity { User = dummyUser, Type = RecentActivityType.BeatmapsetApprove, @@ -88,6 +102,13 @@ namespace osu.Game.Tests.Visual.Online Beatmapset = dummyBeatmap, }, new APIRecentActivity + { + User = dummyUser, + Type = RecentActivityType.BeatmapsetApprove, + Approval = BeatmapApproval.Ranked, + Beatmapset = dummyBeatmap, + }, + new APIRecentActivity { User = dummyUser, Type = RecentActivityType.BeatmapsetDelete, diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs index 7dfdca8276..5249e8694d 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs @@ -5,6 +5,7 @@ using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; @@ -15,11 +16,11 @@ using osuTK; namespace osu.Game.Tests.Visual.Online { - public class TestSceneUserProfileScores : OsuTestScene + public partial class TestSceneUserProfileScores : OsuTestScene { public TestSceneUserProfileScores() { - var firstScore = new APIScore + var firstScore = new SoloScoreInfo { PP = 1047.21, Rank = ScoreRank.SH, @@ -32,7 +33,7 @@ namespace osu.Game.Tests.Visual.Online }, DifficultyName = "Extreme" }, - Date = DateTimeOffset.Now, + EndedAt = DateTimeOffset.Now, Mods = new[] { new APIMod { Acronym = new OsuModHidden().Acronym }, @@ -42,7 +43,7 @@ namespace osu.Game.Tests.Visual.Online Accuracy = 0.9813 }; - var secondScore = new APIScore + var secondScore = new SoloScoreInfo { PP = 134.32, Rank = ScoreRank.A, @@ -55,7 +56,7 @@ namespace osu.Game.Tests.Visual.Online }, DifficultyName = "[4K] Regret" }, - Date = DateTimeOffset.Now, + EndedAt = DateTimeOffset.Now, Mods = new[] { new APIMod { Acronym = new OsuModHardRock().Acronym }, @@ -64,7 +65,7 @@ namespace osu.Game.Tests.Visual.Online Accuracy = 0.998546 }; - var thirdScore = new APIScore + var thirdScore = new SoloScoreInfo { PP = 96.83, Rank = ScoreRank.S, @@ -77,11 +78,11 @@ namespace osu.Game.Tests.Visual.Online }, DifficultyName = "Insane" }, - Date = DateTimeOffset.Now, + EndedAt = DateTimeOffset.Now, Accuracy = 0.9726 }; - var noPPScore = new APIScore + var noPPScore = new SoloScoreInfo { Rank = ScoreRank.B, Beatmap = new APIBeatmap @@ -93,7 +94,24 @@ namespace osu.Game.Tests.Visual.Online }, DifficultyName = "[4K] Cataclysmic Hypernova" }, - Date = DateTimeOffset.Now, + EndedAt = DateTimeOffset.Now, + Accuracy = 0.55879 + }; + + var unprocessedPPScore = new SoloScoreInfo + { + Rank = ScoreRank.B, + Beatmap = new APIBeatmap + { + BeatmapSet = new APIBeatmapSet + { + Title = "C18H27NO3(extend)", + Artist = "Team Grimoire", + }, + DifficultyName = "[4K] Cataclysmic Hypernova", + Status = BeatmapOnlineStatus.Ranked, + }, + EndedAt = DateTimeOffset.Now, Accuracy = 0.55879 }; @@ -110,6 +128,7 @@ namespace osu.Game.Tests.Visual.Online new ColourProvidedContainer(OverlayColourScheme.Green, new DrawableProfileScore(firstScore)), new ColourProvidedContainer(OverlayColourScheme.Green, new DrawableProfileScore(secondScore)), new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileScore(noPPScore)), + new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileScore(unprocessedPPScore)), new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileWeightedScore(firstScore, 0.97)), new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileWeightedScore(secondScore, 0.85)), new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileWeightedScore(thirdScore, 0.66)), @@ -117,7 +136,7 @@ namespace osu.Game.Tests.Visual.Online }); } - private class ColourProvidedContainer : Container + private partial class ColourProvidedContainer : Container { [Cached] private readonly OverlayColourProvider colourProvider; diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserRanks.cs b/osu.Game.Tests/Visual/Online/TestSceneUserRanks.cs index b9272e7294..a3825f4694 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserRanks.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserRanks.cs @@ -10,12 +10,14 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; +using osu.Game.Overlays.Profile; using osu.Game.Overlays.Profile.Sections; +using osu.Game.Rulesets.Osu; namespace osu.Game.Tests.Visual.Online { [TestFixture] - public class TestSceneUserRanks : OsuTestScene + public partial class TestSceneUserRanks : OsuTestScene { protected override bool UseOnlineAPI => true; @@ -44,7 +46,7 @@ namespace osu.Game.Tests.Visual.Online } }); - AddStep("Show cookiezi", () => ranks.User.Value = new APIUser { Id = 124493 }); + AddStep("Show cookiezi", () => ranks.User.Value = new UserProfileData(new APIUser { Id = 124493 }, new OsuRuleset().RulesetInfo)); } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserRequest.cs b/osu.Game.Tests/Visual/Online/TestSceneUserRequest.cs index e7b6a94642..1ffb438355 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserRequest.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -18,7 +20,7 @@ using osu.Game.Rulesets.Taiko; namespace osu.Game.Tests.Visual.Online { [TestFixture] - public class TestSceneUserRequest : OsuTestScene + public partial class TestSceneUserRequest : OsuTestScene { [Resolved] private IAPIProvider api { get; set; } @@ -69,7 +71,7 @@ namespace osu.Game.Tests.Visual.Online api.Queue(request); } - private class UserTestContainer : FillFlowContainer + private partial class UserTestContainer : FillFlowContainer { public readonly Bindable User = new Bindable(); diff --git a/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs b/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs index a9fed7b302..ce1a9ac6a7 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Overlays.Comments; @@ -13,7 +15,7 @@ using osu.Framework.Graphics.Containers; namespace osu.Game.Tests.Visual.Online { [TestFixture] - public class TestSceneVotePill : OsuTestScene + public partial class TestSceneVotePill : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); @@ -96,7 +98,7 @@ namespace osu.Game.Tests.Visual.Online }; } - private class TestPill : VotePill + private partial class TestPill : VotePill { public new Box Background => base.Background; diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs index 27e989df76..4e71c5977e 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -14,7 +16,7 @@ using osu.Game.Overlays.Wiki; namespace osu.Game.Tests.Visual.Online { - public class TestSceneWikiHeader : OsuTestScene + public partial class TestSceneWikiHeader : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Orange); @@ -95,7 +97,7 @@ namespace osu.Game.Tests.Visual.Online }; } - private class TestHeader : WikiHeader + private partial class TestHeader : WikiHeader { public IReadOnlyList TabControlItems => TabControl.Items; } diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiMainPage.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiMainPage.cs index 3a2bafb128..8876f0fd3b 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiMainPage.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiMainPage.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,7 +12,7 @@ using osu.Game.Overlays.Wiki; namespace osu.Game.Tests.Visual.Online { - public class TestSceneWikiMainPage : OsuTestScene + public partial class TestSceneWikiMainPage : OsuTestScene { [Cached] private readonly OverlayColourProvider overlayColour = new OverlayColourProvider(OverlayColourScheme.Orange); diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs index af2e4fc91a..0aa0295f7d 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using Markdig.Syntax.Inlines; @@ -14,13 +16,17 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Graphics.Containers.Markdown; +using osu.Game.Graphics.Containers.Markdown.Footnotes; using osu.Game.Overlays; using osu.Game.Overlays.Wiki.Markdown; +using osu.Game.Users.Drawables; +using osuTK.Input; namespace osu.Game.Tests.Visual.Online { - public class TestSceneWikiMarkdownContainer : OsuTestScene + public partial class TestSceneWikiMarkdownContainer : OsuManualInputManagerTestScene { + private OverlayScrollContainer scrollContainer; private TestMarkdownContainer markdownContainer; [Cached] @@ -36,15 +42,25 @@ namespace osu.Game.Tests.Visual.Online Colour = overlayColour.Background5, RelativeSizeAxes = Axes.Both, }, - new BasicScrollContainer + scrollContainer = new OverlayScrollContainer { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding(20), - Child = markdownContainer = new TestMarkdownContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - } + } + }; + + scrollContainer.Child = new DependencyProvidingContainer + { + CachedDependencies = new (Type, object)[] + { + (typeof(OverlayScrollContainer), scrollContainer) + }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = markdownContainer = new TestMarkdownContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, } }; }); @@ -107,7 +123,7 @@ needs_cleanup: true AddStep("Add absolute image", () => { markdownContainer.CurrentPath = "https://dev.ppy.sh"; - markdownContainer.Text = "![intro](/wiki/Interface/img/intro-screen.jpg)"; + markdownContainer.Text = "![intro](/wiki/images/Client/Interface/img/intro-screen.jpg)"; }); } @@ -117,7 +133,7 @@ needs_cleanup: true AddStep("Add relative image", () => { markdownContainer.CurrentPath = "https://dev.ppy.sh/wiki/Interface/"; - markdownContainer.Text = "![intro](img/intro-screen.jpg)"; + markdownContainer.Text = "![intro](../images/Client/Interface/img/intro-screen.jpg)"; }); } @@ -129,7 +145,7 @@ needs_cleanup: true markdownContainer.CurrentPath = "https://dev.ppy.sh/wiki/Interface/"; markdownContainer.Text = @"Line before image -![play menu](img/play-menu.jpg ""Main Menu in osu!"") +![play menu](../images/Client/Interface/img/play-menu.jpg ""Main Menu in osu!"") Line after image"; }); @@ -154,12 +170,12 @@ Line after image"; markdownContainer.Text = @" | Image | Name | Effect | | :-: | :-: | :-- | -| ![](/wiki/Skinning/Interface/img/hit300.png ""300"") | 300 | A possible score when tapping a hit circle precisely on time, completing a Slider and keeping the cursor over every tick, or completing a Spinner with the Spinner Metre full. A score of 300 appears in an blue score by default. Scoring nothing except 300s in a beatmap will award the player with the SS or SSH grade. | -| ![](/wiki/Skinning/Interface/img/hit300g.png ""Geki"") | (激) Geki | A term from Ouendan, called Elite Beat! in EBA. Appears when playing the last element in a combo in which the player has scored only 300s. Getting a Geki will give a sizable boost to the Life Bar. By default, it is blue. | -| ![](/wiki/Skinning/Interface/img/hit100.png ""100"") | 100 | A possible score one can get when tapping a Hit Object slightly late or early, completing a Slider and missing a number of ticks, or completing a Spinner with the Spinner Meter almost full. A score of 100 appears in a green score by default. When very skilled players test a beatmap and they get a lot of 100s, this may mean that the beatmap does not have correct timing. | -| ![](/wiki/Skinning/Interface/img/hit300k.png ""300 Katu"") ![](/wiki/Skinning/Interface/img/hit100k.png ""100 Katu"") | (喝) Katu or Katsu | A term from Ouendan, called Beat! in EBA. Appears when playing the last element in a combo in which the player has scored at least one 100, but no 50s or misses. Getting a Katu will give a small boost to the Life Bar. By default, it is coloured green or blue depending on whether the Katu itself is a 100 or a 300. | -| ![](/wiki/Skinning/Interface/img/hit50.png ""50"") | 50 | A possible score one can get when tapping a hit circle rather early or late but not early or late enough to cause a miss, completing a Slider and missing a lot of ticks, or completing a Spinner with the Spinner Metre close to full. A score of 50 appears in a orange score by default. Scoring a 50 in a combo will prevent the appearance of a Katu or a Geki at the combo's end. | -| ![](/wiki/Skinning/Interface/img/hit0.png ""Miss"") | Miss | A possible score one can get when not tapping a hit circle or too early (based on OD and AR, it may *shake* instead), not tapping or holding the Slider at least once, or completing a Spinner with low Spinner Metre fill. Scoring a Miss will reset the current combo to 0 and will prevent the appearance of a Katu or a Geki at the combo's end. | +| ![](/wiki/images/shared/judgement/osu!/hit300.png ""300"") | 300 | A possible score when tapping a hit circle precisely on time, completing a Slider and keeping the cursor over every tick, or completing a Spinner with the Spinner Metre full. A score of 300 appears in an blue score by default. Scoring nothing except 300s in a beatmap will award the player with the SS or SSH grade. | +| ![](/wiki/images/shared/judgement/osu!/hit300g.png ""Geki"") | (激) Geki | A term from Ouendan, called Elite Beat! in EBA. Appears when playing the last element in a combo in which the player has scored only 300s. Getting a Geki will give a sizable boost to the Life Bar. By default, it is blue. | +| ![](/wiki/images/shared/judgement/osu!/hit100.png ""100"") | 100 | A possible score one can get when tapping a Hit Object slightly late or early, completing a Slider and missing a number of ticks, or completing a Spinner with the Spinner Meter almost full. A score of 100 appears in a green score by default. When very skilled players test a beatmap and they get a lot of 100s, this may mean that the beatmap does not have correct timing. | +| ![](/wiki/images/shared/judgement/osu!/hit300k.png ""300 Katu"") ![](/wiki/Skinning/Interface/img/hit100k.png ""100 Katu"") | (喝) Katu or Katsu | A term from Ouendan, called Beat! in EBA. Appears when playing the last element in a combo in which the player has scored at least one 100, but no 50s or misses. Getting a Katu will give a small boost to the Life Bar. By default, it is coloured green or blue depending on whether the Katu itself is a 100 or a 300. | +| ![](/wiki/images/shared/judgement/osu!/hit50.png ""50"") | 50 | A possible score one can get when tapping a hit circle rather early or late but not early or late enough to cause a miss, completing a Slider and missing a lot of ticks, or completing a Spinner with the Spinner Metre close to full. A score of 50 appears in a orange score by default. Scoring a 50 in a combo will prevent the appearance of a Katu or a Geki at the combo's end. | +| ![](/wiki/images/shared/judgement/osu!/hit0.png ""Miss"") | Miss | A possible score one can get when not tapping a hit circle or too early (based on OD and AR, it may *shake* instead), not tapping or holding the Slider at least once, or completing a Spinner with low Spinner Metre fill. Scoring a Miss will reset the current combo to 0 and will prevent the appearance of a Katu or a Geki at the combo's end. | "; }); } @@ -170,7 +186,7 @@ Line after image"; AddStep("Add image", () => { markdownContainer.CurrentPath = "https://dev.ppy.sh/wiki/osu!_Program_Files/"; - markdownContainer.Text = "![](img/file_structure.jpg \"The file structure of osu!'s installation folder, on Windows and macOS\")"; + markdownContainer.Text = "![](../images/Client/Program_files/img/file_structure.jpg \"The file structure of osu!'s installation folder, on Windows and macOS\")"; }); AddUntilStep("Wait image to load", () => markdownContainer.ChildrenOfType().First().DelayedLoadCompleted); @@ -187,7 +203,98 @@ Line after image"; }); } - private class TestMarkdownContainer : WikiMarkdownContainer + [Test] + public void TestFlag() + { + AddStep("Add flag", () => + { + markdownContainer.CurrentPath = @"https://dev.ppy.sh"; + markdownContainer.Text = "::{flag=\"AU\"}:: ::{flag=\"ZZ\"}::"; + }); + AddAssert("Two flags visible", () => markdownContainer.ChildrenOfType().Count(), () => Is.EqualTo(2)); + } + + [Test] + public void TestHeadingWithIdAttribute() + { + AddStep("Add heading with ID", () => + { + markdownContainer.Text = "# This is a heading with an ID {#this-is-the-id}"; + }); + AddAssert("ID not visible", () => markdownContainer.ChildrenOfType().All(spriteText => spriteText.Text != "{#this-is-the-id}")); + } + + [Test] + public void TestFootnotes() + { + AddStep("set content", () => markdownContainer.Text = @"This text has a footnote[^test]. + +Here's some more text[^test2] with another footnote! + +# Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam efficitur laoreet posuere. Ut accumsan tortor in ipsum tincidunt ultrices. Suspendisse a malesuada tellus. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Fusce a sagittis nibh. In et velit sit amet mauris aliquet consectetur quis vehicula lorem. Etiam sit amet tellus ac velit ornare maximus. Donec quis metus eget libero ullamcorper imperdiet id vitae arcu. Vivamus iaculis rhoncus purus malesuada mollis. Vestibulum dictum at nisi sed tincidunt. Suspendisse finibus, ipsum ut dapibus commodo, leo eros porttitor sapien, non scelerisque nisi ligula sed ex. Pellentesque magna orci, hendrerit eu iaculis sit amet, ullamcorper in urna. Vivamus dictum mauris orci, nec facilisis dolor fringilla eu. Sed at porttitor nisi, at venenatis urna. Ut at orci vitae libero semper ullamcorper eu ut risus. Mauris hendrerit varius enim, ut varius nisi feugiat mattis. + +## In at eros urna. Sed ipsum lorem, tempor sit amet purus in, vehicula pellentesque leo. Fusce volutpat pellentesque velit sit amet porttitor. Nulla eget erat ex. Praesent eu lacinia est, quis vehicula lacus. Donec consequat ultrices neque, at finibus quam efficitur vel. Vestibulum molestie nisl sit amet metus semper, at vestibulum massa rhoncus. Quisque imperdiet suscipit augue, et dignissim odio eleifend ut. + +Aliquam sed vestibulum mauris, ut lobortis elit. Sed quis lacinia erat. Nam ultricies, risus non pellentesque sollicitudin, mauris dolor tincidunt neque, ac porta ipsum dui quis libero. Integer eget velit neque. Vestibulum venenatis mauris vitae rutrum vestibulum. Maecenas suscipit eu purus eu tempus. Nam dui nisl, bibendum condimentum mollis et, gravida vel dui. Sed et eros rutrum, facilisis sapien eu, mattis ligula. Fusce finibus pulvinar dolor quis consequat. + +Donec ipsum felis, feugiat vel fermentum at, commodo eu sapien. Suspendisse nec enim vitae felis laoreet laoreet. Phasellus purus quam, fermentum a pharetra vel, tempor et urna. Integer vitae quam diam. Aliquam tincidunt tortor a iaculis convallis. Suspendisse potenti. Cras quis risus quam. Nullam tincidunt in lorem posuere sagittis. + +Phasellus eu nunc nec ligula semper fringilla. Aliquam magna neque, placerat sed urna tristique, laoreet pharetra nulla. Vivamus maximus turpis purus, eu viverra dolor sodales porttitor. Praesent bibendum sapien purus, sed ultricies dolor iaculis sed. Fusce congue hendrerit malesuada. Nulla nulla est, auctor ac fringilla sed, ornare a lorem. Donec quis velit imperdiet, imperdiet sem non, pellentesque sapien. Maecenas in orci id ipsum placerat facilisis non sed nisi. Duis dictum lorem sodales odio dictum eleifend. Vestibulum bibendum euismod quam, eget pharetra orci facilisis sed. Vivamus at diam non ipsum consequat tristique. Pellentesque gravida dignissim pellentesque. Donec ullamcorper lacinia orci, id consequat purus faucibus quis. Phasellus metus nunc, iaculis a interdum vel, congue sed erat. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Etiam eros libero, hendrerit luctus nulla vitae, luctus maximus nunc. + +[^test]: This is a **footnote**. +[^test2]: This is another footnote [with a link](https://google.com/)!"); + AddStep("shrink scroll height", () => scrollContainer.Height = 0.5f); + + AddStep("press second footnote link", () => + { + InputManager.MoveMouseTo(markdownContainer.ChildrenOfType().ElementAt(1)); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("second footnote scrolled into view", () => + { + var footnote = markdownContainer.ChildrenOfType().ElementAt(1); + return scrollContainer.ScreenSpaceDrawQuad.Contains(footnote.ScreenSpaceDrawQuad.TopLeft) + && scrollContainer.ScreenSpaceDrawQuad.Contains(footnote.ScreenSpaceDrawQuad.BottomRight); + }); + + AddStep("press first footnote backlink", () => + { + InputManager.MoveMouseTo(markdownContainer.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("first footnote link scrolled into view", () => + { + var footnote = markdownContainer.ChildrenOfType().First(); + return scrollContainer.ScreenSpaceDrawQuad.Contains(footnote.ScreenSpaceDrawQuad.TopLeft) + && scrollContainer.ScreenSpaceDrawQuad.Contains(footnote.ScreenSpaceDrawQuad.BottomRight); + }); + } + + [Test] + public void TestCodeSyntax() + { + AddStep("set content", () => + { + markdownContainer.Text = @" +This is a paragraph containing `inline code` synatax. +Oh wow I do love the `WikiMarkdownContainer`, it is very cool! + +This is a line before the fenced code block: +```csharp +public class WikiMarkdownContainer : MarkdownContainer +{ + public WikiMarkdownContainer() + { + this.foo = bar; + } +} +``` +This is a line after the fenced code block! +"; + }); + } + + private partial class TestMarkdownContainer : WikiMarkdownContainer { public LinkInline Link; @@ -196,7 +303,7 @@ Line after image"; UrlAdded = link => Link = link, }; - private class TestMarkdownTextFlowContainer : OsuMarkdownTextFlowContainer + private partial class TestMarkdownTextFlowContainer : OsuMarkdownTextFlowContainer { public Action UrlAdded; diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs index 3506d459ce..b0e4303ca4 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs @@ -1,8 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System; +using System.Linq; using System.Net; using NUnit.Framework; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; @@ -10,7 +16,7 @@ using osu.Game.Overlays; namespace osu.Game.Tests.Visual.Online { - public class TestSceneWikiOverlay : OsuTestScene + public partial class TestSceneWikiOverlay : OsuTestScene { private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; @@ -23,24 +29,51 @@ namespace osu.Game.Tests.Visual.Online public void TestMainPage() { setUpWikiResponse(responseMainPage); - AddStep("Show Main Page", () => wiki.Show()); + AddStep("Show main page", () => wiki.Show()); + } + + [Test] + public void TestCancellationDoesntShowError() + { + AddStep("Show main page", () => wiki.Show()); + AddStep("Show another page", () => wiki.ShowPage("Article_styling_criteria/Formatting")); + + AddUntilStep("Current path is not error", () => wiki.CurrentPath != "error"); } [Test] public void TestArticlePage() { setUpWikiResponse(responseArticlePage); - AddStep("Show Article Page", () => wiki.ShowPage("Article_styling_criteria/Formatting")); + AddStep("Show article page", () => wiki.ShowPage("Article_styling_criteria/Formatting")); + } + + [Test] + public void TestRedirection() + { + const string redirection_path = "Redirection_path_for_article"; + + setUpWikiResponse(responseArticlePage, redirection_path); + AddStep("Show article page", () => wiki.ShowPage(redirection_path)); + + AddUntilStep("Current page is article", () => wiki.Header.Current.Value == "Formatting"); + + setUpWikiResponse(responseArticleParentPage); + AddStep("Show parent page", () => wiki.Header.ShowParentPage?.Invoke()); + + AddUntilStep("Current page is parent", () => wiki.Header.Current.Value == "Article styling criteria"); } [Test] public void TestErrorPage() { - setUpWikiResponse(null, true); - AddStep("Show Error Page", () => wiki.ShowPage("Error")); + setUpWikiResponse(responseArticlePage); + AddStep("Show nonexistent page", () => wiki.ShowPage("This_page_will_error_out")); + AddUntilStep("Wait for error page", () => wiki.CurrentPath == "error"); + AddUntilStep("Error message correct", () => wiki.ChildrenOfType().Any(text => text.Text == "\"This_page_will_error_out\".")); } - private void setUpWikiResponse(APIWikiPage r, bool isFailed = false) + private void setUpWikiResponse(APIWikiPage r, string redirectionPath = null) => AddStep("set up response", () => { dummyAPI.HandleRequest = request => @@ -48,10 +81,13 @@ namespace osu.Game.Tests.Visual.Online if (!(request is GetWikiRequest getWikiRequest)) return false; - if (isFailed) - getWikiRequest.TriggerFailure(new WebException()); - else + if (getWikiRequest.Path.Equals(r.Path, StringComparison.OrdinalIgnoreCase) || + getWikiRequest.Path.Equals(redirectionPath, StringComparison.OrdinalIgnoreCase)) + { getWikiRequest.TriggerSuccess(r); + } + else + getWikiRequest.TriggerFailure(new WebException()); return true; }; @@ -78,7 +114,19 @@ namespace osu.Game.Tests.Visual.Online Locale = "en", Subtitle = "Article styling criteria", Markdown = - "# Formatting\n\n*For the writing standards, see: [Article style criteria/Writing](../Writing)*\n\n*Notice: This article uses [RFC 2119](https://tools.ietf.org/html/rfc2119 \"IETF Tools\") to describe requirement levels.*\n\n## Locales\n\nListed below are the properly-supported locales for the wiki:\n\n| File Name | Locale Name | Native Script |\n| :-- | :-- | :-- |\n| `en.md` | English | English |\n| `ar.md` | Arabic | اَلْعَرَبِيَّةُ |\n| `be.md` | Belarusian | Беларуская мова |\n| `bg.md` | Bulgarian | Български |\n| `cs.md` | Czech | Česky |\n| `da.md` | Danish | Dansk |\n| `de.md` | German | Deutsch |\n| `gr.md` | Greek | Ελληνικά |\n| `es.md` | Spanish | Español |\n| `fi.md` | Finnish | Suomi |\n| `fr.md` | French | Français |\n| `hu.md` | Hungarian | Magyar |\n| `id.md` | Indonesian | Bahasa Indonesia |\n| `it.md` | Italian | Italiano |\n| `ja.md` | Japanese | 日本語 |\n| `ko.md` | Korean | 한국어 |\n| `nl.md` | Dutch | Nederlands |\n| `no.md` | Norwegian | Norsk |\n| `pl.md` | Polish | Polski |\n| `pt.md` | Portuguese | Português |\n| `pt-br.md` | Brazilian Portuguese | Português (Brasil) |\n| `ro.md` | Romanian | Română |\n| `ru.md` | Russian | Русский |\n| `sk.md` | Slovak | Slovenčina |\n| `sv.md` | Swedish | Svenska |\n| `th.md` | Thai | ไทย |\n| `tr.md` | Turkish | Türkçe |\n| `uk.md` | Ukrainian | Українська мова |\n| `vi.md` | Vietnamese | Tiếng Việt |\n| `zh.md` | Chinese (Simplified) | 简体中文 |\n| `zh-tw.md` | Traditional Chinese (Taiwan) | 繁體中文(台灣) |\n\n*Note: The website will give readers their selected language's version of an article. If it is not available, the English version will be given.*\n\n### Content parity\n\nTranslations are subject to strict content parity with their English article, in the sense that they must have the same message, regardless of grammar and syntax. Any changes to the translations' meanings must be accompanied by equivalent changes to the English article.\n\nThere are some cases where the content is allowed to differ:\n\n- Articles originally written in a language other than English (in this case, English should act as the translation)\n- Explanations of English words that are common terms in the osu! community\n- External links\n- Tags\n- Subcommunity-specific explanations\n\n## Front matter\n\nFront matter must be placed at the very top of the file. It is written in [YAML](https://en.wikipedia.org/wiki/YAML#Example \"YAML Wikipedia article\") and describes additional information about the article. This must be surrounded by three hyphens (`---`) on the lines above and below it, and an empty line must follow it before the title heading.\n\n### Articles that need help\n\n*Note: Avoid translating English articles with this tag. In addition to this, this tag should be added when the translation needs its own clean up.*\n\nThe `needs_cleanup` tag may be added to articles that need rewriting or formatting help. It is also acceptable to open an issue on GitHub for this purpose. This tag must be written as shown below:\n\n```yaml\nneeds_cleanup: true\n```\n\nWhen adding this tag to an article, [comments](#comments) should also be added to explain what needs to be done to remove the tag.\n\n### Outdated articles\n\n*Note: Avoid translating English articles with this tag. If the English article has this tag, the translation must also have this tag.*\n\nTranslated articles that are outdated must use the `outdated` tag when the English variant is updated. English articles may also become outdated when the content they contain is misleading or no longer relevant. This tag must be written as shown below:\n\n```yaml\noutdated: true\n```\n\nWhen adding this tag to an article, [comments](#comments) should also be added to explain what needs to be updated to remove the tag.\n\n### Tagging articles\n\nTags help the website's search engine query articles better. Tags should be written in the same language as the article and include the original list of tags. Tags should use lowercase letters where applicable.\n\nFor example, an article called \"Beatmap discussion\" may include the following tags:\n\n```yaml\ntags:\n - beatmap discussions\n - modding V2\n - MV2\n```\n\n### Translations without reviews\n\n*Note: Wiki maintainers will determine and apply this mark prior to merging.*\n\nSometimes, translations are added to the wiki without review from other native speakers of the language. In this case, the `no_native_review` mark is added to let future translators know that it may need to be checked again. This tag must be written as shown below:\n\n```yaml\nno_native_review: true\n```\n\n## Article naming\n\n*See also: [Folder names](#folder-names) and [Titles](#titles)*\n\nArticle titles should be singular and use sentence case. See [Wikipedia's naming conventions article](https://en.wikipedia.org/wiki/Wikipedia:Naming_conventions_(plurals) \"Wikipedia\") for more details.\n\nArticle titles should match the folder name it is in (spaces may replace underscores (`_`) where appropriate). If the folder name changes, the article title should be changed to match it and vice versa.\n\n---\n\nContest and tournament articles are an exception. The folder name must use abbreviations, acronyms, or initialisms. The article's title must be the full name of the contest or tournament.\n\n## Folder and file structure\n\n### Folder names\n\n*See also: [Article naming](#article-naming)*\n\nFolder names must be in English and use sentence case.\n\nFolder names must only use these characters:\n\n- uppercase and lowercase letters\n- numbers\n- underscores (`_`)\n- hyphens (`-`)\n- exclamation marks (`!`)\n\n### Article file names\n\nThe file name of an article can be found in the `File Name` column of the [locales section](#locales). The location of a translated article must be placed in the same folder as the English article.\n\n### Index articles\n\nAn index article must be created if the folder is intended to only hold other articles. Index articles must contain a list of articles that are inside its own folder. They may also contain other information, such as a lead paragraph or descriptions of the linked articles.\n\n### Disambiguation articles\n\n[Disambiguation](/wiki/Disambiguation) articles must be placed in the `/wiki/Disambiguation` folder. The main page must be updated to include the disambiguation article. Refer to [Disambiguation/Mod](/wiki/Disambiguation/Mod) as an example.\n\nRedirects must be updated to have the ambiguous keyword(s) redirect to the disambiguation article.\n\nArticles linked from a disambiguation article must have a [For other uses](#for-other-uses) hatnote.\n\n## HTML\n\nHTML must not be used, with exception for [comments](#comments). The structure of the article must be redone if HTML is used.\n\n### Comments\n\nHTML comments should be used for marking to-dos, but may also be used to annotate text. They should be on their own line, but can be placed inline in a paragraph. If placed inline, the start of the comment must not have a space.\n\nBad example:\n\n```markdown\nHTML comments should be used for marking to-dos or annotate text.\n```\n\nGood example:\n\n```markdown\nHTML comments should be used for marking to-dos or annotate text.\n```\n\n## Editing\n\n### End of line sequence\n\n*Caution: Uploading Markdown files using `CRLF` (carriage return and line feed) via GitHub will result in those files using `CRLF`. To prevent this, set the line ending to `LF` (line feed) before uploading.*\n\nMarkdown files must be checked in using the `LF` end of line sequence.\n\n### Escaping\n\nMarkdown syntax should be escaped as needed. However, article titles are parsed as plain text and so must not be escaped.\n\n### Paragraphs\n\nEach paragraph must be followed by one empty line.\n\n### Line breaks\n\nLine breaks must use a backslash (`\\`).\n\nLine breaks must be used sparingly.\n\n## Hatnote\n\n*Not to be confused with [Notice](#notice).*\n\nHatnotes are short notes placed at the top of an article or section to help readers navigate to related articles or inform them about related topics.\n\nHatnotes must be italicised and be placed immediately after the heading. If multiple hatnotes are used, they must be on the same paragraph separated with a line break.\n\n### Main page\n\n*Main page* hatnotes direct the reader to the main article of a topic. When this hatnote is used, it implies that the section it is on is a summary of what the linked page is about. This hatnote should have only one link. These must be formatted as follows:\n\n```markdown\n*Main page: {article}*\n\n*Main pages: {article} and {article}*\n```\n\n### See also\n\n*See also* hatnotes suggest to readers other points of interest from a given article or section. These must be formatted as follows:\n\n```markdown\n*See also: {article}*\n\n*See also: {article} and {article}*\n```\n\n### For see\n\n*For see* hatnotes are similar to *see also* hatnotes, but are generally more descriptive and direct. This hatnote may use more than one link if necessary. These must be formatted as follows:\n\n```markdown\n*For {description}, see: {article}`*\n\n*For {description}, see: {article} and {article}`*\n```\n\n### Not to be confused with\n\n*Not to be confused with* hatnotes help distinguish ambiguous or misunderstood article titles or sections. This hatnote may use more than one link if necessary. These must be formatted as follows:\n\n```markdown\n*Not to be confused with {article}.*\n\n*Not to be confused with {article} or {article}.*\n```\n\n### For other uses\n\n*For other uses* hatnotes are similar to *not to be confused with* hatnotes, but links directly to the [disambiguation article](#disambiguation-articles). This hatnote must only link to the disambiguation article. These must be formatted as follows:\n\n```markdown\n*For other uses, see {disambiguation article}.*\n```\n\n## Notice\n\n*Not to be confused with [Hatnote](#hatnote).*\n\nA notice should be placed where appropriate in a section, but must start off the paragraph and use italics. Notices may contain bolding where appropriate, but should be kept to a minimum. Notices must be written as complete sentences. Thus, unlike most [hatnotes](#hatnotes), must use a full stop (`.`) or an exclamation mark (`!`) if appropriate. Anything within the same paragraph of a notice must also be italicised. These must be formatted as follows:\n\n```markdown\n*Note: {note}.*\n\n*Notice: {notice}.*\n\n*Caution: {caution}.*\n\n*Warning: {warning}.*\n```\n\n- `Note` should be used for factual or trivial details.\n- `Notice` should be used for reminders or to draw attention to something that the reader should be made aware of.\n- `Caution` should be used to warn the reader to avoid unintended consequences.\n- `Warning` should be used to warn the reader that action may be taken against them.\n\n## Emphasising\n\n### Bold\n\nBold must use double asterisks (`**`).\n\nLead paragraphs may bold the first occurrence of the article's title.\n\n### Italics\n\nItalics must use single asterisks (`*`).\n\nNames of work or video games should be italicised. osu!—the game—is exempt from this.\n\nThe first occurrence of an abbreviation, acronym, or initialism may be italicised.\n\nItalics may also be used to provide emphasis or help with readability.\n\n## Headings\n\nAll headings must use sentence case.\n\nHeadings must use the [ATX (hash) style](https://github.github.com/gfm/#atx-headings \"GitHub\") and must have an empty line before and after the heading. The title heading is an exception when it is on the first line. If this is the case, there only needs to be an empty line after the title heading.\n\nHeadings must not exceed a heading level of 5 and must not be used to style or format text.\n\n### Titles\n\n*See also: [Article naming](#article-naming)*\n\n*Caution: Titles are parsed as plain text; they must not be escaped.*\n\nThe first heading in all articles must be a level 1 heading, being the article's title. All headings afterwards must be [section headings](#sections). Titles must not contain formatting, links, or images.\n\nThe title heading must be on the first line, unless [front matter](#front-matter) is being used. If that is the case, the title heading must go after it and have an empty line before the title heading.\n\n### Sections\n\nSection headings must use levels 2 to 5. The section heading proceeding the [title heading](#titles) must be a level 2 heading. Unlike titles, section headings may have small image icons.\n\nSection headings must not skip a heading level (i.e. do not go from a level 2 heading to a level 4 heading) and must not contain formatting or links.\n\n*Notice: On the website, heading levels 4 and 5 will not appear in the table of contents. They cannot be linked to directly either.*\n\n## Lists\n\nLists should not go over 4 levels of indentation and should not have an empty line in between each item.\n\nFor nested lists, bullets or numbers must align with the item content of their parent lists.\n\nThe following example was done incorrectly (take note of the spacing before the bullet):\n\n```markdown\n1. Fly a kite\n - Don't fly a kite if it's raining\n```\n\nThe following example was done correctly:\n\n```markdown\n1. Fly a kite\n - Don't fly a kite if it's raining\n```\n\n### Bulleted\n\nBulleted lists must use a hyphen (`-`). These must then be followed by one space. (Example shown below.)\n\n```markdown\n- osu!\n - Hit circle\n - Combo number\n - Approach circle\n - Slider\n - Hit circles\n - Slider body\n - Slider ticks\n - Spinner\n- osu!taiko\n```\n\n### Numbered\n\nThe numbers in a numbered list must be incremented to represent their step.\n\n```markdown\n1. Download the osu! installer.\n2. Run the installer.\n 1. To change the installation location, click the text underneath the progression bar.\n 2. The installer will prompt for a new location, choose the installation folder.\n3. osu! will start up once installation is complete.\n4. Sign in.\n```\n\n### Mixed\n\nCombining both bulleted and numbered lists should be done sparingly.\n\n```markdown\n1. Download a skin from the forums.\n2. Load the skin file into osu!.\n - If the file is a `.zip`, unzip it and move the contents into the `Skins/` folder (found in your osu! installation folder).\n - If the file is a `.osk`, open it on your desktop or drag-and-drop it into the game client.\n3. Open osu!, if it is not opened, and select the skin in the options.\n - This may have been completed if you opened the `.osk` file or drag-and-dropped it into the game client.\n```\n\n## Code\n\nThe markup for code is a grave mark (`` ` ``). To put grave marks in code, use double grave marks instead. If the grave mark is at the start or end, pad it with one space. (Example shown below.)\n\n```markdown\n`` ` ``\n`` `Space` ``\n```\n\n### Keyboard keys\n\n*Notice: When denoting the letter itself, and not the keyboard key, use quotation marks instead.*\n\nWhen representing keyboard keys, use capital letters for single characters and title case for modifiers. Use the plus symbol (`+`) (without code) to represent key combinations. (Example shown below.)\n\n```markdown\npippi is spelt with a lowercase \"p\" like peppy.\n\nPress `Ctrl` + `O` to open the open dialog.\n```\n\nWhen representing a space or the spacebar, use `` `Space` ``.\n\n### Button and menu text\n\nWhen copying the text from a menu or button, the letter casing should be copied as it appears. (Example shown below.)\n\n```markdown\nThe `osu!direct` button is visible in the main menu on the right side, if you have an active osu!supporter tag.\n```\n\n### Folder and directory names\n\nWhen copying the name of a folder or directory, the letter casing should be copied as it appears, but prefer lowercased paths when possible. Directory paths must not be absolute (i.e. do not start the directory name from the drive letter or from the root folder). (Example shown below.)\n\n```markdown\nosu! is installed in the `AppData/Local` folder by default, unless specified otherwise during installation.\n```\n\n### Keywords and commands\n\nWhen copying a keyword or command, the letter casing should be copied as it appears or how someone normally would type it. If applicable, prefer lowercase letters. (Example shown below.)\n\n```markdown\nAs of now, the `Name` and `Author` commands in the skin configuration file (`skin.ini`) do nothing.\n```\n\n### File names\n\nWhen copying the name of a file, the letter casing should be copied as it appears. If applicable, prefer lowercase letters. (Example shown below.)\n\n```markdown\nTo play osu!, double click the `osu!.exe` icon.\n```\n\n### File extensions\n\n*Notice: File formats (not to be confused with file extensions) must be written in capital letters without the prefixed fullstop (`.`).*\n\nFile extensions must be prefixed with a fullstop (`.`) and be followed by the file extension in lowercase letters. (Example shown below.)\n\n```markdown\nThe JPG (or JPEG) file format has the `.jpg` (or `.jpeg`) extension.\n```\n\n### Chat channels\n\nWhen copying the name of a chat channel, start it with a hash (`#`), followed by the channel name in lowercase letters. (Example shown below.)\n\n```markdown\n`#lobby` is where you can advertise your multi room.\n```\n\n## Preformatted text (code blocks)\n\n*Notice: Syntax highlighting for preformatted text is not implemented on the website yet.*\n\nPreformatted text (also known as code blocks) must be fenced using three grave marks. They should set the language identifier for syntax highlighting.\n\n## Links\n\nThere are two types of links: inline and reference. Inline has two styles.\n\nThe following is an example of both inline styles:\n\n```markdown\n[Game Modifiers](/wiki/Game_Modifiers)\n\n\n```\n\nThe following is an example of the reference style:\n\n```markdown\n[Game Modifiers][game mods link]\n\n[game mods link]: /wiki/Game_Modifiers\n```\n\n---\n\nLinks must use the inline style if they are only referenced once. The inline angle brackets style should be avoided. References to reference links must be placed at the bottom of the article.\n\n### Internal links\n\n*Note: Internal links refer to links that stay inside the `https://osu.ppy.sh/` domain.*\n\n#### Wiki links\n\nAll links that point to an wiki article should start with `/wiki/` followed by the path to get to the article you are targeting. Relative links may also be used. Some examples include the following:\n\n```markdown\n[FAQ](/wiki/FAQ)\n[pippi](/wiki/Mascots#-pippi)\n[Beatmaps](../)\n[Pattern](./Pattern)\n```\n\nWiki links must not use redirects and must not have a trailing forward slash (`/`).\n\nBad examples include the following:\n\n```markdown\n[Article styling criteria](/wiki/ASC)\n[Developers](/wiki/Developers/)\n[Developers](/wiki/Developers/#game-client-developers)\n```\n\nGood examples include the following:\n\n```markdown\n[Article styling criteria](/wiki/Article_styling_criteria)\n[Developers](/wiki/Developers)\n[Developers](/wiki/Developers#game-client-developers)\n```\n\n##### Sub-article links\n\nWiki links that point to a sub-article should include the parent article's folder name in its link text. See the following example:\n\n```markdown\n*See also: [Beatmap Editor/Design](/wiki/Beatmap_Editor/Design)*\n```\n\n##### Section links\n\n*Notice: On the website, heading levels 4 and 5 are not given the id attribute. This means that they can not be linked to directly.*\n\nWiki links that point to a section of an article may use the section sign symbol (`§`). See the following example:\n\n```markdown\n*For timing rules, see: [Ranking Criteria § Timing](/wiki/Ranking_Criteria#timing)*\n```\n\n#### Other osu! links\n\nThe URL from the address bar of your web browser should be copied as it is when linking to other osu! web pages. The `https://osu.ppy.sh` part of the URL must be kept.\n\n##### User profiles\n\nAll usernames must be linked on first occurrence. Other occurrences are optional, but must be consistent throughout the entire article for all usernames. If it is difficult to determine the user's id, it may be skipped over.\n\nWhen linking to a user profile, the user's id number must be used. Use the new website (`https://osu.ppy.sh/users/{username})`) to get the user's id.\n\nThe link text of the user link should be the user's current name.\n\n##### Difficulties\n\nWhenever linking to a single difficulty, use this format as the link text:\n\n```\n{artist} - {title} ({creator}) [{difficuty_name}]\n```\n\nThe link must actually link to that difficulty. Beatmap difficulty URLs must be formatted as follows:\n\n```\nhttps://osu.ppy.sh/beatmapsets/{BeatmapSetID}#{mode}/{BeatmapID}\n```\n\nThe difficulty name may be left outside of the link text, but doing so must be consistent throughout the entire article.\n\n##### Beatmaps\n\nWhenever linking to a beatmap, use this format as the link text:\n\n```\n{artist} - {title} ({creator})\n```\n\nAll beatmap URLs must be formatted as follows:\n\n```\nhttps://osu.ppy.sh/beatmapsets/{BeatmapSetID}\n```\n\n### External links\n\n*Notice: External links refers to links that go outside the `https://osu.ppy.sh/` domain.*\n\nThe `https` protocol must be used, unless the site does not support it. External links must be a clean and direct link to a reputable source. The link text should be the title of the page it is linking to. The URL from the address bar of your web browser should be copied as it is when linking to other external pages.\n\nThere are no visual differences between external and osu! web links. Due to this, the website name should be included in the title text. See the following example:\n\n```markdown\n*For more information about music theory, see: [Music theory](https://en.wikipedia.org/wiki/Music_theory \"Wikipedia\")*\n```\n\n## Images\n\nThere are two types of image links: inline and reference. Examples:\n\n**Inline style:**\n\n```markdown\n![](/wiki/shared/flag/AU.gif)\n```\n\n**Reference style:**\n\n```markdown\n![][flag_AU]\n\n[flag_AU]: /wiki/shared/flag/AU.gif\n```\n\nImages should use the inline linking style. References to reference links must be placed at the bottom of the article.\n\nImages must be placed in a folder named `img`, located in the article's folder. Images that are used in multiple articles should be stored in the `/wiki/shared/` folder.\n\n### Image caching\n\nImages on the website are cached for up to 60 days. The cached image is matched with the image link's URL.\n\nWhen updating an image, either change the image's name or append a query string to the URL. In both cases, all translations linking to the updated image should also be updated.\n\n### Formats and quality\n\nImages should use the JPG format at quality 8 (80 or 80%, depending on the program). If the image contains transparency or has text that must be readable, use the PNG format instead. If the image contains an animation, the GIF format can be used; however, this should be used sparingly as these may take longer to load or can be bigger then the [max file size](#file-size).\n\n### File size\n\nImages must be under 1 megabyte, otherwise they will fail to load. Downscaling and using JPG at 80% is almost always under the size limit.\n\nAll images should be optimised as much as possible. Use [jpeg-archive](https://github.com/danielgtaylor/jpeg-archive \"GitHub\") to compress JPEG images. For consistency, use the following command for jpeg-archive:\n\n```sh\njpeg-recompress -am smallfry \n```\n\nWhere `` is the file name to be compressed and `` is the compressed file name.\n\n### File names\n\n*Notice: File extensions must use lowercase letters, otherwise they will fail to load!*\n\nUse hyphens (`-`) when spacing words. When naming an image, the file name should be meaningful or descriptive but short.\n\n### Formatting and positioning\n\n*Note: It is currently not possible to float an image or have text wrap around it.*\n\nImages on the website will be centred when it is on a single line, by themself. Otherwise, they will be positioned inline with the paragraph. The following example will place the image in the center:\n\n```markdown\nInstalling osu! is easy. First, download the installer from the download page.\n\n![](img/download-page.jpg)\n\nThen locate the installer and run it.\n```\n\n### Alt text\n\nImages should have alt text unless it is for decorative purposes.\n\n### Captions\n\nImages are given captions on the website if they fulfill these conditions:\n\n1. The image is by itself.\n2. The image is not inside a heading.\n3. The image has title text.\n\nCaptions are assumed via the title text, which must be in plain text. Images with captions are also centred with the image on the website.\n\n### Max image width\n\nThe website's max image width is the width of the article body. Images should be no wider than 800 pixels.\n\n### Annotating images\n\nWhen annotating images, use *Torus Regular*. For Chinese, Korean, Japanese characters, use *Microsoft YaHei*.\n\nAnnotating images should be avoided, as it is difficult for translators (and other editors) to edit them.\n\n#### Translating annotated images\n\nWhen translating annotated images, the localised image version must be placed in the same directory as the original version (i.e. the English version). The filename of a localised image version must start with the original version's name, followed by a hyphen, followed by the locale name (in capital letters). See the following examples:\n\n- `hardrock-mod-vs-easy-mod.jpg` for English\n- `hardrock-mod-vs-easy-mod-DE.jpg` for German\n- `hardrock-mod-vs-easy-mod-ZH-TW.jpg` for Traditional Chinese\n\n### Screenshots of gameplay\n\nAll screenshots of gameplay must be done in the stable build, unless it is for a specific feature that is unavailable in the stable build. You should use the in-game screenshot feature (`F12`).\n\n#### Game client settings\n\n*Note: If you do not want to change your current settings for the wiki, you can move your `osu!..cfg` out of the osu! folder and move it back later.*\n\nYou must set these settings before taking a screenshot of the game client (settings not stated below are assumed to be at their defaults):\n\n- Select language: `English`\n- Prefer metadata in original language: `Enabled`\n- Resolution: `1280x720`\n- Fullscreen mode: `Disabled`\n- Parallax: `Disabled`\n- Menu tips: `Disabled`\n- Seasonal backgrounds: `Never`\n- Always show key overlay: `Enabled`\n- Current skin: `Default` (first option)\n\n*Notice to translators: If you are translating an article containing screenshots of the game, you may set the game client's language to the language you are translating in.*\n\n### Image links\n\nImages must not be part of a link text.\n\nFlag icons next to user links must be separate from the link text. See the following example:\n\n```markdown\n![][flag_AU] [peppy](https://osu.ppy.sh/users/2)\n```\n\n### Flag icons\n\n*For a list of flag icons, see: [issue \\#328](https://github.com/ppy/osu-wiki/issues/328 \"GitHub\")*\n\nThe flag icons use the two letter code (in all capital letters) and end with `.gif`. When adding a flag inline, use this format:\n\n```markdown\n![](/wiki/shared/flag/xx.gif)\n```\n\nWhere `xx` is the [ISO 3166-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 \"Wikipedia\") two-lettered country code for the flag.\n\nThe full country name should be added in the title text. The country code in the alternate text is optional, but must be applied to all flag icons in the article.\n\n## Tables\n\nTables on the website only support headings along the first row.\n\nTables must not be beautified (do not pad cells with extra spaces to make their widths uniform). They must have a vertical bar (`|`) on the left and right sides and the text of each cell must be padded with one space on both sides. Empty cells must use a vertical bar (`|`) followed by two spaces then another vertical bar (`|`).\n\nThe delimiter row (the next line after the table heading) must use only three characters per column (and be padded with a space on both sides), which must look like one of the following:\n\n- `:--` (for left align)\n- `:-:` (for centre align)\n- `--:` (for right align)\n\n---\n\nThe following is an example of what a table should look like:\n\n```markdown\n| Team \"Picturesque\" Red | Score | Team \"Statuesque\" Blue | Average Beatmap Stars |\n| :-- | :-: | --: | :-- |\n| **peppy** | 5 - 2 | pippi | 9.3 stars |\n| Aiko | 1 - 6 | **Alisa** | 4.2 stars |\n| Ryūta | 3 - 4 | **Yuzu** | 5.1 stars |\n| **Taikonator** | 7 - 0 | Tama | 13.37 stars |\n| Maria | No Contest | Mocha | |\n```\n\n## Blockquotes\n\nThe blockquote is limited to quoting text from someone. It must not be used to format text otherwise.\n\n## Thematic breaks\n\nThe thematic break (also known as the horizontal rule or line) should be used sparingly. A few uses of the thematic break may include (but is not limited to):\n\n- separating images from text\n- separating multiple images that follow one another\n- shifting the topic within a section\n\nThese must have an empty line before and after the markup. Thematic breaks must use only three hyphens, as depicted below:\n\n```markdown\n---\n```\n" + "# Formatting\n\n*For the writing standards, see: [Article style criteria/Writing](../Writing)*\\\n*Notice: This article uses [RFC 2119](https://tools.ietf.org/html/rfc2119) to describe requirement levels.*\n\n## Locales\n\nListed below are the properly-supported locales for the wiki:\n\n| File Name | Locale Name | Native Script |\n| :-- | :-- | :-- |\n| `en.md` | English | English |\n| `ar.md` | Arabic | \u0627\u064e\u0644\u0652\u0639\u064e\u0631\u064e\u0628\u0650\u064a\u064e\u0651\u0629\u064f |\n| `be.md` | Belarusian | \u0411\u0435\u043b\u0430\u0440\u0443\u0441\u043a\u0430\u044f \u043c\u043e\u0432\u0430 |\n| `bg.md` | Bulgarian | \u0411\u044a\u043b\u0433\u0430\u0440\u0441\u043a\u0438 |\n| `cs.md` | Czech | \u010cesky |\n| `da.md` | Danish | Dansk |\n| `de.md` | German | Deutsch |\n| `el.md` | Greek | \u0395\u03bb\u03bb\u03b7\u03bd\u03b9\u03ba\u03ac |\n| `es.md` | Spanish | Espa\u00f1ol |\n| `fi.md` | Finnish | Suomi |\n| `fr.md` | French | Fran\u00e7ais |\n| `hu.md` | Hungarian | Magyar |\n| `id.md` | Indonesian | Bahasa Indonesia |\n| `it.md` | Italian | Italiano |\n| `ja.md` | Japanese | \u65e5\u672c\u8a9e |\n| `ko.md` | Korean | \ud55c\uad6d\uc5b4 |\n| `nl.md` | Dutch | Nederlands |\n| `no.md` | Norwegian | Norsk |\n| `pl.md` | Polish | Polski |\n| `pt.md` | Portuguese | Portugu\u00eas |\n| `pt-br.md` | Brazilian Portuguese | Portugu\u00eas (Brasil) |\n| `ro.md` | Romanian | Rom\u00e2n\u0103 |\n| `ru.md` | Russian | \u0420\u0443\u0441\u0441\u043a\u0438\u0439 |\n| `sk.md` | Slovak | Sloven\u010dina |\n| `sv.md` | Swedish | Svenska |\n| `th.md` | Thai | \u0e44\u0e17\u0e22 |\n| `tr.md` | Turkish | T\u00fcrk\u00e7e |\n| `uk.md` | Ukrainian | \u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430 \u043c\u043e\u0432\u0430 |\n| `vi.md` | Vietnamese | Ti\u1ebfng Vi\u1ec7t |\n| `zh.md` | Chinese (Simplified) | \u7b80\u4f53\u4e2d\u6587 |\n| `zh-tw.md` | Traditional Chinese (Taiwan) | \u7e41\u9ad4\u4e2d\u6587\uff08\u53f0\u7063\uff09 |\n\n*Note: The website will give readers their selected language's version of an article. If it is not available, the English version will be given.*\n\n## Front matter\n\nFront matter must be placed at the very top of the file. It is written in [YAML](https://en.wikipedia.org/wiki/YAML#Example) and describes additional information about the article. This must be surrounded by three hyphens (`---`) on the lines above and below it, and an empty line must follow it before the title heading.\n\n### Articles that need help\n\n*Note: Avoid translating English articles with this tag. In addition to this, this tag should be added when the translation needs its own clean up.*\n\nThe `needs_cleanup` tag may be added to articles that need rewriting or formatting help. It is also acceptable to open an issue on GitHub for this purpose. This tag must be written as shown below:\n\n```yaml\nneeds_cleanup: true\n```\n\nWhen adding this tag to an article, [comments](#comments) should also be added to explain what needs to be done to remove the tag.\n\n### Outdated articles\n\n*Note: Avoid translating English articles with this tag. If the English article has this tag, the translation must also have this tag.*\n\nEnglish articles may become outdated when the content they contain is misleading or no longer relevant. These should receive an `outdated` tag, which must be written as shown below:\n\n```yaml\noutdated: true\n```\n\nWhen adding this tag to an article, [comments](#comments) should also be added to explain what needs to be updated to remove the tag.\n\n### Outdated translations\n\nTranslated articles that are outdated must use the `outdated_translation` tag when the English variant is updated, except for minor wording, grammar changes, and the like, that do not affect the meaning of the article.\n\n```yaml\noutdated_translation: true\n```\n\nWhen outdating translations, they must also receive an `outdated_since` tag that points to the first commit where the English version is updated.\n\n```yaml\noutdated_since: 29eac89cd535f8b071ca000af8fe4f0be22bdc9b\n```\n\n### Tagging articles\n\nTags help the website's search engine query articles better. Tags should be written in the same language as the article and include the original list of tags. Tags should use lowercase letters where applicable.\n\nFor example, an article called \"Beatmap discussion\" may include the following tags:\n\n```yaml\ntags:\n - beatmap discussions\n - modding V2\n - MV2\n```\n\n### Translations without reviews\n\n*Note: Wiki maintainers will determine and apply this mark prior to merging.*\n\nSometimes, translations are added to the wiki without review from other native speakers of the language. In this case, the `no_native_review` mark is added to let future translators know that it may need to be checked again. This tag must be written as shown below:\n\n```yaml\nno_native_review: true\n```\n\n## Article naming\n\n*See also: [Folder names](#folder-names) and [Titles](#titles)*\n\nArticle titles should be singular and use sentence case. See [Wikipedia's naming conventions article](https://en.wikipedia.org/wiki/Wikipedia:Naming_conventions_(plurals)) for more details.\n\nArticle titles should match the folder name it is in (spaces may replace underscores (`_`) where appropriate). If the folder name changes, the article title should be changed to match it and vice versa.\n\n---\n\nContest and tournament articles are an exception. The folder name must use abbreviations, acronyms, or initialisms. The article's title must be the full name of the contest or tournament.\n\n## Folder and file structure\n\n### Folder names\n\n*See also: [Article naming](#article-naming)*\n\nFolder names must be in English and use sentence case.\n\nFolder names must only use these characters:\n\n- uppercase and lowercase letters\n- numbers\n- underscores (`_`)\n- hyphens (`-`)\n- exclamation marks (`!`)\n\n### Article file names\n\nThe file name of an article can be found in the `File Name` column of the [locales section](#locales). The location of a translated article must be placed in the same folder as the English article.\n\n### Index articles\n\nAn index article must be created if the folder is intended to only hold other articles. Index articles must contain a list of articles that are inside its own folder. They may also contain other information, such as a lead paragraph or descriptions of the linked articles.\n\n### Disambiguation articles\n\n[Disambiguation](/wiki/Disambiguation) articles must be placed in the `/wiki/Disambiguation` folder. The main page must be updated to include the disambiguation article. Refer to [Disambiguation/Mod](/wiki/Disambiguation/Mod) as an example.\n\nRedirects must be updated to have the ambiguous keyword(s) redirect to the disambiguation article.\n\nArticles linked from a disambiguation article must have a [For other uses](#for-other-uses) hatnote.\n\n## HTML\n\nHTML must not be used, with exception for [comments](#comments). The structure of the article must be redone if HTML is used.\n\n### Comments\n\nHTML comments should be used for marking to-dos, but may also be used to annotate text. They should be on their own line, but can be placed inline in a paragraph. If placed inline, the start of the comment must not have a space.\n\nBad example:\n\n```markdown\nHTML comments should be used for marking to-dos or annotate text.\n```\n\nGood example:\n\n```markdown\nHTML comments should be used for marking to-dos or annotate text.\n```\n\n## Editing\n\n### End of line sequence\n\n*Caution: Uploading Markdown files using `CRLF` (carriage return and line feed) via GitHub will result in those files using `CRLF`. To prevent this, set the line ending to `LF` (line feed) before uploading.*\n\nMarkdown files must be checked in using the `LF` end of line sequence.\n\n### Escaping\n\nMarkdown syntax should be escaped as needed. However, article titles are parsed as plain text and so must not be escaped.\n\n### Paragraphs\n\nEach paragraph must be followed by one empty line.\n\n### Line breaks\n\nLine breaks must use a backslash (`\\`).\n\nLine breaks must be used sparingly.\n\n## Hatnote\n\n*Not to be confused with [Notice](#notice).*\n\nHatnotes are short notes placed at the top of an article or section to help readers navigate to related articles or inform them about related topics.\n\nHatnotes must be italicised and be placed immediately after the heading. If multiple hatnotes are used, they must be on the same paragraph separated with a line break.\n\n### Main page\n\n*Main page* hatnotes direct the reader to the main article of a topic. When this hatnote is used, it implies that the section it is on is a summary of what the linked page is about. This hatnote should have only one link. These must be formatted as follows:\n\n```markdown\n*Main page: {article}*\n\n*Main pages: {article} and {article}*\n```\n\n### See also\n\n*See also* hatnotes suggest to readers other points of interest from a given article or section. These must be formatted as follows:\n\n```markdown\n*See also: {article}*\n\n*See also: {article} and {article}*\n```\n\n### For see\n\n*For see* hatnotes are similar to *see also* hatnotes, but are generally more descriptive and direct. This hatnote may use more than one link if necessary. These must be formatted as follows:\n\n```markdown\n*For {description}, see: {article}*\n\n*For {description}, see: {article} and {article}*\n```\n\n### Not to be confused with\n\n*Not to be confused with* hatnotes help distinguish ambiguous or misunderstood article titles or sections. This hatnote may use more than one link if necessary. These must be formatted as follows:\n\n```markdown\n*Not to be confused with {article}.*\n\n*Not to be confused with {article} or {article}.*\n```\n\n### For other uses\n\n*For other uses* hatnotes are similar to *not to be confused with* hatnotes, but links directly to the [disambiguation article](#disambiguation-articles). This hatnote must only link to the disambiguation article. These must be formatted as follows:\n\n```markdown\n*For other uses, see {disambiguation article}.*\n```\n\n## Notice\n\n*Not to be confused with [Hatnote](#hatnote).*\n\nA notice should be placed where appropriate in a section, but must start off the paragraph and use italics. Notices may contain bolding where appropriate, but should be kept to a minimum. Notices must be written as complete sentences. Thus, unlike most [hatnotes](#hatnote), notices must use a full stop (`.`) or an exclamation mark (`!`) if appropriate. Anything within the same paragraph of a notice must also be italicised. These must be formatted as follows:\n\n```markdown\n*Note: {note}.*\n\n*Notice: {notice}.*\n\n*Caution: {caution}.*\n\n*Warning: {warning}.*\n```\n\n- `Note` should be used for factual or trivial details.\n- `Notice` should be used for reminders or to draw attention to something that the reader should be made aware of.\n- `Caution` should be used to warn the reader to avoid unintended consequences.\n- `Warning` should be used to warn the reader that action may be taken against them.\n\n## Stacked hatnotes and notices\n\nMultiple hatnotes and notices may be stacked when necessary. When doing this, they must be stacked without blank lines and use trailing backslashes:\n\n```markdown\n*Warning: {warning}.*\\\n*See also: {article}*\n```\n\nIn many cases, it may be more fitting to embed extraneous hatnotes or notices into paragraph text instead of stacking many of them.\n\n## Emphasising\n\n### Bolding\n\nBold text must use double asterisks (`**`).\n\nLead paragraphs may bold the first occurrence of the article's title.\n\n### Italics\n\nItalics must use single asterisks (`*`).\n\nThe first occurrence of an abbreviation, acronym, or initialism may be italicised.\n\nItalics may also be used to provide emphasis or help with readability.\n\nNames of work or video games should be italicised. osu! \u2014 the game \u2014 is exempt from this.\n\nAs an example, when referring to songs in the format of `{artist} - {title}`, the whole part is a reference to the work and should therefore be italicised:\n\n```markdown\n*cYsmix - triangles* is a one of the three intro songs that can be heard when starting the game client.\n```\n\nArtist names are otherwise generally not italicised. This means that in free-form references, only the title should be italicised, because the artist name is then not part of the name of the work:\n\n```markdown\n*Blue Zenith* by xi is an infamous song in the osu! community due to a famous score set by a top player on a certain beatmap.\n```\n\n### Emphasis and links\n\nLinked text appears in a different colour which already provides emphasis, and therefore does not need further emphasis:\n\n```markdown\n[Camellia - OOPARTS](https://cametek.bandcamp.com/track/parts) is an example of a song officially created specifically for osu!, otherwise known as an *osu! original*, since it was specifically commissioned for the osu! World Cup 2020 tiebreaker.\n```\n\nThis however does not apply if the referenced work is not the only part of the link:\n\n```markdown\n[Voltaeyx's beatmap of *TheFatRat - Mayday (feat. Laura Brehm)*](https://osu.ppy.sh/beatmapsets/756794) amassed considerable popularity in 2018 due to its unique overlapping slider patterns.\n```\n\nThe above type of construction should be used sparingly, and must not be used in places with many links, such as tables or lists.\n\n## Headings\n\nAll headings must use sentence case.\n\nHeadings must use the [ATX (hash) style](https://github.github.com/gfm/#atx-headings) and must have an empty line before and after the heading. The title heading is an exception when it is on the first line. If this is the case, there only needs to be an empty line after the title heading.\n\nHeadings must not exceed a heading level of 5 and must not be used to style or format text.\n\n### Titles\n\n*See also: [Article naming](#article-naming)*\\\n*Caution: Titles are parsed as plain text; they must not be escaped.*\n\nThe first heading in all articles must be a level 1 heading, being the article's title. All headings afterwards must be [section headings](#sections). Titles must not contain formatting, links, or images.\n\nThe title heading must be on the first line, unless [front matter](#front-matter) is being used. If that is the case, the title heading must go after it and have an empty line before the title heading.\n\n### Sections\n\nSection headings must use levels 2 to 5. The section heading proceeding the [title heading](#titles) must be a level 2 heading. Unlike titles, section headings may have small image icons.\n\nSection headings must not skip a heading level (i.e. do not go from a level 2 heading to a level 4 heading) and must not contain formatting or links.\n\n*Notice: On the website, heading levels 4 and 5 will not appear in the table of contents.*\n\n### Custom identifiers\n\nIt is possible to redefine a section's identifier, which is used for linking to it directly. Custom identifiers should be used in case the automatically generated ones are too long or contain tricky punctuation marks or images:\n\n\n\n```markdown\n## My cooldown has passed. How do I appeal? {#appeal}\n\n## Common restriction reasons and cooldowns {#common-reasons}\n\n## Ideas for a multiplayer match {id=\u0438\u0434\u0435\u0438-\u0434\u043b\u044f-\u043c\u0443\u043b\u044c\u0442\u0438\u043f\u043b\u0435\u0435\u0440\u0430} \n```\n\nThis feature can also be used for tagging a specific part of the article which doesn't have a heading. Use it sparingly:\n\n```markdown\n> That's it! You're well on your way to becoming an osu! rhythm champion!\n{#tutorial-quote}\n```\n\n## Lists\n\nLists should not go over 4 levels of indentation and should not have an empty line in between each item.\n\nFor nested lists, bullets or numbers must align with the item content of their parent lists.\n\nThe following example was done incorrectly (take note of the spacing before the bullet):\n\n```markdown\n1. Fly a kite\n - Don't fly a kite if it's raining\n```\n\nThe following example was done correctly:\n\n```markdown\n1. Fly a kite\n - Don't fly a kite if it's raining\n```\n\n### Bulleted\n\nBulleted lists must use a hyphen (`-`). These must then be followed by one space. (Example shown below.)\n\n```markdown\n- osu!\n - Hit circle\n - Combo number\n - Approach circle\n - Slider\n - Hit circles\n - Slider body\n - Slider ticks\n - Spinner\n- osu!taiko\n```\n\n### Numbered\n\nThe numbers in a numbered list must be incremented to represent their step.\n\n```markdown\n1. Download the osu! installer.\n2. Run the installer.\n 1. To change the installation location, click the text underneath the progression bar.\n 2. The installer will prompt for a new location, choose the installation folder.\n3. osu! will start up once installation is complete.\n4. Sign in.\n```\n\n### Mixed\n\nCombining both bulleted and numbered lists should be done sparingly.\n\n```markdown\n1. Download a skin from the forums.\n2. Load the skin file into osu!.\n - If the file is a `.zip`, unzip it and move the contents into the `Skins/` folder (found in your osu! installation folder).\n - If the file is a `.osk`, open it on your desktop or drag-and-drop it into the game client.\n3. Open osu!, if it is not opened, and select the skin in the options.\n - This may have been completed if you opened the `.osk` file or drag-and-dropped it into the game client.\n```\n\n## Code\n\nThe markup for code is a grave mark (`` ` ``). To put grave marks in code, use double grave marks instead. If the grave mark is at the start or end, pad it with one space. (Example shown below.)\n\n```markdown\n`` ` ``\n`` `Space` ``\n```\n\n### Keyboard keys\n\n*Notice: When denoting the letter itself, and not the keyboard key, use quotation marks instead.*\n\nWhen representing keyboard keys, use capital letters for single characters and title case for modifiers. Use the plus symbol (`+`) (without code) to represent key combinations. (Example shown below.)\n\n```markdown\npippi is spelt with a lowercase \"p\" like peppy.\n\nPress `Ctrl` + `O` to open the open dialog.\n```\n\nWhen representing a space or the spacebar, use `` `Space` ``.\n\n### Button and menu text\n\nWhen copying the text from a menu or button, the letter casing should be copied as it appears. (Example shown below.)\n\n```markdown\nThe `osu!direct` button is visible in the main menu on the right side, if you have an active osu!supporter tag.\n```\n\n### Folder and directory names\n\nWhen copying the name of a folder or directory, the letter casing should be copied as it appears, but prefer lowercased paths when possible. Directory paths must not be absolute (i.e. do not start the directory name from the drive letter or from the root folder). (Example shown below.)\n\n```markdown\nosu! is installed in the `AppData/Local` folder by default, unless specified otherwise during installation.\n```\n\n### Keywords and commands\n\nWhen copying a keyword or command, the letter casing should be copied as it appears or how someone normally would type it. If applicable, prefer lowercase letters. (Example shown below.)\n\n```markdown\nAs of now, the `Name` and `Author` commands in the skin configuration file (`skin.ini`) do nothing.\n```\n\n### File names\n\nWhen copying the name of a file, the letter casing should be copied as it appears. If applicable, prefer lowercase letters. (Example shown below.)\n\n```markdown\nTo play osu!, double click the `osu!.exe` icon.\n```\n\n### File extensions\n\n*Notice: File formats (not to be confused with file extensions) must be written in capital letters without the prefixed fullstop (`.`).*\n\nFile extensions must be prefixed with a fullstop (`.`) and be followed by the file extension in lowercase letters. (Example shown below.)\n\n```markdown\nThe JPG (or JPEG) file format has the `.jpg` (or `.jpeg`) extension.\n```\n\n### Chat channels\n\nWhen copying the name of a chat channel, start it with a hash (`#`), followed by the channel name in lowercase letters. (Example shown below.)\n\n```markdown\n`#lobby` is where you can advertise your multi room.\n```\n\n## Preformatted text (code blocks)\n\n*Notice: Syntax highlighting for preformatted text is not implemented on the website yet.*\n\nPreformatted text (also known as code blocks) must be fenced using three grave marks. They should set the language identifier for syntax highlighting.\n\n## Links\n\n*See also: [Footnotes](#footnotes)*\n\nThere are two types of links: inline and reference. Inline has two styles.\n\nThe following is an example of both inline styles:\n\n```markdown\n[Game modifier](/wiki/Game_modifier)\n\n\n```\n\nThe following is an example of the reference style:\n\n```markdown\n[Game modifier][game mods link]\n\n[game mods link]: /wiki/Game_modifier\n```\n\n---\n\nLinks must use the inline style if they are only referenced once. The inline angle brackets style should be avoided. References to reference links must be placed at the bottom of the article.\n\n### Internal links\n\n*Note: Internal links refer to links that stay inside the `https://osu.ppy.sh/` domain.*\n\n#### Wiki links\n\nAll links that point to an wiki article should start with `/wiki/` followed by the path to get to the article you are targeting. Relative links may also be used. Some examples include the following:\n\n```markdown\n[FAQ](/wiki/FAQ)\n[pippi](/wiki/Mascots#pippi)\n[Beatmaps](../)\n[Pattern](./Pattern)\n```\n\nWiki links must not use redirects and must not have a trailing forward slash (`/`).\n\nBad examples include the following:\n\n```markdown\n[Article styling criteria](/wiki/ASC)\n[Developers](/wiki/Developers/)\n[Developers](/wiki/Developers/#game-client-developers)\n```\n\nGood examples include the following:\n\n```markdown\n[Article styling criteria](/wiki/Article_styling_criteria)\n[Developers](/wiki/People/The_Team/Developers)\n[Developers](/wiki/People/The_Team/Developers#game-client-developers)\n```\n\n##### Sub-article links\n\nWiki links that point to a sub-article should include the parent article's folder name in its link text. See the following example:\n\n```markdown\n*See also: [Beatmap Editor/Design](/wiki/Client/Beatmap_editor/Design)*\n```\n\n##### Section links\n\n*Notice: On the website, heading levels 4 and 5 are not given the id attribute. This means that they can not be linked to directly.*\n\nWiki links that point to a section of an article may use the section sign symbol (`\u00a7`). See the following example:\n\n```markdown\n*For timing rules, see: [Ranking Criteria \u00a7 Timing](/wiki/Ranking_Criteria#timing)*\n```\n\n#### Other osu! links\n\nThe URL from the address bar of your web browser should be copied as it is when linking to other osu! web pages. The `https://osu.ppy.sh` part of the URL must be kept.\n\n##### User profiles\n\nAll usernames must be linked on first occurrence. Other occurrences are optional, but must be consistent throughout the entire article for all usernames. If it is difficult to determine the user's id, it may be skipped over.\n\nWhen linking to a user profile, the user's id number must be used. Use the new website (`https://osu.ppy.sh/users/{username})`) to get the user's id.\n\nThe link text of the user link should be the user's current name.\n\n##### Difficulties\n\nWhenever linking to a single difficulty, use this format as the link text:\n\n```\n{artist} - {title} ({creator}) [{difficulty_name}]\n```\n\nThe link must actually link to that difficulty. Beatmap difficulty URLs must be formatted as follows:\n\n```\nhttps://osu.ppy.sh/beatmapsets/{BeatmapSetID}#{mode}/{BeatmapID}\n```\n\nThe difficulty name may be left outside of the link text, but doing so must be consistent throughout the entire article.\n\n##### Beatmaps\n\nWhenever linking to a beatmap, use this format as the link text:\n\n```\n{artist} - {title} ({creator})\n```\n\nAll beatmap URLs must be formatted as follows:\n\n```\nhttps://osu.ppy.sh/beatmapsets/{BeatmapSetID}\n```\n\n### External links\n\n*Notice: External links refers to links that go outside the `https://osu.ppy.sh/` domain.*\n\nThe `https` protocol must be used, unless the site does not support it. External links must be a clean and direct link to a reputable source. The URL from the address bar of your web browser should be copied as it is when linking to other external pages.\n\nThere are no visual differences between external and osu! web links. Due to this, the website name should be included in the title text. See the following example:\n\n```markdown\n*For more information about music theory, see: [Music theory](https://en.wikipedia.org/wiki/Music_theory)*\n```\n\n## Images\n\nThere are two types of image links: inline and reference. Examples:\n\n**Inline style:**\n\n```markdown\n![Gold crown](/wiki/shared/crown-gold.png \"1st place\")\n```\n\n**Reference style:**\n\n```markdown\n![Gold crown][GCrown]\n\n[GCrown]: /wiki/shared/crown-gold.png \"1st place\"\n```\n\nImages should use the inline linking style. Reference link definitions must be placed at the bottom of the article.\n\nAll block images on the page (that have nothing else on the same line) are combined into a single gallery, which can be navigated using arrow icons on both sides of the screen, keyboard shortcuts, or screen swipes on mobile devices.\n\n### Alternative and title text\n\nThe text in the first pair of square brackets (*alternative text*) should describe the image literally. It is used by screen readers or when the image fails to load. It can be omitted if it is identical to the title text or if the image is included only for decorative purposes.\n\nThe text in the quotation marks (*title text*) should give additional context to the image or indicate its meaning. It is displayed as a tooltip when hovering over the image and used as a caption if applicable. It does not support any markdown formatting.\n\n### Display\n\nIf an image is the sole content of a paragraph, it displays as a centred block. Otherwise, it flows with the surrounding inline text.\n\nBlock images with title text display the title text as a caption below the image. Avoid adding [HTML comment](#comments) or any other text on the same line as the image, as this will cause the caption not to be rendered.\n\nBlock images are commonly paired with [infobox](#infoboxes) formatting to reduce their initial size and float them to the side of other content:\n\n```markdown\n::: Infobox\n![](img/mod-response.png \"An example of a response to a mod\")\n:::\n```\n\n### Image caching\n\nImages on the website are cached for up to 60 days. The cached image is matched with the image link's URL.\n\nWhen updating an image, either change the image's name or append a query string to the URL. In both cases, all translations linking to the updated image should also be updated.\n\n### Formats and quality\n\nImages should use the JPG format at quality 8 (80 or 80%, depending on the program). If the image contains transparency or has text that must be readable, use the PNG format instead. If the image contains an animation, the GIF format can be used; however, this should be used sparingly as these may take longer to load or can be bigger then the [max file size](#file-size).\n\n### File size\n\nImages must be under 1 megabyte, otherwise they will fail to load. Downscaling and using JPG at 80% is almost always under the size limit.\n\nAll images should be optimised as much as possible. Use [jpeg-archive](https://github.com/danielgtaylor/jpeg-archive) to compress JPEG images. For consistency, use the following command for jpeg-archive:\n\n```sh\njpeg-recompress -am smallfry \n```\n\nWhere `` is the file name to be compressed and `` is the compressed file name.\n\n### File names\n\n*Notice: File extensions must use lowercase letters, otherwise they will fail to load!*\n\nUse hyphens (`-`) when spacing words. When naming an image, the file name should be meaningful or descriptive but short.\n\nImages must be placed in a folder named `img` under the article's folder. Images that are used in multiple articles should be stored in the `/wiki/shared` folder.\n\n### Max image width\n\nThe website's max image width is the width of the article body. Images should be no wider than 800 pixels.\n\n### Annotating images\n\nWhen annotating images, use *Torus Regular*. For Chinese, Korean, Japanese characters, use *Microsoft YaHei*.\n\nAnnotating images should be avoided, as it is difficult for translators (and other editors) to edit them.\n\n#### Translating annotated images\n\nWhen translating annotated images, the localised image version must be placed in the same directory as the original version (i.e. the English version). The filename of a localised image version must start with the original version's name, followed by a hyphen, followed by the locale name (in capital letters). See the following examples:\n\n- `hardrock-mod-vs-easy-mod.jpg` for English\n- `hardrock-mod-vs-easy-mod-DE.jpg` for German\n- `hardrock-mod-vs-easy-mod-ZH-TW.jpg` for Traditional Chinese\n\n### Screenshots of gameplay\n\nAll screenshots of gameplay must be done in the stable build, unless it is for a specific feature that is unavailable in the stable build. You should use the in-game screenshot feature (`F12`).\n\n#### Game client settings\n\n*Note: If you do not want to change your current settings for the wiki, you can move your `osu!..cfg` out of the osu! folder and move it back later.*\n\nYou must set these settings before taking a screenshot of the game client (settings not stated below are assumed to be at their defaults):\n\n- Select language: `English`\n- Prefer metadata in original language: `Enabled`\n- Resolution: `1280x720`\n- Fullscreen mode: `Disabled`\n- Parallax: `Disabled`\n- Menu tips: `Disabled`\n- Seasonal backgrounds: `Never`\n- Always show key overlay: `Enabled`\n- Current skin: `Default` (first option)\n\n*Notice to translators: If you are translating an article containing screenshots of the game, you may set the game client's language to the language you are translating in.*\n\n### Image links\n\nImages must not be part of a link text.\n\n## Flag icons\n\nThe flag icons use the two letter code (in all capital letters) to match a certain territory. When adding a flag inline, use this format:\n\n```markdown\n::{ flag=XX }::\n```\n\nWhere `XX` is the [ISO 3166-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) two-lettered country code for the flag.\n\n## Tables\n\nTables on the website only support headings along the first row.\n\nTables must not be beautified (do not pad cells with extra spaces to make their widths uniform). They must have a vertical bar (`|`) on the left and right sides and the text of each cell must be padded with one space on both sides. Empty cells must use a vertical bar (`|`) followed by two spaces then another vertical bar (`|`).\n\nThe delimiter row (the next line after the table heading) must use only three characters per column (and be padded with a space on both sides), which must look like one of the following:\n\n- `:--` (for left align)\n- `:-:` (for centre align)\n- `--:` (for right align)\n\n---\n\nThe following is an example of what a table should look like:\n\n```markdown\n| Team \"Picturesque\" Red | Score | Team \"Statuesque\" Blue | Average Beatmap Stars |\n| :-- | :-: | --: | :-- |\n| **peppy** | 5 - 2 | pippi | 9.3 stars |\n| Aiko | 1 - 6 | **Alisa** | 4.2 stars |\n| Ry\u016bta | 3 - 4 | **Yuzu** | 5.1 stars |\n| **Taikonator** | 7 - 0 | Tama | 13.37 stars |\n| Maria | No Contest | Mocha | |\n```\n\n## Infoboxes\n\nAn infobox is a fixed-width block which is aligned to the right side of the article. It may contain a relevant image, which explains the surrounding text, or a block of navigation that links to other articles from the same category.\n\nExample use, rendered on the right:\n\n\n\n::: Infobox\n![](/wiki/shared/mods/SD.png \"Sudden Death mod icon\")\n:::\n\n```markdown\n::: Infobox\n![](/wiki/shared/mods/SD.png \"Sudden Death mod icon\")\n:::\n```\n\nInfoboxes should be used with caution in the following cases:\n\n- Short sections: the next section's heading appears below any infoboxes, leaving a large gap after the text.\n- Several images at once: instead, use individual infoboxes for better design.\n\n## Footnotes\n\nFootnotes are short notes located at the end of the page. They are used for citing sources, or providing background information that would otherwise disrupt the flow of the article. Footnotes may contain text formatting and links.\n\nIn the osu! wiki, footnotes are implemented using special syntax (`[^identifier]`). Footnotes can use any identifier, but they will automatically be rendered as superscripts with increasing numbers in order of their first appearance. Translations must not modify identifiers of footnotes.\n\nFootnote references are placed directly after the words, phrases, or sentences they explain, with no space in between. These references must be placed after punctuation, except for parentheses, when they pertain to the contents inside, and dashes.\n\nThe footnotes themselves must be placed in a separate second-level heading at the end of the article. Depending on the content, the heading used may be `References`, `Notes`, or `Notes and references`.\n\nCorrect usage examples:\n\n```markdown\nThe osu! wiki is a project that was meant to replace the old FAQ system.[^wiki-faq] It was named after the rhythm game osu![^osu] and the largest open online encyclopedia, Wikipedia. From the very start, it had attracted skillful translators[^wiki-tl] and editors.\n\n## References\n\n[^wiki-faq]: https://osu.ppy.sh/community/forums/topics/68525\n[^wiki-tl]: https://osu.ppy.sh/community/forums/posts/1177500\n[^osu]: https://osu.ppy.sh/community/forums/posts/1178153\n```\n\n### Citations\n\nCitations, or references, are used to identify a source of information. Citations via footnotes should be preferred over inline links.\n\nReferences should whenever applicable specify author, date, service/platform, and title. The exact format may vary depending on the referenced material with a preference for brevity.\n\nExamples:\n\n```markdown\nThe first version of the osu!api was made available on July 2, 2013.[^api-first-usage] It had received critical acclaim from users.[^api-praise] A new version of API, released several years later, contains many more capabilities.[^api-v2-2020] Endpoint versioning is common among web APIs.[^web-api]\n\n## References\n\n[^api-first-usage]: [Forum thread by peppy (2013-07-02) \"osu!api open beta\"](https://osu.ppy.sh/community/forums/posts/2403913)\n[^api-praise]: [Forum post by Menchi (2013-11-02) in \"osu!api open beta\"](https://osu.ppy.sh/community/forums/posts/2662247)\n[^api-v2-2020]: [Tweet by @ppy (2020-03-20)](https://twitter.com/ppy/status/1263083636363948032)\n[^web-api]: [\"Web API\" on Wikipedia](https://en.wikipedia.org/wiki/Web_API)\n```\n\n### Notes\n\nFootnotes may be used for storing explanations or tangential remarks which cannot be inlined without worsening the article's readability, or are less significant than the article itself. Such footnotes may use free-form text.\n\nExample:\n\n```markdown\nA tournament must not be organised and run by an inexperienced team of unaccomplished and irreputable staff.[^staff]\n\n## Notes\n\n[^staff]: An *inexperienced* staff member is loosely defined as someone who has been playing osu! for less than an hour in total.\n```\n\n## Blockquotes\n\nThe blockquote is limited to [quoting someone or something](/wiki/Article_styling_criteria/Writing#block-quotation). It must not be used to format text otherwise.\n\n```markdown\n> plz enjoy game\n\n\u2014rrtyui\n```\n\n## Thematic breaks\n\nThe thematic break (also known as the horizontal rule or line) should be used sparingly. A few uses of the thematic break may include (but is not limited to):\n\n- separating images from text\n- separating multiple images that follow one another\n- shifting the topic within a section\n\nThese must have an empty line before and after the markup. Thematic breaks must use only three hyphens, as depicted below:\n\n```markdown\n---\n```\n" + }; + + // From https://osu.ppy.sh/api/v2/wiki/en/Article_styling_criteria + private APIWikiPage responseArticleParentPage => new APIWikiPage + { + Title = "Article styling criteria", + Layout = "markdown_page", + Path = "Article_styling_criteria", + Locale = "en", + Subtitle = null, + Markdown = + "---\ntags:\n - wiki standards\n---\n\n# Article styling criteria\n\n*For news posts, see: [News Styling Criteria](/wiki/News_styling_criteria)*\n\nThe article styling criteria (ASC) serve as the osu! wiki's enforced styling standards to keep consistency in clarity, formatting, and layout in all articles, and to help them strive for proper grammar, correct spelling, and correct information.\n\nThese articles are primarily tools to aid in reviewing and represent the consensus of osu! wiki contributors formed over the years. Since the wiki is a collaborative effort through the review process, it is not necessary to read or memorise all of the ASC at once. If you are looking to contribute, read the [contribution guide](/wiki/osu!_wiki/Contribution_guide).\n\nTo suggest changes regarding the article styling criteria, [open an issue on GitHub](https://github.com/ppy/osu-wiki/issues/new).\n\n## Standards\n\n*Notice: The articles below use [RFC 2119](https://tools.ietf.org/html/rfc2119) to describe requirement levels.*\n\nThe article styling criteria are split up into two articles:\n\n- [Formatting](Formatting): includes Markdown and other formatting rules\n- [Writing](Writing): includes writing practices and other grammar rules\n" }; } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiSidebar.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiSidebar.cs index 862b3667b1..89944b3a8a 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiSidebar.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiSidebar.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Markdig.Parsers; using Markdig.Syntax; using Markdig.Syntax.Inlines; @@ -12,7 +14,7 @@ using osu.Game.Overlays.Wiki; namespace osu.Game.Tests.Visual.Online { - public class TestSceneWikiSidebar : OsuTestScene + public partial class TestSceneWikiSidebar : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Orange); diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs index 9ba0da1911..0c536cb1d4 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Bindables; @@ -15,7 +17,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Playlists { - public class TestScenePlaylistsLoungeSubScreen : OnlinePlayTestScene + public partial class TestScenePlaylistsLoungeSubScreen : OnlinePlayTestScene { protected new TestRoomManager RoomManager => (TestRoomManager)base.RoomManager; @@ -93,7 +95,7 @@ namespace osu.Game.Tests.Visual.Playlists loungeScreen.ChildrenOfType().First().ScreenSpaceDrawQuad .Contains(room.ScreenSpaceDrawQuad.Centre); - private class TestLoungeSubScreen : PlaylistsLoungeSubScreen + private partial class TestLoungeSubScreen : PlaylistsLoungeSubScreen { public new Bindable SelectedRoom => base.SelectedRoom; } diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs index 666e32d1d0..1053789b27 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Framework.Bindables; @@ -8,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Playlists; @@ -15,7 +18,7 @@ using osu.Game.Tests.Visual.OnlinePlay; namespace osu.Game.Tests.Visual.Playlists { - public class TestScenePlaylistsMatchSettingsOverlay : OnlinePlayTestScene + public partial class TestScenePlaylistsMatchSettingsOverlay : OnlinePlayTestScene { protected new TestRoomManager RoomManager => (TestRoomManager)base.RoomManager; @@ -23,17 +26,21 @@ namespace osu.Game.Tests.Visual.Playlists protected override OnlinePlayTestSceneDependencies CreateOnlinePlayDependencies() => new TestDependencies(); - [SetUp] - public new void Setup() => Schedule(() => + public override void SetUpSteps() { - SelectedRoom.Value = new Room(); + base.SetUpSteps(); - Child = settings = new TestRoomSettings(SelectedRoom.Value) + AddStep("create overlay", () => { - RelativeSizeAxes = Axes.Both, - State = { Value = Visibility.Visible } - }; - }); + SelectedRoom.Value = new Room(); + + Child = settings = new TestRoomSettings(SelectedRoom.Value) + { + RelativeSizeAxes = Axes.Both, + State = { Value = Visibility.Visible } + }; + }); + } [Test] public void TestButtonEnabledOnlyWithNameAndBeatmap() @@ -140,9 +147,9 @@ namespace osu.Game.Tests.Visual.Playlists AddUntilStep("error not displayed", () => !settings.ErrorText.IsPresent); } - private class TestRoomSettings : PlaylistsRoomSettingsOverlay + private partial class TestRoomSettings : PlaylistsRoomSettingsOverlay { - public TriangleButton ApplyButton => ((MatchSettings)Settings).ApplyButton; + public RoundedButton ApplyButton => ((MatchSettings)Settings).ApplyButton; public OsuTextBox NameField => ((MatchSettings)Settings).NameField; public OsuDropdown DurationField => ((MatchSettings)Settings).DurationField; diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs index 1288b9e765..c4a1200cb1 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Online.API.Requests.Responses; @@ -11,23 +13,27 @@ using osu.Game.Users; namespace osu.Game.Tests.Visual.Playlists { - public class TestScenePlaylistsParticipantsList : OnlinePlayTestScene + public partial class TestScenePlaylistsParticipantsList : OnlinePlayTestScene { - [SetUp] - public new void Setup() => Schedule(() => + public override void SetUpSteps() { - SelectedRoom.Value = new Room { RoomID = { Value = 7 } }; + base.SetUpSteps(); - for (int i = 0; i < 50; i++) + AddStep("create list", () => { - SelectedRoom.Value.RecentParticipants.Add(new APIUser + SelectedRoom.Value = new Room { RoomID = { Value = 7 } }; + + for (int i = 0; i < 50; i++) { - Username = "peppy", - Statistics = new UserStatistics { GlobalRank = 1234 }, - Id = 2 - }); - } - }); + SelectedRoom.Value.RecentParticipants.Add(new APIUser + { + Username = "peppy", + Statistics = new UserStatistics { GlobalRank = 1234 }, + Id = 2 + }); + } + }); + } [Test] public void TestHorizontalLayout() diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs index c532e8bc05..cb422d8c06 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -26,7 +28,7 @@ using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.Playlists { - public class TestScenePlaylistsResultsScreen : ScreenTestScene + public partial class TestScenePlaylistsResultsScreen : ScreenTestScene { private const int scores_per_result = 10; private const int real_user_position = 200; @@ -59,6 +61,7 @@ namespace osu.Game.Tests.Visual.Playlists userScore = TestResources.CreateTestScoreInfo(); userScore.TotalScore = 0; userScore.Statistics = new Dictionary(); + userScore.MaximumStatistics = new Dictionary(); bindHandler(); @@ -185,8 +188,8 @@ namespace osu.Game.Tests.Visual.Playlists // pre-check for requests we should be handling (as they are scheduled below). switch (request) { - case ShowPlaylistUserScoreRequest _: - case IndexPlaylistScoresRequest _: + case ShowPlaylistUserScoreRequest: + case IndexPlaylistScoresRequest: break; default: @@ -362,7 +365,7 @@ namespace osu.Game.Tests.Visual.Playlists }; } - private class TestResultsScreen : PlaylistsResultsScreen + private partial class TestResultsScreen : PlaylistsResultsScreen { public new LoadingSpinner LeftSpinner => base.LeftSpinner; public new LoadingSpinner CentreSpinner => base.CentreSpinner; diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs index 14f8c1d6d4..6c732f4295 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs @@ -1,9 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using System.Linq; +using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -14,9 +17,12 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Menu; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.OnlinePlay.Playlists; @@ -27,10 +33,9 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Playlists { - public class TestScenePlaylistsRoomCreation : OnlinePlayTestScene + public partial class TestScenePlaylistsRoomCreation : OnlinePlayTestScene { private BeatmapManager manager; - private RulesetStore rulesets; private TestPlaylistsRoomSubScreen match; @@ -39,8 +44,8 @@ namespace osu.Game.Tests.Visual.Playlists [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, rulesets, API, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, API, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); } @@ -74,7 +79,7 @@ namespace osu.Game.Tests.Visual.Playlists AddUntilStep("Leaderboard shows two aggregate scores", () => match.ChildrenOfType().Count(s => s.ScoreText.Text != "0") == 2); - AddStep("start match", () => match.ChildrenOfType().First().TriggerClick()); + ClickButtonWhenEnabled(); AddUntilStep("player loader loaded", () => Stack.CurrentScreen is PlayerLoader); } @@ -214,16 +219,32 @@ namespace osu.Game.Tests.Visual.Playlists importedBeatmap = manager.Import(beatmap.BeatmapInfo.BeatmapSet)?.Value.Detach(); }); - private class TestPlaylistsRoomSubScreen : PlaylistsRoomSubScreen + private partial class TestPlaylistsRoomSubScreen : PlaylistsRoomSubScreen { public new Bindable SelectedItem => base.SelectedItem; public new Bindable Beatmap => base.Beatmap; + [Resolved(canBeNull: true)] + [CanBeNull] + private IDialogOverlay dialogOverlay { get; set; } + public TestPlaylistsRoomSubScreen(Room room) : base(room) { } + + public override bool OnExiting(ScreenExitEvent e) + { + // For testing purposes allow the screen to exit without confirming on second attempt. + if (!ExitConfirmed && dialogOverlay?.CurrentDialog is ConfirmDiscardChangesDialog confirmDialog) + { + confirmDialog.PerformAction(); + return true; + } + + return base.OnExiting(e); + } } } } diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsScreen.cs index 63bd7c8068..71e284ecfe 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsScreen.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; namespace osu.Game.Tests.Visual.Playlists { [TestFixture] - public class TestScenePlaylistsScreen : ScreenTestScene + public partial class TestScenePlaylistsScreen : ScreenTestScene { protected override bool UseOnlineAPI => true; diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs b/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs index ac736086fd..bf18bd3e51 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Framework.Extensions.Color4Extensions; @@ -20,19 +22,28 @@ using osuTK; namespace osu.Game.Tests.Visual.Ranking { - public class TestSceneAccuracyCircle : OsuTestScene + public partial class TestSceneAccuracyCircle : OsuTestScene { - [TestCase(0.2, ScoreRank.D)] - [TestCase(0.5, ScoreRank.D)] - [TestCase(0.75, ScoreRank.C)] - [TestCase(0.85, ScoreRank.B)] - [TestCase(0.925, ScoreRank.A)] - [TestCase(0.975, ScoreRank.S)] - [TestCase(0.9999, ScoreRank.S)] - [TestCase(1, ScoreRank.X)] - public void TestRank(double accuracy, ScoreRank rank) + [TestCase(0)] + [TestCase(0.2)] + [TestCase(0.5)] + [TestCase(0.6999)] + [TestCase(0.7)] + [TestCase(0.75)] + [TestCase(0.7999)] + [TestCase(0.8)] + [TestCase(0.85)] + [TestCase(0.8999)] + [TestCase(0.9)] + [TestCase(0.925)] + [TestCase(0.9499)] + [TestCase(0.95)] + [TestCase(0.975)] + [TestCase(0.9999)] + [TestCase(1)] + public void TestRank(double accuracy) { - var score = createScore(accuracy, rank); + var score = createScore(accuracy, ScoreProcessor.RankFromAccuracy(accuracy)); addCircleStep(score); } diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs index 85306b9354..e92e74598d 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -19,7 +21,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Ranking { - public class TestSceneContractedPanelMiddleContent : OsuTestScene + public partial class TestSceneContractedPanelMiddleContent : OsuTestScene { [Test] public void TestShowPanel() @@ -43,7 +45,7 @@ namespace osu.Game.Tests.Visual.Ranking Child = new ContractedPanelMiddleContentContainer(workingBeatmap, score); } - private class ContractedPanelMiddleContentContainer : Container + private partial class ContractedPanelMiddleContentContainer : Container { [Cached] private Bindable workingBeatmap { get; set; } diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs index 2a31728f87..bd7a11b4bb 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using JetBrains.Annotations; using NUnit.Framework; @@ -25,7 +27,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Ranking { - public class TestSceneExpandedPanelMiddleContent : OsuTestScene + public partial class TestSceneExpandedPanelMiddleContent : OsuTestScene { [Resolved] private RulesetStore rulesetStore { get; set; } @@ -103,7 +105,7 @@ namespace osu.Game.Tests.Visual.Ranking private bool containsAny(string text, params string[] stringsToMatch) => stringsToMatch.Any(text.Contains); - private class ExpandedPanelMiddleContentContainer : Container + private partial class ExpandedPanelMiddleContentContainer : Container { public ExpandedPanelMiddleContentContainer(ScoreInfo score) { diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelTopContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelTopContent.cs index a2fa142896..be7be6d4f1 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelTopContent.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelTopContent.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -11,7 +13,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Ranking { - public class TestSceneExpandedPanelTopContent : OsuTestScene + public partial class TestSceneExpandedPanelTopContent : OsuTestScene { public TestSceneExpandedPanelTopContent() { diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs index 7471b6acf2..a40cb41e2c 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; @@ -16,17 +17,27 @@ using osuTK; namespace osu.Game.Tests.Visual.Ranking { - public class TestSceneHitEventTimingDistributionGraph : OsuTestScene + public partial class TestSceneHitEventTimingDistributionGraph : OsuTestScene { - private HitEventTimingDistributionGraph graph; + private HitEventTimingDistributionGraph graph = null!; + private readonly BindableFloat width = new BindableFloat(600); + private readonly BindableFloat height = new BindableFloat(130); private static readonly HitObject placeholder_object = new HitCircle(); + public TestSceneHitEventTimingDistributionGraph() + { + width.BindValueChanged(e => graph.Width = e.NewValue); + height.BindValueChanged(e => graph.Height = e.NewValue); + } + [Test] public void TestManyDistributedEvents() { createTest(CreateDistributedHitEvents()); AddStep("add adjustment", () => graph.UpdateOffset(10)); + AddSliderStep("width", 0.0f, 1000.0f, width.Value, width.Set); + AddSliderStep("height", 0.0f, 1000.0f, height.Value, height.Set); } [Test] @@ -41,6 +52,65 @@ namespace osu.Game.Tests.Visual.Ranking createTest(Enumerable.Range(-150, 300).Select(i => new HitEvent(i / 50f, HitResult.Perfect, placeholder_object, placeholder_object, null)).ToList()); } + [Test] + public void TestSparse() + { + createTest(new List + { + new HitEvent(-7, HitResult.Perfect, placeholder_object, placeholder_object, null), + new HitEvent(-6, HitResult.Perfect, placeholder_object, placeholder_object, null), + new HitEvent(-5, HitResult.Perfect, placeholder_object, placeholder_object, null), + new HitEvent(5, HitResult.Perfect, placeholder_object, placeholder_object, null), + new HitEvent(6, HitResult.Perfect, placeholder_object, placeholder_object, null), + new HitEvent(7, HitResult.Perfect, placeholder_object, placeholder_object, null), + }); + } + + [Test] + public void TestVariousTypesOfHitResult() + { + createTest(CreateDistributedHitEvents(0, 50).Select(h => + { + double offset = Math.Abs(h.TimeOffset); + HitResult result = offset > 36 ? HitResult.Miss + : offset > 32 ? HitResult.Meh + : offset > 24 ? HitResult.Ok + : offset > 16 ? HitResult.Good + : offset > 8 ? HitResult.Great + : HitResult.Perfect; + return new HitEvent(h.TimeOffset, result, placeholder_object, placeholder_object, null); + }).ToList()); + } + + [Test] + public void TestMultipleWindowsOfHitResult() + { + var wide = CreateDistributedHitEvents(0, 50).Select(h => + { + double offset = Math.Abs(h.TimeOffset); + HitResult result = offset > 36 ? HitResult.Miss + : offset > 32 ? HitResult.Meh + : offset > 24 ? HitResult.Ok + : offset > 16 ? HitResult.Good + : offset > 8 ? HitResult.Great + : HitResult.Perfect; + + return new HitEvent(h.TimeOffset, result, placeholder_object, placeholder_object, null); + }); + var narrow = CreateDistributedHitEvents(0, 50).Select(h => + { + double offset = Math.Abs(h.TimeOffset); + HitResult result = offset > 25 ? HitResult.Miss + : offset > 20 ? HitResult.Meh + : offset > 15 ? HitResult.Ok + : offset > 10 ? HitResult.Good + : offset > 5 ? HitResult.Great + : HitResult.Perfect; + return new HitEvent(h.TimeOffset, result, placeholder_object, placeholder_object, null); + }); + createTest(wide.Concat(narrow).ToList()); + } + [Test] public void TestZeroTimeOffset() { @@ -78,7 +148,7 @@ namespace osu.Game.Tests.Visual.Ranking { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(600, 130) + Size = new Vector2(width.Value, height.Value) } }; }); diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneOverallRanking.cs b/osu.Game.Tests/Visual/Ranking/TestSceneOverallRanking.cs new file mode 100644 index 0000000000..355a572f95 --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneOverallRanking.cs @@ -0,0 +1,117 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Online.Solo; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking.Statistics.User; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Ranking +{ + public partial class TestSceneOverallRanking : OsuTestScene + { + private OverallRanking overallRanking = null!; + + [Test] + public void TestUpdatePending() + { + createDisplay(); + } + + [Test] + public void TestAllIncreased() + { + createDisplay(); + displayUpdate( + new UserStatistics + { + GlobalRank = 12_345, + Accuracy = 98.99, + MaxCombo = 2_322, + RankedScore = 23_123_543_456, + TotalScore = 123_123_543_456, + PP = 5_072 + }, + new UserStatistics + { + GlobalRank = 1_234, + Accuracy = 99.07, + MaxCombo = 2_352, + RankedScore = 23_124_231_435, + TotalScore = 123_124_231_435, + PP = 5_434 + }); + } + + [Test] + public void TestAllDecreased() + { + createDisplay(); + displayUpdate( + new UserStatistics + { + GlobalRank = 1_234, + Accuracy = 99.07, + MaxCombo = 2_352, + RankedScore = 23_124_231_435, + TotalScore = 123_124_231_435, + PP = 5_434 + }, + new UserStatistics + { + GlobalRank = 12_345, + Accuracy = 98.99, + MaxCombo = 2_322, + RankedScore = 23_123_543_456, + TotalScore = 123_123_543_456, + PP = 5_072 + }); + } + + [Test] + public void TestNoChanges() + { + var statistics = new UserStatistics + { + GlobalRank = 12_345, + Accuracy = 98.99, + MaxCombo = 2_322, + RankedScore = 23_123_543_456, + TotalScore = 123_123_543_456, + PP = 5_072 + }; + + createDisplay(); + displayUpdate(statistics, statistics); + } + + [Test] + public void TestNotRanked() + { + var statistics = new UserStatistics + { + GlobalRank = null, + Accuracy = 98.99, + MaxCombo = 2_322, + RankedScore = 23_123_543_456, + TotalScore = 123_123_543_456, + PP = null + }; + + createDisplay(); + displayUpdate(statistics, statistics); + } + + private void createDisplay() => AddStep("create display", () => Child = overallRanking = new OverallRanking + { + Width = 400, + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }); + + private void displayUpdate(UserStatistics before, UserStatistics after) => + AddStep("display update", () => overallRanking.StatisticsUpdate.Value = new SoloStatisticsUpdate(new ScoreInfo(), before, after)); + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs index 2a5fc050d3..42068ff117 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -33,7 +35,7 @@ using Realms; namespace osu.Game.Tests.Visual.Ranking { [TestFixture] - public class TestSceneResultsScreen : OsuManualInputManagerTestScene + public partial class TestSceneResultsScreen : OsuManualInputManagerTestScene { [Resolved] private BeatmapManager beatmaps { get; set; } @@ -307,7 +309,7 @@ namespace osu.Game.Tests.Visual.Ranking private UnrankedSoloResultsScreen createUnrankedSoloResultsScreen() => new UnrankedSoloResultsScreen(TestResources.CreateTestScoreInfo()); - private class TestResultsContainer : Container + private partial class TestResultsContainer : Container { [Cached(typeof(Player))] private readonly Player player = new TestPlayer(); @@ -326,7 +328,7 @@ namespace osu.Game.Tests.Visual.Ranking } } - private class TestResultsScreen : ResultsScreen + private partial class TestResultsScreen : ResultsScreen { public HotkeyRetryOverlay RetryOverlay; @@ -360,7 +362,7 @@ namespace osu.Game.Tests.Visual.Ranking } } - private class DelayedFetchResultsScreen : TestResultsScreen + private partial class DelayedFetchResultsScreen : TestResultsScreen { private readonly Task fetchWaitTask; @@ -396,7 +398,7 @@ namespace osu.Game.Tests.Visual.Ranking } } - private class UnrankedSoloResultsScreen : SoloResultsScreen + private partial class UnrankedSoloResultsScreen : SoloResultsScreen { public HotkeyRetryOverlay RetryOverlay; diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneScorePanel.cs b/osu.Game.Tests/Visual/Ranking/TestSceneScorePanel.cs index 5dbeefd390..b87e5adfb1 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneScorePanel.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneScorePanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Rulesets.Scoring; @@ -10,7 +12,7 @@ using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.Ranking { - public class TestSceneScorePanel : OsuTestScene + public partial class TestSceneScorePanel : OsuTestScene { private ScorePanel panel; diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs b/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs index c65587d433..f08a6c9eca 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -17,7 +19,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Ranking { - public class TestSceneScorePanelList : OsuManualInputManagerTestScene + public partial class TestSceneScorePanelList : OsuManualInputManagerTestScene { private ScorePanelList list; diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneSimpleStatisticTable.cs b/osu.Game.Tests/Visual/Ranking/TestSceneSimpleStatisticTable.cs index 07a0bcc8d8..81d146614a 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneSimpleStatisticTable.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneSimpleStatisticTable.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using Humanizer; using NUnit.Framework; @@ -13,7 +15,7 @@ using osu.Game.Screens.Ranking.Statistics; namespace osu.Game.Tests.Visual.Ranking { - public class TestSceneSimpleStatisticTable : OsuTestScene + public partial class TestSceneSimpleStatisticTable : OsuTestScene { private Container container; diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs index 1efe6d7380..fcd5f97fcc 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Threading; @@ -25,7 +27,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Ranking { - public class TestSceneStatisticsPanel : OsuTestScene + public partial class TestSceneStatisticsPanel : OsuTestScene { [Test] public void TestScoreWithTimeStatistics() diff --git a/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs b/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs index 227bce0c60..ce6973aacf 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs @@ -1,18 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Allocation; +#nullable disable + using osu.Framework.Graphics; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Tests.Visual.UserInterface; namespace osu.Game.Tests.Visual.Settings { - public class TestSceneDirectorySelector : OsuTestScene + public partial class TestSceneDirectorySelector : ThemeComparisonTestScene { - [BackgroundDependencyLoader] - private void load() + protected override Drawable CreateContent() => new OsuDirectorySelector { - Add(new OsuDirectorySelector { RelativeSizeAxes = Axes.Both }); - } + RelativeSizeAxes = Axes.Both + }; } } diff --git a/osu.Game.Tests/Visual/Settings/TestSceneFileSelector.cs b/osu.Game.Tests/Visual/Settings/TestSceneFileSelector.cs index 84a0fc6e4c..f61e3ca557 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneFileSelector.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneFileSelector.cs @@ -1,24 +1,46 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Tests.Visual.UserInterface; namespace osu.Game.Tests.Visual.Settings { - public class TestSceneFileSelector : OsuTestScene + public partial class TestSceneFileSelector : ThemeComparisonTestScene { - [Test] - public void TestAllFiles() - { - AddStep("create", () => Child = new OsuFileSelector { RelativeSizeAxes = Axes.Both }); - } + [Resolved] + private OsuColour colours { get; set; } [Test] public void TestJpgFilesOnly() { - AddStep("create", () => Child = new OsuFileSelector(validFileExtensions: new[] { ".jpg" }) { RelativeSizeAxes = Axes.Both }); + AddStep("create", () => + { + Cell(0, 0).Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.GreySeaFoam + }, + new OsuFileSelector(validFileExtensions: new[] { ".jpg" }) + { + RelativeSizeAxes = Axes.Both, + }, + }; + }); } + + protected override Drawable CreateContent() => new OsuFileSelector + { + RelativeSizeAxes = Axes.Both, + }; } } diff --git a/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs index 1effe52608..da48086717 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Diagnostics; using System.Linq; using NUnit.Framework; @@ -15,7 +17,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Settings { [TestFixture] - public class TestSceneKeyBindingPanel : OsuManualInputManagerTestScene + public partial class TestSceneKeyBindingPanel : OsuManualInputManagerTestScene { private readonly KeyBindingPanel panel; @@ -38,6 +40,43 @@ namespace osu.Game.Tests.Visual.Settings AddWaitStep("wait for scroll", 5); } + [Test] + public void TestBindingTwoNonModifiers() + { + AddStep("press j", () => InputManager.PressKey(Key.J)); + scrollToAndStartBinding("Increase volume"); + AddStep("press k", () => InputManager.Key(Key.K)); + AddStep("release j", () => InputManager.ReleaseKey(Key.J)); + checkBinding("Increase volume", "K"); + } + + [Test] + public void TestBindingSingleKey() + { + scrollToAndStartBinding("Increase volume"); + AddStep("press k", () => InputManager.Key(Key.K)); + checkBinding("Increase volume", "K"); + } + + [Test] + public void TestBindingSingleModifier() + { + scrollToAndStartBinding("Increase volume"); + AddStep("press shift", () => InputManager.PressKey(Key.ShiftLeft)); + AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); + checkBinding("Increase volume", "LShift"); + } + + [Test] + public void TestBindingSingleKeyWithModifier() + { + scrollToAndStartBinding("Increase volume"); + AddStep("press shift", () => InputManager.PressKey(Key.ShiftLeft)); + AddStep("press k", () => InputManager.Key(Key.K)); + AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); + checkBinding("Increase volume", "LShift-K"); + } + [Test] public void TestBindingMouseWheelToNonGameplay() { @@ -167,7 +206,8 @@ namespace osu.Game.Tests.Visual.Settings AddUntilStep("restore button hidden", () => settingsKeyBindingRow.ChildrenOfType>().First().Alpha == 0); - AddAssert("binding cleared", () => settingsKeyBindingRow.ChildrenOfType().ElementAt(0).KeyBinding.KeyCombination.Equals(settingsKeyBindingRow.Defaults.ElementAt(0))); + AddAssert("binding cleared", + () => settingsKeyBindingRow.ChildrenOfType().ElementAt(0).KeyBinding.KeyCombination.Equals(settingsKeyBindingRow.Defaults.ElementAt(0))); } [Test] @@ -196,7 +236,8 @@ namespace osu.Game.Tests.Visual.Settings AddUntilStep("restore button hidden", () => settingsKeyBindingRow.ChildrenOfType>().First().Alpha == 0); - AddAssert("binding cleared", () => settingsKeyBindingRow.ChildrenOfType().ElementAt(0).KeyBinding.KeyCombination.Equals(settingsKeyBindingRow.Defaults.ElementAt(0))); + AddAssert("binding cleared", + () => settingsKeyBindingRow.ChildrenOfType().ElementAt(0).KeyBinding.KeyCombination.Equals(settingsKeyBindingRow.Defaults.ElementAt(0))); } [Test] @@ -254,8 +295,8 @@ namespace osu.Game.Tests.Visual.Settings var firstRow = panel.ChildrenOfType().First(r => r.ChildrenOfType().Any(s => s.Text.ToString() == name)); var firstButton = firstRow.ChildrenOfType().First(); - return firstButton.Text.Text == keyName; - }); + return firstButton.Text.Text.ToString(); + }, () => Is.EqualTo(keyName)); } private void scrollToAndStartBinding(string name) diff --git a/osu.Game.Tests/Visual/Settings/TestSceneLatencyCertifierScreen.cs b/osu.Game.Tests/Visual/Settings/TestSceneLatencyCertifierScreen.cs new file mode 100644 index 0000000000..84c153c15b --- /dev/null +++ b/osu.Game.Tests/Visual/Settings/TestSceneLatencyCertifierScreen.cs @@ -0,0 +1,94 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; +using osu.Game.Screens.Utility; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Settings +{ + public partial class TestSceneLatencyCertifierScreen : ScreenTestScene + { + private LatencyCertifierScreen latencyCertifier = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("Load screen", () => LoadScreen(latencyCertifier = new LatencyCertifierScreen())); + AddUntilStep("wait for load", () => latencyCertifier.IsLoaded); + } + + [Test] + public void TestSimple() + { + AddStep("set visual mode to simple", () => latencyCertifier.VisualMode.Value = LatencyVisualMode.Simple); + } + + [Test] + public void TestCircleGameplay() + { + AddStep("set visual mode to circles", () => latencyCertifier.VisualMode.Value = LatencyVisualMode.CircleGameplay); + } + + [Test] + public void TestScrollingGameplay() + { + AddStep("set visual mode to scrolling", () => latencyCertifier.VisualMode.Value = LatencyVisualMode.ScrollingGameplay); + } + + [Test] + public void TestCycleVisualModes() + { + AddRepeatStep("cycle mode", () => InputManager.Key(Key.Space), 6); + } + + [Test] + public void TestCertification() + { + checkDifficulty(1); + clickUntilResults(true); + continueFromResults(); + checkDifficulty(2); + + clickUntilResults(false); + continueFromResults(); + checkDifficulty(1); + + clickUntilResults(true); + AddAssert("check at results", () => !latencyCertifier.ChildrenOfType().Any()); + checkDifficulty(1); + } + + private void continueFromResults() + { + AddAssert("check at results", () => !latencyCertifier.ChildrenOfType().Any()); + AddStep("hit enter to continue", () => InputManager.Key(Key.Enter)); + } + + private void checkDifficulty(int difficulty) + { + AddAssert($"difficulty is {difficulty}", () => latencyCertifier.DifficultyLevel == difficulty); + } + + private void clickUntilResults(bool clickCorrect) + { + AddUntilStep("click correct button until results", () => + { + var latencyArea = latencyCertifier + .ChildrenOfType() + .SingleOrDefault(a => clickCorrect ? a.TargetFrameRate == null : a.TargetFrameRate != null); + + // reached results + if (latencyArea == null) + return true; + + latencyArea.ChildrenOfType().Single().TriggerClick(); + return false; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Settings/TestSceneMigrationScreens.cs b/osu.Game.Tests/Visual/Settings/TestSceneMigrationScreens.cs index ac0956502e..91320fdb1c 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneMigrationScreens.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneMigrationScreens.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.IO; using System.Threading; using NUnit.Framework; @@ -12,7 +14,7 @@ using osu.Game.Overlays.Settings.Sections.Maintenance; namespace osu.Game.Tests.Visual.Settings { - public class TestSceneMigrationScreens : ScreenTestScene + public partial class TestSceneMigrationScreens : ScreenTestScene { [Cached(typeof(INotificationOverlay))] private readonly NotificationOverlay notifications; @@ -41,7 +43,7 @@ namespace osu.Game.Tests.Visual.Settings AddStep("Push screen", () => Stack.Push(new TestMigrationSelectScreen(false))); } - private class TestMigrationSelectScreen : MigrationSelectScreen + private partial class TestMigrationSelectScreen : MigrationSelectScreen { private readonly bool deleteSuccess; @@ -52,7 +54,7 @@ namespace osu.Game.Tests.Visual.Settings protected override void BeginMigration(DirectoryInfo target) => this.Push(new TestMigrationRunScreen(deleteSuccess)); - private class TestMigrationRunScreen : MigrationRunScreen + private partial class TestMigrationRunScreen : MigrationRunScreen { private readonly bool success; diff --git a/osu.Game.Tests/Visual/Settings/TestSceneRestoreDefaultValueButton.cs b/osu.Game.Tests/Visual/Settings/TestSceneRestoreDefaultValueButton.cs index 0c46fa439a..6e52881f5e 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneRestoreDefaultValueButton.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneRestoreDefaultValueButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -13,7 +15,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Settings { - public class TestSceneRestoreDefaultValueButton : OsuTestScene + public partial class TestSceneRestoreDefaultValueButton : OsuTestScene { [Resolved] private OsuColour colours { get; set; } diff --git a/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs index 3e679a7905..384508f375 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Bindables; @@ -15,7 +17,7 @@ using osu.Game.Overlays; namespace osu.Game.Tests.Visual.Settings { [TestFixture] - public class TestSceneSettingsItem : OsuTestScene + public partial class TestSceneSettingsItem : OsuTestScene { [Test] public void TestRestoreDefaultValueButtonVisibility() @@ -27,11 +29,7 @@ namespace osu.Game.Tests.Visual.Settings { Child = textBox = new SettingsTextBox { - Current = new Bindable - { - Default = "test", - Value = "test" - } + Current = new Bindable("test") }; }); AddUntilStep("wait for loaded", () => textBox.IsLoaded); @@ -57,11 +55,7 @@ namespace osu.Game.Tests.Visual.Settings { Child = textBox = new SettingsTextBox { - Current = new Bindable - { - Default = "test", - Value = "test" - } + Current = new Bindable("test") }; }); AddUntilStep("wait for loaded", () => textBox.IsLoaded); diff --git a/osu.Game.Tests/Visual/Settings/TestSceneSettingsNumberBox.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsNumberBox.cs index 334a814688..fc261611db 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneSettingsNumberBox.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneSettingsNumberBox.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Testing; @@ -9,7 +11,7 @@ using osu.Game.Overlays.Settings; namespace osu.Game.Tests.Visual.Settings { - public class TestSceneSettingsNumberBox : OsuTestScene + public partial class TestSceneSettingsNumberBox : OsuTestScene { private SettingsNumberBox numberBox; private OsuTextBox textBox; diff --git a/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.cs index 97463b7466..24c2eee783 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -16,7 +18,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Settings { [TestFixture] - public class TestSceneSettingsPanel : OsuManualInputManagerTestScene + public partial class TestSceneSettingsPanel : OsuManualInputManagerTestScene { private SettingsPanel settings; private DialogOverlay dialogOverlay; @@ -33,13 +35,19 @@ namespace osu.Game.Tests.Visual.Settings State = { Value = Visibility.Visible } }); }); + } - AddStep("reset mouse", () => InputManager.MoveMouseTo(settings)); + [Test] + public void TestBasic() + { + AddStep("do nothing", () => { }); } [Test] public void TestFiltering([Values] bool beforeLoad) { + AddStep("reset mouse", () => InputManager.MoveMouseTo(settings)); + if (beforeLoad) AddStep("set filter", () => settings.SectionsContainer.ChildrenOfType().First().Current.Value = "scaling"); @@ -54,7 +62,6 @@ namespace osu.Game.Tests.Visual.Settings section.Children.Where(f => f.IsPresent) .OfType() .OfType() - .Where(f => !(f is IHasFilterableChildren)) .All(f => f.FilterTerms.Any(t => t.ToString().Contains("scaling"))) )); @@ -65,6 +72,8 @@ namespace osu.Game.Tests.Visual.Settings [Test] public void TestFilterAfterLoad() { + AddStep("reset mouse", () => InputManager.MoveMouseTo(settings)); + AddUntilStep("wait for items to load", () => settings.SectionsContainer.ChildrenOfType().Any()); AddStep("set filter", () => settings.SectionsContainer.ChildrenOfType().First().Current.Value = "scaling"); @@ -73,13 +82,17 @@ namespace osu.Game.Tests.Visual.Settings [Test] public void ToggleVisibility() { + AddStep("reset mouse", () => InputManager.MoveMouseTo(settings)); + AddWaitStep("wait some", 5); - AddToggleStep("toggle visibility", visible => settings.ToggleVisibility()); + AddToggleStep("toggle visibility", _ => settings.ToggleVisibility()); } [Test] public void TestTextboxFocusAfterNestedPanelBackButton() { + AddStep("reset mouse", () => InputManager.MoveMouseTo(settings)); + AddUntilStep("sections loaded", () => settings.SectionsContainer.Children.Count > 0); AddUntilStep("top-level textbox focused", () => settings.SectionsContainer.ChildrenOfType().FirstOrDefault()?.HasFocus == true); @@ -105,6 +118,8 @@ namespace osu.Game.Tests.Visual.Settings [Test] public void TestTextboxFocusAfterNestedPanelEscape() { + AddStep("reset mouse", () => InputManager.MoveMouseTo(settings)); + AddUntilStep("sections loaded", () => settings.SectionsContainer.Children.Count > 0); AddUntilStep("top-level textbox focused", () => settings.SectionsContainer.ChildrenOfType().FirstOrDefault()?.HasFocus == true); diff --git a/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.cs index ffaa038930..30811bab32 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.cs @@ -1,10 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Overlays.Settings; using osuTK; @@ -12,7 +15,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Settings { [TestFixture] - public class TestSceneSettingsSource : OsuTestScene + public partial class TestSceneSettingsSource : OsuTestScene { public TestSceneSettingsSource() { @@ -37,6 +40,9 @@ namespace osu.Game.Tests.Visual.Settings [SettingSource("Sample bool", "Clicking this changes a setting")] public BindableBool TickBindable { get; } = new BindableBool(); + [SettingSource(typeof(TestStrings), nameof(TestStrings.LocalisableLabel), nameof(TestStrings.LocalisableDescription))] + public BindableBool LocalisableBindable { get; } = new BindableBool(true); + [SettingSource("Sample float", "Change something for a mod")] public BindableFloat SliderBindable { get; } = new BindableFloat { @@ -61,11 +67,7 @@ namespace osu.Game.Tests.Visual.Settings }; [SettingSource("Sample number textbox", "Textbox number entry", SettingControlType = typeof(SettingsNumberBox))] - public Bindable IntTextBoxBindable { get; } = new Bindable - { - Default = null, - Value = null - }; + public Bindable IntTextBoxBindable { get; } = new Bindable(); } private enum TestEnum @@ -73,5 +75,11 @@ namespace osu.Game.Tests.Visual.Settings Value1, Value2 } + + private class TestStrings + { + public static LocalisableString LocalisableLabel => new LocalisableString("Sample localisable label"); + public static LocalisableString LocalisableDescription => new LocalisableString("Sample localisable description"); + } } } diff --git a/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs b/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs index dc5b0e0d77..5ca08e0bba 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -9,6 +11,7 @@ using osu.Framework.Graphics; using osu.Framework.Input.Handlers.Tablet; using osu.Framework.Testing; using osu.Framework.Utils; +using osu.Game.Graphics.Containers; using osu.Game.Overlays; using osu.Game.Overlays.Settings; using osu.Game.Overlays.Settings.Sections.Input; @@ -17,7 +20,7 @@ using osuTK; namespace osu.Game.Tests.Visual.Settings { [TestFixture] - public class TestSceneTabletSettings : OsuTestScene + public partial class TestSceneTabletSettings : OsuTestScene { private TestTabletHandler tabletHandler; private TabletSettings settings; @@ -34,12 +37,16 @@ namespace osu.Game.Tests.Visual.Settings Children = new Drawable[] { - settings = new TabletSettings(tabletHandler) + new OsuScrollContainer(Direction.Vertical) { - RelativeSizeAxes = Axes.None, - Width = SettingsPanel.PANEL_WIDTH, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Both, + Child = settings = new TabletSettings(tabletHandler) + { + RelativeSizeAxes = Axes.None, + Width = SettingsPanel.PANEL_WIDTH, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + } } }; }); diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs index 8af70df48a..8650119dd4 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -20,7 +22,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.SongSelect { [System.ComponentModel.Description("Advanced beatmap statistics display")] - public class TestSceneAdvancedStats : OsuTestScene + public partial class TestSceneAdvancedStats : OsuTestScene { private TestAdvancedStats advancedStats; @@ -68,7 +70,7 @@ namespace osu.Game.Tests.Visual.SongSelect { AddStep("set beatmap", () => advancedStats.BeatmapInfo = new BeatmapInfo { - Ruleset = rulesets.GetRuleset(3) ?? throw new InvalidOperationException(), + Ruleset = rulesets.GetRuleset(3) ?? throw new InvalidOperationException("osu!mania ruleset not found"), Difficulty = new BeatmapDifficulty { CircleSize = 5, @@ -124,7 +126,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("select unchanged Difficulty Adjust mod", () => { var ruleset = advancedStats.BeatmapInfo.Ruleset.CreateInstance().AsNonNull(); - var difficultyAdjustMod = ruleset.CreateMod(); + var difficultyAdjustMod = ruleset.CreateMod().AsNonNull(); difficultyAdjustMod.ReadFromDifficulty(advancedStats.BeatmapInfo.Difficulty); SelectedMods.Value = new[] { difficultyAdjustMod }; }); @@ -143,7 +145,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("select changed Difficulty Adjust mod", () => { var ruleset = advancedStats.BeatmapInfo.Ruleset.CreateInstance().AsNonNull(); - var difficultyAdjustMod = ruleset.CreateMod(); + var difficultyAdjustMod = ruleset.CreateMod().AsNonNull(); var originalDifficulty = advancedStats.BeatmapInfo.Difficulty; difficultyAdjustMod.ReadFromDifficulty(originalDifficulty); @@ -162,7 +164,7 @@ namespace osu.Game.Tests.Visual.SongSelect private bool barIsBlue(AdvancedStats.StatisticRow row) => row.ModBar.AccentColour == colours.BlueDark; private bool barIsRed(AdvancedStats.StatisticRow row) => row.ModBar.AccentColour == colours.Red; - private class TestAdvancedStats : AdvancedStats + private partial class TestAdvancedStats : AdvancedStats { public new StatisticRow FirstValue => base.FirstValue; public new StatisticRow HpDrain => base.HpDrain; diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs index 540b820250..61a8322ee3 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -9,11 +11,14 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Taiko; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Carousel; using osu.Game.Screens.Select.Filter; @@ -23,7 +28,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.SongSelect { [TestFixture] - public class TestSceneBeatmapCarousel : OsuManualInputManagerTestScene + public partial class TestSceneBeatmapCarousel : OsuManualInputManagerTestScene { private TestBeatmapCarousel carousel; private RulesetStore rulesets; @@ -69,7 +74,7 @@ namespace osu.Game.Tests.Visual.SongSelect var visibleBeatmapPanels = carousel.Items.OfType().Where(p => p.IsPresent).ToArray(); return visibleBeatmapPanels.Length == 1 - && visibleBeatmapPanels.Count(p => ((CarouselBeatmap)p.Item).BeatmapInfo.Ruleset.OnlineID == 0) == 1; + && visibleBeatmapPanels.Count(p => ((CarouselBeatmap)p.Item)!.BeatmapInfo.Ruleset.OnlineID == 0) == 1; }); AddStep("filter to ruleset 1", () => carousel.Filter(new FilterCriteria @@ -83,8 +88,8 @@ namespace osu.Game.Tests.Visual.SongSelect var visibleBeatmapPanels = carousel.Items.OfType().Where(p => p.IsPresent).ToArray(); return visibleBeatmapPanels.Length == 2 - && visibleBeatmapPanels.Count(p => ((CarouselBeatmap)p.Item).BeatmapInfo.Ruleset.OnlineID == 0) == 1 - && visibleBeatmapPanels.Count(p => ((CarouselBeatmap)p.Item).BeatmapInfo.Ruleset.OnlineID == 1) == 1; + && visibleBeatmapPanels.Count(p => ((CarouselBeatmap)p.Item)!.BeatmapInfo.Ruleset.OnlineID == 0) == 1 + && visibleBeatmapPanels.Count(p => ((CarouselBeatmap)p.Item)!.BeatmapInfo.Ruleset.OnlineID == 1) == 1; }); AddStep("filter to ruleset 2", () => carousel.Filter(new FilterCriteria @@ -98,8 +103,8 @@ namespace osu.Game.Tests.Visual.SongSelect var visibleBeatmapPanels = carousel.Items.OfType().Where(p => p.IsPresent).ToArray(); return visibleBeatmapPanels.Length == 2 - && visibleBeatmapPanels.Count(p => ((CarouselBeatmap)p.Item).BeatmapInfo.Ruleset.OnlineID == 0) == 1 - && visibleBeatmapPanels.Count(p => ((CarouselBeatmap)p.Item).BeatmapInfo.Ruleset.OnlineID == 2) == 1; + && visibleBeatmapPanels.Count(p => ((CarouselBeatmap)p.Item!).BeatmapInfo.Ruleset.OnlineID == 0) == 1 + && visibleBeatmapPanels.Count(p => ((CarouselBeatmap)p.Item!).BeatmapInfo.Ruleset.OnlineID == 2) == 1; }); } @@ -116,6 +121,15 @@ namespace osu.Game.Tests.Visual.SongSelect } } + [Test] + public void TestDeletion() + { + loadBeatmaps(count: 5, randomDifficulties: true); + + AddStep("remove first set", () => carousel.RemoveBeatmapSet(carousel.Items.Select(item => item.Item).OfType().First().BeatmapSet)); + AddUntilStep("4 beatmap sets visible", () => this.ChildrenOfType().Count(set => set.Alpha > 0) == 4); + } + [Test] public void TestScrollPositionMaintainedOnDelete() { @@ -171,7 +185,7 @@ namespace osu.Game.Tests.Visual.SongSelect if (isIterating) AddUntilStep("selection changed", () => !carousel.SelectedBeatmapInfo?.Equals(selection) == true); else - AddUntilStep("selection not changed", () => carousel.SelectedBeatmapInfo.Equals(selection)); + AddUntilStep("selection not changed", () => carousel.SelectedBeatmapInfo?.Equals(selection) == true); } } } @@ -242,8 +256,12 @@ namespace osu.Game.Tests.Visual.SongSelect const int total_set_count = 200; - for (int i = 0; i < total_set_count; i++) - sets.Add(TestResources.CreateTestBeatmapSetInfo()); + AddStep("Populuate beatmap sets", () => + { + sets.Clear(); + for (int i = 0; i < total_set_count; i++) + sets.Add(TestResources.CreateTestBeatmapSetInfo()); + }); loadBeatmaps(sets); @@ -273,8 +291,12 @@ namespace osu.Game.Tests.Visual.SongSelect const int total_set_count = 20; - for (int i = 0; i < total_set_count; i++) - sets.Add(TestResources.CreateTestBeatmapSetInfo(3)); + AddStep("Populuate beatmap sets", () => + { + sets.Clear(); + for (int i = 0; i < total_set_count; i++) + sets.Add(TestResources.CreateTestBeatmapSetInfo(3)); + }); loadBeatmaps(sets); @@ -372,7 +394,7 @@ namespace osu.Game.Tests.Visual.SongSelect // buffer the selection setSelected(3, 2); - AddStep("get search text", () => searchText = carousel.SelectedBeatmapSet.Metadata.Title); + AddStep("get search text", () => searchText = carousel.SelectedBeatmapSet!.Metadata.Title); setSelected(1, 3); @@ -484,9 +506,59 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("Something is selected", () => carousel.SelectedBeatmapInfo != null); } - /// - /// Test sorting - /// + [Test] + public void TestSortingDateSubmitted() + { + var sets = new List(); + const string zzz_string = "zzzzz"; + + AddStep("Populuate beatmap sets", () => + { + sets.Clear(); + + for (int i = 0; i < 10; i++) + { + var set = TestResources.CreateTestBeatmapSetInfo(5); + + // A total of 6 sets have date submitted (4 don't) + // A total of 5 sets have artist string (3 of which also have date submitted) + + if (i >= 2 && i < 8) // i = 2, 3, 4, 5, 6, 7 have submitted date + set.DateSubmitted = DateTimeOffset.Now.AddMinutes(i); + if (i < 5) // i = 0, 1, 2, 3, 4 have matching string + set.Beatmaps.ForEach(b => b.Metadata.Artist = zzz_string); + + set.Beatmaps.ForEach(b => b.Metadata.Title = $"submitted: {set.DateSubmitted}"); + + sets.Add(set); + } + }); + + loadBeatmaps(sets); + + AddStep("Sort by date submitted", () => carousel.Filter(new FilterCriteria { Sort = SortMode.DateSubmitted }, false)); + checkVisibleItemCount(diff: false, count: 10); + checkVisibleItemCount(diff: true, count: 5); + + AddAssert("missing date are at end", + () => carousel.Items.OfType().Reverse().TakeWhile(i => i.Item is CarouselBeatmapSet s && s.BeatmapSet.DateSubmitted == null).Count(), () => Is.EqualTo(4)); + AddAssert("rest are at start", () => carousel.Items.OfType().TakeWhile(i => i.Item is CarouselBeatmapSet s && s.BeatmapSet.DateSubmitted != null).Count(), + () => Is.EqualTo(6)); + + AddStep("Sort by date submitted and string", () => carousel.Filter(new FilterCriteria + { + Sort = SortMode.DateSubmitted, + SearchText = zzz_string + }, false)); + checkVisibleItemCount(diff: false, count: 5); + checkVisibleItemCount(diff: true, count: 5); + + AddAssert("missing date are at end", + () => carousel.Items.OfType().Reverse().TakeWhile(i => i.Item is CarouselBeatmapSet s && s.BeatmapSet.DateSubmitted == null).Count(), () => Is.EqualTo(2)); + AddAssert("rest are at start", () => carousel.Items.OfType().TakeWhile(i => i.Item is CarouselBeatmapSet s && s.BeatmapSet.DateSubmitted != null).Count(), + () => Is.EqualTo(3)); + } + [Test] public void TestSorting() { @@ -494,18 +566,23 @@ namespace osu.Game.Tests.Visual.SongSelect const string zzz_string = "zzzzz"; - for (int i = 0; i < 20; i++) + AddStep("Populuate beatmap sets", () => { - var set = TestResources.CreateTestBeatmapSetInfo(); + sets.Clear(); - if (i == 4) - set.Beatmaps.ForEach(b => b.Metadata.Artist = zzz_string); + for (int i = 0; i < 20; i++) + { + var set = TestResources.CreateTestBeatmapSetInfo(); - if (i == 16) - set.Beatmaps.ForEach(b => b.Metadata.Author.Username = zzz_string); + if (i == 4) + set.Beatmaps.ForEach(b => b.Metadata.Artist = zzz_string); - sets.Add(set); - } + if (i == 16) + set.Beatmaps.ForEach(b => b.Metadata.Author.Username = zzz_string); + + sets.Add(set); + } + }); loadBeatmaps(sets); @@ -515,12 +592,129 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert($"Check {zzz_string} is at bottom", () => carousel.BeatmapSets.Last().Metadata.Artist == zzz_string); } + /// + /// Ensures stability is maintained on different sort modes for items with equal properties. + /// [Test] - public void TestSortingStability() + public void TestSortingStabilityDateAdded() { var sets = new List(); - for (int i = 0; i < 20; i++) + AddStep("Populuate beatmap sets", () => + { + sets.Clear(); + + for (int i = 0; i < 10; i++) + { + var set = TestResources.CreateTestBeatmapSetInfo(); + + set.DateAdded = DateTimeOffset.FromUnixTimeSeconds(i); + + // only need to set the first as they are a shared reference. + var beatmap = set.Beatmaps.First(); + + beatmap.Metadata.Artist = "a"; + beatmap.Metadata.Title = "b"; + + sets.Add(set); + } + }); + + loadBeatmaps(sets); + + AddStep("Sort by title", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Title }, false)); + AddAssert("Items remain in descending added order", () => carousel.BeatmapSets.Select(s => s.DateAdded), () => Is.Ordered.Descending); + + AddStep("Sort by artist", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }, false)); + AddAssert("Items remain in descending added order", () => carousel.BeatmapSets.Select(s => s.DateAdded), () => Is.Ordered.Descending); + } + + /// + /// Ensures stability is maintained on different sort modes while a new item is added to the carousel. + /// + [Test] + public void TestSortingStabilityWithRemovedAndReaddedItem() + { + List sets = new List(); + + AddStep("Populuate beatmap sets", () => + { + sets.Clear(); + + for (int i = 0; i < 3; i++) + { + var set = TestResources.CreateTestBeatmapSetInfo(3); + + // only need to set the first as they are a shared reference. + var beatmap = set.Beatmaps.First(); + + beatmap.Metadata.Artist = "same artist"; + beatmap.Metadata.Title = "same title"; + + // testing the case where DateAdded happens to equal (quite rare). + set.DateAdded = DateTimeOffset.UnixEpoch; + + sets.Add(set); + } + }); + + Guid[] originalOrder = null!; + + loadBeatmaps(sets); + + AddStep("Sort by artist", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }, false)); + + AddAssert("Items in descending added order", () => carousel.BeatmapSets.Select(s => s.DateAdded), () => Is.Ordered.Descending); + AddStep("Save order", () => originalOrder = carousel.BeatmapSets.Select(s => s.ID).ToArray()); + + AddStep("Remove item", () => carousel.RemoveBeatmapSet(sets[1])); + AddStep("Re-add item", () => carousel.UpdateBeatmapSet(sets[1])); + + AddAssert("Order didn't change", () => carousel.BeatmapSets.Select(s => s.ID), () => Is.EqualTo(originalOrder)); + + AddStep("Sort by title", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Title }, false)); + AddAssert("Order didn't change", () => carousel.BeatmapSets.Select(s => s.ID), () => Is.EqualTo(originalOrder)); + } + + /// + /// Ensures stability is maintained on different sort modes while a new item is added to the carousel. + /// + [Test] + public void TestSortingStabilityWithNewItems() + { + List sets = new List(); + + AddStep("Populuate beatmap sets", () => + { + sets.Clear(); + + for (int i = 0; i < 3; i++) + { + var set = TestResources.CreateTestBeatmapSetInfo(3); + + // only need to set the first as they are a shared reference. + var beatmap = set.Beatmaps.First(); + + beatmap.Metadata.Artist = "same artist"; + beatmap.Metadata.Title = "same title"; + + // testing the case where DateAdded happens to equal (quite rare). + set.DateAdded = DateTimeOffset.UnixEpoch; + + sets.Add(set); + } + }); + + Guid[] originalOrder = null!; + + loadBeatmaps(sets); + + AddStep("Sort by artist", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }, false)); + + AddAssert("Items in descending added order", () => carousel.BeatmapSets.Select(s => s.DateAdded), () => Is.Ordered.Descending); + AddStep("Save order", () => originalOrder = carousel.BeatmapSets.Select(s => s.ID).ToArray()); + + AddStep("Add new item", () => { var set = TestResources.CreateTestBeatmapSetInfo(); @@ -530,18 +724,18 @@ namespace osu.Game.Tests.Visual.SongSelect beatmap.Metadata.Artist = "same artist"; beatmap.Metadata.Title = "same title"; - sets.Add(set); - } + set.DateAdded = DateTimeOffset.FromUnixTimeSeconds(1); - int idOffset = sets.First().OnlineID; + carousel.UpdateBeatmapSet(set); - loadBeatmaps(sets); + // add set to expected ordering + originalOrder = originalOrder.Prepend(set.ID).ToArray(); + }); - AddStep("Sort by artist", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }, false)); - AddAssert("Items remain in original order", () => carousel.BeatmapSets.Select((set, index) => set.OnlineID == index + idOffset).All(b => b)); + AddAssert("Order didn't change", () => carousel.BeatmapSets.Select(s => s.ID), () => Is.EqualTo(originalOrder)); AddStep("Sort by title", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Title }, false)); - AddAssert("Items remain in original order", () => carousel.BeatmapSets.Select((set, index) => set.OnlineID == index + idOffset).All(b => b)); + AddAssert("Order didn't change", () => carousel.BeatmapSets.Select(s => s.ID), () => Is.EqualTo(originalOrder)); } [Test] @@ -549,13 +743,18 @@ namespace osu.Game.Tests.Visual.SongSelect { List sets = new List(); - for (int i = 0; i < 3; i++) + AddStep("Populuate beatmap sets", () => { - var set = TestResources.CreateTestBeatmapSetInfo(3); - set.Beatmaps[0].StarRating = 3 - i; - set.Beatmaps[2].StarRating = 6 + i; - sets.Add(set); - } + sets.Clear(); + + for (int i = 0; i < 3; i++) + { + var set = TestResources.CreateTestBeatmapSetInfo(3); + set.Beatmaps[0].StarRating = 3 - i; + set.Beatmaps[2].StarRating = 6 + i; + sets.Add(set); + } + }); loadBeatmaps(sets); @@ -576,7 +775,7 @@ namespace osu.Game.Tests.Visual.SongSelect setSelected(2, 1); AddAssert("Selection is non-null", () => currentSelection != null); - AddStep("Remove selected", () => carousel.RemoveBeatmapSet(carousel.SelectedBeatmapSet)); + AddStep("Remove selected", () => carousel.RemoveBeatmapSet(carousel.SelectedBeatmapSet!)); waitForSelection(2); AddStep("Remove first", () => carousel.RemoveBeatmapSet(carousel.BeatmapSets.First())); @@ -679,7 +878,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("filter to ruleset 0", () => carousel.Filter(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(0) }, false)); AddStep("select filtered map skipping filtered", () => carousel.SelectBeatmap(testMixed.Beatmaps[1], false)); - AddAssert("unfiltered beatmap not selected", () => carousel.SelectedBeatmapInfo.Ruleset.OnlineID == 0); + AddAssert("unfiltered beatmap not selected", () => carousel.SelectedBeatmapInfo?.Ruleset.OnlineID == 0); AddStep("remove mixed set", () => { @@ -707,8 +906,13 @@ namespace osu.Game.Tests.Visual.SongSelect { List manySets = new List(); - for (int i = 1; i <= 50; i++) - manySets.Add(TestResources.CreateTestBeatmapSetInfo(3)); + AddStep("Populuate beatmap sets", () => + { + manySets.Clear(); + + for (int i = 1; i <= 50; i++) + manySets.Add(TestResources.CreateTestBeatmapSetInfo(3)); + }); loadBeatmaps(manySets); @@ -724,7 +928,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("Restore no filter", () => { carousel.Filter(new FilterCriteria(), false); - eagerSelectedIDs.Add(carousel.SelectedBeatmapSet.ID); + eagerSelectedIDs.Add(carousel.SelectedBeatmapSet!.ID); }); } @@ -732,50 +936,6 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("Selection was remembered", () => eagerSelectedIDs.Count == 1); } - [Test] - public void TestRandomFallbackOnNonMatchingPrevious() - { - List manySets = new List(); - - AddStep("populate maps", () => - { - for (int i = 0; i < 10; i++) - { - manySets.Add(TestResources.CreateTestBeatmapSetInfo(3, new[] - { - // all taiko except for first - rulesets.GetRuleset(i > 0 ? 1 : 0) - })); - } - }); - - loadBeatmaps(manySets); - - for (int i = 0; i < 10; i++) - { - AddStep("Reset filter", () => carousel.Filter(new FilterCriteria(), false)); - - AddStep("select first beatmap", () => carousel.SelectBeatmap(manySets.First().Beatmaps.First())); - - AddStep("Toggle non-matching filter", () => - { - carousel.Filter(new FilterCriteria { SearchText = Guid.NewGuid().ToString() }, false); - }); - - AddAssert("selection lost", () => carousel.SelectedBeatmapInfo == null); - - AddStep("Restore different ruleset filter", () => - { - carousel.Filter(new FilterCriteria { Ruleset = rulesets.GetRuleset(1) }, false); - eagerSelectedIDs.Add(carousel.SelectedBeatmapSet.ID); - }); - - AddAssert("selection changed", () => !carousel.SelectedBeatmapInfo.Equals(manySets.First().Beatmaps.First())); - } - - AddAssert("Selection was random", () => eagerSelectedIDs.Count > 2); - } - [Test] public void TestFilteringByUserStarDifficulty() { @@ -823,7 +983,87 @@ namespace osu.Game.Tests.Visual.SongSelect checkVisibleItemCount(true, 15); } - private void loadBeatmaps(List beatmapSets = null, Func initialCriteria = null, Action carouselAdjust = null, int? count = null, bool randomDifficulties = false) + [Test] + public void TestCarouselSelectsNextWhenPreviousIsFiltered() + { + List sets = new List(); + + // 10 sets that go osu! -> taiko -> catch -> osu! -> ... + for (int i = 0; i < 10; i++) + sets.Add(TestResources.CreateTestBeatmapSetInfo(5, new[] { getRuleset(i) })); + + // Sort mode is important to keep the ruleset order + loadBeatmaps(sets, () => new FilterCriteria { Sort = SortMode.Title }); + setSelected(1, 1); + + for (int i = 1; i < 10; i++) + { + var rulesetInfo = getRuleset(i % 3); + + AddStep($"Set ruleset to {rulesetInfo.ShortName}", () => + { + carousel.Filter(new FilterCriteria { Ruleset = rulesetInfo, Sort = SortMode.Title }, false); + }); + waitForSelection(i + 1, 1); + } + + static RulesetInfo getRuleset(int index) + { + switch (index % 3) + { + default: + return new OsuRuleset().RulesetInfo; + + case 1: + return new TaikoRuleset().RulesetInfo; + + case 2: + return new CatchRuleset().RulesetInfo; + } + } + } + + [Test] + public void TestCarouselSelectsBackwardsWhenDistanceIsShorter() + { + List sets = new List(); + + // 10 sets that go taiko, osu!, osu!, osu!, taiko, osu!, osu!, osu!, ... + for (int i = 0; i < 10; i++) + sets.Add(TestResources.CreateTestBeatmapSetInfo(5, new[] { getRuleset(i) })); + + // Sort mode is important to keep the ruleset order + loadBeatmaps(sets, () => new FilterCriteria { Sort = SortMode.Title }); + + for (int i = 2; i < 10; i += 4) + { + setSelected(i, 1); + AddStep("Set ruleset to taiko", () => + { + carousel.Filter(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(1), Sort = SortMode.Title }, false); + }); + waitForSelection(i - 1, 1); + AddStep("Remove ruleset filter", () => + { + carousel.Filter(new FilterCriteria { Sort = SortMode.Title }, false); + }); + } + + static RulesetInfo getRuleset(int index) + { + switch (index % 4) + { + case 0: + return new TaikoRuleset().RulesetInfo; + + default: + return new OsuRuleset().RulesetInfo; + } + } + } + + private void loadBeatmaps(List beatmapSets = null, Func initialCriteria = null, Action carouselAdjust = null, int? count = null, + bool randomDifficulties = false) { bool changed = false; @@ -905,7 +1145,7 @@ namespace osu.Game.Tests.Visual.SongSelect { // until step required as we are querying against alive items, which are loaded asynchronously inside DrawableCarouselBeatmapSet. AddUntilStep($"{count} {(diff ? "diffs" : "sets")} visible", () => - carousel.Items.Count(s => (diff ? s.Item is CarouselBeatmap : s.Item is CarouselBeatmapSet) && s.Item.Visible) == count); + carousel.Items.Count(s => (diff ? s.Item is CarouselBeatmap : s.Item is CarouselBeatmapSet) && s.Item.Visible), () => Is.EqualTo(count)); } private void checkSelectionIsCentered() @@ -915,7 +1155,7 @@ namespace osu.Game.Tests.Visual.SongSelect return Precision.AlmostEquals( carousel.ScreenSpaceDrawQuad.Centre, carousel.Items - .First(i => i.Item.State.Value == CarouselItemState.Selected) + .First(i => i.Item?.State.Value == CarouselItemState.Selected) .ScreenSpaceDrawQuad.Centre, 100); }); } @@ -949,7 +1189,7 @@ namespace osu.Game.Tests.Visual.SongSelect if (currentlySelected == null) return true; - return currentlySelected.Item.Visible; + return currentlySelected.Item!.Visible; } private void checkInvisibleDifficultiesUnselectable() @@ -958,7 +1198,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("Selection is visible", selectedBeatmapVisible); } - private class TestBeatmapCarousel : BeatmapCarousel + private partial class TestBeatmapCarousel : BeatmapCarousel { public bool PendingFilterTask => PendingFilter != null; @@ -966,8 +1206,11 @@ namespace osu.Game.Tests.Visual.SongSelect { get { - foreach (var item in Scroll.Children) + foreach (var item in Scroll.Children.OrderBy(c => c.Y)) { + if (item.Item?.Visible != true) + continue; + yield return item; if (item is DrawableCarouselBeatmapSet set) diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs index bd15c40271..20cc1e544e 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osu.Game.Screens.Select; namespace osu.Game.Tests.Visual.SongSelect { [System.ComponentModel.Description("PlaySongSelect beatmap details")] - public class TestSceneBeatmapDetails : OsuTestScene + public partial class TestSceneBeatmapDetails : OsuTestScene { private BeatmapDetails details; diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs index ef04baefa2..a470ed47d4 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; @@ -28,7 +30,7 @@ using osuTK; namespace osu.Game.Tests.Visual.SongSelect { [TestFixture] - public class TestSceneBeatmapInfoWedge : OsuTestScene + public partial class TestSceneBeatmapInfoWedge : OsuTestScene { private RulesetStore rulesets; private TestBeatmapInfoWedge infoWedge; @@ -51,13 +53,8 @@ namespace osu.Game.Tests.Visual.SongSelect Margin = new MarginPadding { Top = 20 } }); - AddStep("show", () => - { - infoWedge.Show(); - infoWedge.Beatmap = Beatmap.Value; - }); + AddStep("show", () => infoWedge.Show()); - // select part is redundant, but wait for load isn't selectBeatmap(Beatmap.Value.Beatmap); AddWaitStep("wait for select", 3); @@ -89,19 +86,19 @@ namespace osu.Game.Tests.Visual.SongSelect switch (instance) { - case OsuRuleset _: + case OsuRuleset: testInfoLabels(5); break; - case TaikoRuleset _: + case TaikoRuleset: testInfoLabels(5); break; - case CatchRuleset _: + case CatchRuleset: testInfoLabels(5); break; - case ManiaRuleset _: + case ManiaRuleset: testInfoLabels(4); break; @@ -270,7 +267,7 @@ namespace osu.Game.Tests.Visual.SongSelect }; } - private class TestBeatmapInfoWedge : BeatmapInfoWedge + private partial class TestBeatmapInfoWedge : BeatmapInfoWedge { public new Container DisplayedContent => base.DisplayedContent; diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs index 3b15ee9c45..c234cc8a9c 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs @@ -27,25 +27,24 @@ using osuTK; namespace osu.Game.Tests.Visual.SongSelect { - public class TestSceneBeatmapLeaderboard : OsuTestScene + public partial class TestSceneBeatmapLeaderboard : OsuTestScene { private readonly FailableLeaderboard leaderboard; [Cached(typeof(IDialogOverlay))] private readonly DialogOverlay dialogOverlay; - private ScoreManager scoreManager; - - private RulesetStore rulesetStore; - private BeatmapManager beatmapManager; + private ScoreManager scoreManager = null!; + private RulesetStore rulesetStore = null!; + private BeatmapManager beatmapManager = null!; protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); dependencies.Cache(rulesetStore = new RealmRulesetStore(Realm)); - dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, rulesetStore, null, dependencies.Get(), Resources, dependencies.Get(), Beatmap.Default)); - dependencies.Cache(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, Realm, Scheduler)); + dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, dependencies.Get(), Resources, dependencies.Get(), Beatmap.Default)); + dependencies.Cache(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, Realm, API)); Dependencies.Cache(Realm); return dependencies; @@ -72,7 +71,7 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestLocalScoresDisplay() { - BeatmapInfo beatmapInfo = null; + BeatmapInfo beatmapInfo = null!; AddStep(@"Set scope", () => leaderboard.Scope = BeatmapLeaderboardScope.Local); @@ -85,16 +84,80 @@ namespace osu.Game.Tests.Visual.SongSelect }); clearScores(); - checkCount(0); + checkDisplayedCount(0); - loadMoreScores(() => beatmapInfo); - checkCount(10); + importMoreScores(() => beatmapInfo); + checkDisplayedCount(10); - loadMoreScores(() => beatmapInfo); - checkCount(20); + importMoreScores(() => beatmapInfo); + checkDisplayedCount(20); clearScores(); - checkCount(0); + checkDisplayedCount(0); + } + + [Test] + public void TestLocalScoresDisplayOnBeatmapEdit() + { + BeatmapInfo beatmapInfo = null!; + string originalHash = string.Empty; + + AddStep(@"Set scope", () => leaderboard.Scope = BeatmapLeaderboardScope.Local); + + AddStep(@"Import beatmap", () => + { + beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); + beatmapInfo = beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First(); + + leaderboard.BeatmapInfo = beatmapInfo; + }); + + clearScores(); + checkDisplayedCount(0); + + AddStep(@"Perform initial save to guarantee stable hash", () => + { + IBeatmap beatmap = beatmapManager.GetWorkingBeatmap(beatmapInfo).Beatmap; + beatmapManager.Save(beatmapInfo, beatmap); + + originalHash = beatmapInfo.Hash; + }); + + importMoreScores(() => beatmapInfo); + + checkDisplayedCount(10); + checkStoredCount(10); + + AddStep(@"Save with changes", () => + { + IBeatmap beatmap = beatmapManager.GetWorkingBeatmap(beatmapInfo).Beatmap; + beatmap.Difficulty.ApproachRate = 12; + beatmapManager.Save(beatmapInfo, beatmap); + }); + + AddAssert("Hash changed", () => beatmapInfo.Hash, () => Is.Not.EqualTo(originalHash)); + checkDisplayedCount(0); + checkStoredCount(10); + + importMoreScores(() => beatmapInfo); + importMoreScores(() => beatmapInfo); + checkDisplayedCount(20); + checkStoredCount(30); + + AddStep(@"Revert changes", () => + { + IBeatmap beatmap = beatmapManager.GetWorkingBeatmap(beatmapInfo).Beatmap; + beatmap.Difficulty.ApproachRate = 8; + beatmapManager.Save(beatmapInfo, beatmap); + }); + + AddAssert("Hash restored", () => beatmapInfo.Hash, () => Is.EqualTo(originalHash)); + checkDisplayedCount(10); + checkStoredCount(30); + + clearScores(); + checkDisplayedCount(0); + checkStoredCount(0); } [Test] @@ -138,11 +201,7 @@ namespace osu.Game.Tests.Visual.SongSelect { Id = 6602580, Username = @"waaiiru", - Country = new Country - { - FullName = @"Spain", - FlagName = @"ES", - }, + CountryCode = CountryCode.ES, }, }); } @@ -162,18 +221,14 @@ namespace osu.Game.Tests.Visual.SongSelect { Id = 6602580, Username = @"waaiiru", - Country = new Country - { - FullName = @"Spain", - FlagName = @"ES", - }, - }, + CountryCode = CountryCode.ES, + } }); } - private void loadMoreScores(Func beatmapInfo) + private void importMoreScores(Func beatmapInfo) { - AddStep(@"Load new scores via manager", () => + AddStep(@"Import new scores", () => { foreach (var score in generateSampleScores(beatmapInfo())) scoreManager.Import(score); @@ -185,8 +240,11 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("Clear all scores", () => scoreManager.Delete()); } - private void checkCount(int expected) => - AddUntilStep("Correct count displayed", () => leaderboard.ChildrenOfType().Count() == expected); + private void checkDisplayedCount(int expected) => + AddUntilStep($"{expected} scores displayed", () => leaderboard.ChildrenOfType().Count(), () => Is.EqualTo(expected)); + + private void checkStoredCount(int expected) => + AddUntilStep($"Total scores stored is {expected}", () => Realm.Run(r => r.All().Count(s => !s.DeletePending)), () => Is.EqualTo(expected)); private static ScoreInfo[] generateSampleScores(BeatmapInfo beatmapInfo) { @@ -219,15 +277,12 @@ namespace osu.Game.Tests.Visual.SongSelect }, Ruleset = new OsuRuleset().RulesetInfo, BeatmapInfo = beatmapInfo, + BeatmapHash = beatmapInfo.Hash, User = new APIUser { Id = 6602580, Username = @"waaiiru", - Country = new Country - { - FullName = @"Spain", - FlagName = @"ES", - }, + CountryCode = CountryCode.ES, }, }, new ScoreInfo @@ -239,16 +294,13 @@ namespace osu.Game.Tests.Visual.SongSelect Date = DateTime.Now.AddSeconds(-30), Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, BeatmapInfo = beatmapInfo, + BeatmapHash = beatmapInfo.Hash, Ruleset = new OsuRuleset().RulesetInfo, User = new APIUser { Id = 4608074, Username = @"Skycries", - Country = new Country - { - FullName = @"Brazil", - FlagName = @"BR", - }, + CountryCode = CountryCode.BR, }, }, new ScoreInfo @@ -260,17 +312,14 @@ namespace osu.Game.Tests.Visual.SongSelect Date = DateTime.Now.AddSeconds(-70), Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, BeatmapInfo = beatmapInfo, + BeatmapHash = beatmapInfo.Hash, Ruleset = new OsuRuleset().RulesetInfo, User = new APIUser { Id = 1014222, Username = @"eLy", - Country = new Country - { - FullName = @"Japan", - FlagName = @"JP", - }, + CountryCode = CountryCode.JP, }, }, new ScoreInfo @@ -282,17 +331,14 @@ namespace osu.Game.Tests.Visual.SongSelect Date = DateTime.Now.AddMinutes(-40), Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, BeatmapInfo = beatmapInfo, + BeatmapHash = beatmapInfo.Hash, Ruleset = new OsuRuleset().RulesetInfo, User = new APIUser { Id = 1541390, Username = @"Toukai", - Country = new Country - { - FullName = @"Canada", - FlagName = @"CA", - }, + CountryCode = CountryCode.CA, }, }, new ScoreInfo @@ -304,17 +350,14 @@ namespace osu.Game.Tests.Visual.SongSelect Date = DateTime.Now.AddHours(-2), Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, BeatmapInfo = beatmapInfo, + BeatmapHash = beatmapInfo.Hash, Ruleset = new OsuRuleset().RulesetInfo, User = new APIUser { Id = 2243452, Username = @"Satoruu", - Country = new Country - { - FullName = @"Venezuela", - FlagName = @"VE", - }, + CountryCode = CountryCode.VE, }, }, new ScoreInfo @@ -326,17 +369,14 @@ namespace osu.Game.Tests.Visual.SongSelect Date = DateTime.Now.AddHours(-25), Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, BeatmapInfo = beatmapInfo, + BeatmapHash = beatmapInfo.Hash, Ruleset = new OsuRuleset().RulesetInfo, User = new APIUser { Id = 2705430, Username = @"Mooha", - Country = new Country - { - FullName = @"France", - FlagName = @"FR", - }, + CountryCode = CountryCode.FR, }, }, new ScoreInfo @@ -348,17 +388,14 @@ namespace osu.Game.Tests.Visual.SongSelect Date = DateTime.Now.AddHours(-50), Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, BeatmapInfo = beatmapInfo, + BeatmapHash = beatmapInfo.Hash, Ruleset = new OsuRuleset().RulesetInfo, User = new APIUser { Id = 7151382, Username = @"Mayuri Hana", - Country = new Country - { - FullName = @"Thailand", - FlagName = @"TH", - }, + CountryCode = CountryCode.TH, }, }, new ScoreInfo @@ -370,17 +407,14 @@ namespace osu.Game.Tests.Visual.SongSelect Date = DateTime.Now.AddHours(-72), Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, BeatmapInfo = beatmapInfo, + BeatmapHash = beatmapInfo.Hash, Ruleset = new OsuRuleset().RulesetInfo, User = new APIUser { Id = 2051389, Username = @"FunOrange", - Country = new Country - { - FullName = @"Canada", - FlagName = @"CA", - }, + CountryCode = CountryCode.CA, }, }, new ScoreInfo @@ -392,17 +426,14 @@ namespace osu.Game.Tests.Visual.SongSelect Date = DateTime.Now.AddMonths(-3), Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, BeatmapInfo = beatmapInfo, + BeatmapHash = beatmapInfo.Hash, Ruleset = new OsuRuleset().RulesetInfo, User = new APIUser { Id = 6169483, Username = @"-Hebel-", - Country = new Country - { - FullName = @"Mexico", - FlagName = @"MX", - }, + CountryCode = CountryCode.MX, }, }, new ScoreInfo @@ -414,26 +445,23 @@ namespace osu.Game.Tests.Visual.SongSelect Date = DateTime.Now.AddYears(-2), Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, BeatmapInfo = beatmapInfo, + BeatmapHash = beatmapInfo.Hash, Ruleset = new OsuRuleset().RulesetInfo, User = new APIUser { Id = 6702666, Username = @"prhtnsm", - Country = new Country - { - FullName = @"Germany", - FlagName = @"DE", - }, + CountryCode = CountryCode.DE, }, }, }; } - private class FailableLeaderboard : BeatmapLeaderboard + private partial class FailableLeaderboard : BeatmapLeaderboard { public new void SetErrorState(LeaderboardState state) => base.SetErrorState(state); - public new void SetScores(IEnumerable scores, ScoreInfo userScore = default) => base.SetScores(scores, userScore); + public new void SetScores(IEnumerable? scores, ScoreInfo? userScore = null) => base.SetScores(scores, userScore); } } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs index fb6d9a0b4b..c2537cff79 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -20,7 +22,7 @@ using osuTK; namespace osu.Game.Tests.Visual.SongSelect { - public class TestSceneBeatmapMetadataDisplay : OsuTestScene + public partial class TestSceneBeatmapMetadataDisplay : OsuTestScene { private BeatmapMetadataDisplay display; @@ -96,7 +98,7 @@ namespace osu.Game.Tests.Visual.SongSelect OsuLogo logo = new OsuLogo { Scale = new Vector2(0.15f) }; - Remove(testDifficultyCache); + Remove(testDifficultyCache, false); Children = new Drawable[] { @@ -117,7 +119,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("finish loading", () => display.Loading = false); } - private class TestBeatmapDifficultyCache : BeatmapDifficultyCache + private partial class TestBeatmapDifficultyCache : BeatmapDifficultyCache { private TaskCompletionSource calculationBlocker; diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapOptionsOverlay.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapOptionsOverlay.cs index e9742acdde..46a26d2e98 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapOptionsOverlay.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapOptionsOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; @@ -9,7 +11,7 @@ using osu.Game.Screens.Select.Options; namespace osu.Game.Tests.Visual.SongSelect { [Description("bottom beatmap details")] - public class TestSceneBeatmapOptionsOverlay : OsuTestScene + public partial class TestSceneBeatmapOptionsOverlay : OsuTestScene { public TestSceneBeatmapOptionsOverlay() { diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs index 940d001c5b..a368e901f5 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs @@ -1,16 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Extensions; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; @@ -21,40 +23,30 @@ using osu.Game.Users; namespace osu.Game.Tests.Visual.SongSelect { - public class TestSceneBeatmapRecommendations : OsuGameTestScene + public partial class TestSceneBeatmapRecommendations : OsuGameTestScene { + [Resolved] + private IRulesetStore rulesetStore { get; set; } + [SetUpSteps] public override void SetUpSteps() { - AddStep("register request handling", () => - { - ((DummyAPIAccess)API).HandleRequest = req => - { - switch (req) - { - case GetUserRequest userRequest: - userRequest.TriggerSuccess(getUser(userRequest.Ruleset.OnlineID)); - return true; - } - - return false; - }; - }); - base.SetUpSteps(); - APIUser getUser(int? rulesetID) + AddStep("populate ruleset statistics", () => { - return new APIUser + Dictionary rulesetStatistics = new Dictionary(); + + rulesetStore.AvailableRulesets.Where(ruleset => ruleset.IsLegacyRuleset()).ForEach(rulesetInfo => { - Username = @"Dummy", - Id = 1001, - Statistics = new UserStatistics + rulesetStatistics[rulesetInfo.ShortName] = new UserStatistics { - PP = getNecessaryPP(rulesetID) - } - }; - } + PP = getNecessaryPP(rulesetInfo.OnlineID) + }; + }); + + API.LocalUser.Value.RulesetsStatistics = rulesetStatistics; + }); decimal getNecessaryPP(int? rulesetID) { @@ -175,15 +167,22 @@ namespace osu.Game.Tests.Visual.SongSelect var beatmapSet = TestResources.CreateTestBeatmapSetInfo(rulesets.Length, rulesets); - for (int i = 0; i < rulesets.Length; i++) + var importedBeatmapSet = Game.BeatmapManager.Import(beatmapSet); + + Debug.Assert(importedBeatmapSet != null); + + importedBeatmapSet.PerformWrite(s => { - var beatmap = beatmapSet.Beatmaps[i]; + for (int i = 0; i < rulesets.Length; i++) + { + var beatmap = s.Beatmaps[i]; - beatmap.StarRating = i + 1; - beatmap.DifficultyName = $"SR{i + 1}"; - } + beatmap.StarRating = i + 1; + beatmap.DifficultyName = $"SR{i + 1}"; + } + }); - return Game.BeatmapManager.Import(beatmapSet)?.Value; + return importedBeatmapSet.Value; } private bool ensureAllBeatmapSetsImported(IEnumerable beatmapSets) => beatmapSets.All(set => set != null); diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs index b7ec128596..64e2447cca 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -19,42 +20,36 @@ using osu.Game.Rulesets; using osu.Game.Screens.Select; using osu.Game.Tests.Resources; using osuTK.Input; +using Realms; namespace osu.Game.Tests.Visual.SongSelect { - public class TestSceneFilterControl : OsuManualInputManagerTestScene + public partial class TestSceneFilterControl : OsuManualInputManagerTestScene { protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; - private CollectionManager collectionManager; - - private RulesetStore rulesets; - private BeatmapManager beatmapManager; - - private FilterControl control; + private BeatmapManager beatmapManager = null!; + private FilterControl control = null!; [BackgroundDependencyLoader] private void load(GameHost host) { - Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, rulesets, null, Audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, Audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); base.Content.AddRange(new Drawable[] { - collectionManager = new CollectionManager(LocalStorage), Content }); - - Dependencies.Cache(collectionManager); } [SetUp] public void SetUp() => Schedule(() => { - collectionManager.Collections.Clear(); + writeAndRefresh(r => r.RemoveAll()); Child = control = new FilterControl { @@ -75,8 +70,8 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestCollectionAddedToDropdown() { - AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); - AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "2" } })); + AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1")))); + AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "2")))); assertCollectionDropdownContains("1"); assertCollectionDropdownContains("2"); } @@ -84,9 +79,11 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestCollectionRemovedFromDropdown() { - AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); - AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "2" } })); - AddStep("remove collection", () => collectionManager.Collections.RemoveAt(0)); + BeatmapCollection first = null!; + + AddStep("add collection", () => writeAndRefresh(r => r.Add(first = new BeatmapCollection(name: "1")))); + AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "2")))); + AddStep("remove collection", () => writeAndRefresh(r => r.Remove(first))); assertCollectionDropdownContains("1", false); assertCollectionDropdownContains("2"); @@ -95,16 +92,17 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestCollectionRenamed() { - AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1")))); + assertCollectionDropdownContains("1"); AddStep("select collection", () => { - var dropdown = control.ChildrenOfType().Single(); + var dropdown = control.ChildrenOfType().Single(); dropdown.Current.Value = dropdown.ItemSource.ElementAt(1); }); addExpandHeaderStep(); - AddStep("change name", () => collectionManager.Collections[0].Name.Value = "First"); + AddStep("change name", () => writeAndRefresh(_ => getFirstCollection().Name = "First")); assertCollectionDropdownContains("First"); assertCollectionHeaderDisplays("First"); @@ -122,7 +120,8 @@ namespace osu.Game.Tests.Visual.SongSelect public void TestCollectionFilterHasAddButton() { addExpandHeaderStep(); - AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1")))); + assertCollectionDropdownContains("1"); AddStep("hover collection", () => InputManager.MoveMouseTo(getAddOrRemoveButton(1))); AddAssert("collection has add button", () => getAddOrRemoveButton(1).IsPresent); } @@ -132,7 +131,8 @@ namespace osu.Game.Tests.Visual.SongSelect { addExpandHeaderStep(); - AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1")))); + assertCollectionDropdownContains("1"); AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0])); AddAssert("button enabled", () => getAddOrRemoveButton(1).Enabled.Value); @@ -148,14 +148,16 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0])); - AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); - AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare)); + AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1")))); + assertCollectionDropdownContains("1"); - AddStep("add beatmap to collection", () => collectionManager.Collections[0].Beatmaps.Add(Beatmap.Value.BeatmapInfo)); - AddAssert("button is minus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.MinusSquare)); + assertFirstButtonIs(FontAwesome.Solid.PlusSquare); - AddStep("remove beatmap from collection", () => collectionManager.Collections[0].Beatmaps.Clear()); - AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare)); + AddStep("add beatmap to collection", () => writeAndRefresh(r => getFirstCollection().BeatmapMD5Hashes.Add(Beatmap.Value.BeatmapInfo.MD5Hash))); + assertFirstButtonIs(FontAwesome.Solid.MinusSquare); + + AddStep("remove beatmap from collection", () => writeAndRefresh(r => getFirstCollection().BeatmapMD5Hashes.Clear())); + assertFirstButtonIs(FontAwesome.Solid.PlusSquare); } [Test] @@ -165,24 +167,29 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0])); - AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); - AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare)); + AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1")))); + assertCollectionDropdownContains("1"); + assertFirstButtonIs(FontAwesome.Solid.PlusSquare); addClickAddOrRemoveButtonStep(1); - AddAssert("collection contains beatmap", () => collectionManager.Collections[0].Beatmaps.Contains(Beatmap.Value.BeatmapInfo)); - AddAssert("button is minus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.MinusSquare)); + AddAssert("collection contains beatmap", () => getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash)); + assertFirstButtonIs(FontAwesome.Solid.MinusSquare); addClickAddOrRemoveButtonStep(1); - AddAssert("collection does not contain beatmap", () => !collectionManager.Collections[0].Beatmaps.Contains(Beatmap.Value.BeatmapInfo)); - AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare)); + AddAssert("collection does not contain beatmap", () => !getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash)); + assertFirstButtonIs(FontAwesome.Solid.PlusSquare); } [Test] public void TestManageCollectionsFilterIsNotSelected() { + bool received = false; + addExpandHeaderStep(); - AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1", new List { "abc" })))); + assertCollectionDropdownContains("1"); + AddStep("select collection", () => { InputManager.MoveMouseTo(getCollectionDropdownItems().ElementAt(1)); @@ -191,21 +198,39 @@ namespace osu.Game.Tests.Visual.SongSelect addExpandHeaderStep(); + AddStep("watch for filter requests", () => + { + received = false; + control.ChildrenOfType().First().RequestFilter = () => received = true; + }); + AddStep("click manage collections filter", () => { InputManager.MoveMouseTo(getCollectionDropdownItems().Last()); InputManager.Click(MouseButton.Left); }); - AddAssert("collection filter still selected", () => control.CreateCriteria().Collection?.Name.Value == "1"); + AddAssert("collection filter still selected", () => control.CreateCriteria().CollectionBeatmapMD5Hashes.Any()); + + AddAssert("filter request not fired", () => !received); } + private void writeAndRefresh(Action action) => Realm.Write(r => + { + action(r); + r.Refresh(); + }); + + private BeatmapCollection getFirstCollection() => Realm.Run(r => r.All().First()); + private void assertCollectionHeaderDisplays(string collectionName, bool shouldDisplay = true) - => AddAssert($"collection dropdown header displays '{collectionName}'", - () => shouldDisplay == (control.ChildrenOfType().Single().ChildrenOfType().First().Text == collectionName)); + => AddUntilStep($"collection dropdown header displays '{collectionName}'", + () => shouldDisplay == (control.ChildrenOfType().Single().ChildrenOfType().First().Text == collectionName)); + + private void assertFirstButtonIs(IconUsage icon) => AddUntilStep($"button is {icon.Icon.ToString()}", () => getAddOrRemoveButton(1).Icon.Equals(icon)); private void assertCollectionDropdownContains(string collectionName, bool shouldContain = true) => - AddAssert($"collection dropdown {(shouldContain ? "contains" : "does not contain")} '{collectionName}'", + AddUntilStep($"collection dropdown {(shouldContain ? "contains" : "does not contain")} '{collectionName}'", // A bit of a roundabout way of going about this, see: https://github.com/ppy/osu-framework/issues/3871 + https://github.com/ppy/osu-framework/issues/3872 () => shouldContain == (getCollectionDropdownItems().Any(i => i.ChildrenOfType().OfType().First().Text == collectionName))); @@ -214,7 +239,7 @@ namespace osu.Game.Tests.Visual.SongSelect private void addExpandHeaderStep() => AddStep("expand header", () => { - InputManager.MoveMouseTo(control.ChildrenOfType().Single()); + InputManager.MoveMouseTo(control.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); @@ -225,6 +250,6 @@ namespace osu.Game.Tests.Visual.SongSelect }); private IEnumerable.DropdownMenu.DrawableDropdownMenuItem> getCollectionDropdownItems() - => control.ChildrenOfType().Single().ChildrenOfType.DropdownMenu.DrawableDropdownMenuItem>(); + => control.ChildrenOfType().Single().ChildrenOfType.DropdownMenu.DrawableDropdownMenuItem>(); } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index 77f5bd83d6..f094d40caa 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -2,16 +2,21 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics.Containers; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; @@ -21,11 +26,11 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Overlays; using osu.Game.Overlays.Mods; +using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Rulesets.Taiko; using osu.Game.Scoring; using osu.Game.Screens.Play; using osu.Game.Screens.Select; @@ -37,13 +42,14 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.SongSelect { [TestFixture] - public class TestScenePlaySongSelect : ScreenTestScene + public partial class TestScenePlaySongSelect : ScreenTestScene { - private BeatmapManager manager; - private RulesetStore rulesets; - private MusicController music; - private WorkingBeatmap defaultBeatmap; - private TestSongSelect songSelect; + private BeatmapManager manager = null!; + private RulesetStore rulesets = null!; + private MusicController music = null!; + private WorkingBeatmap defaultBeatmap = null!; + private OsuConfigManager config = null!; + private TestSongSelect? songSelect; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) @@ -52,7 +58,7 @@ namespace osu.Game.Tests.Visual.SongSelect // At a point we have isolated interactive test runs enough, this can likely be removed. Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); Dependencies.Cache(Realm); - Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, defaultBeatmap = Beatmap.Default)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, defaultBeatmap = Beatmap.Default)); Dependencies.Cache(music = new MusicController()); @@ -62,8 +68,6 @@ namespace osu.Game.Tests.Visual.SongSelect Dependencies.Cache(config = new OsuConfigManager(LocalStorage)); } - private OsuConfigManager config; - public override void SetUpSteps() { base.SetUpSteps(); @@ -78,7 +82,7 @@ namespace osu.Game.Tests.Visual.SongSelect songSelect = null; }); - AddStep("delete all beatmaps", () => manager?.Delete()); + AddStep("delete all beatmaps", () => manager.Delete()); } [Test] @@ -91,19 +95,36 @@ namespace osu.Game.Tests.Visual.SongSelect addRulesetImportStep(0); AddUntilStep("wait for placeholder hidden", () => getPlaceholder()?.State.Value == Visibility.Hidden); - AddStep("delete all beatmaps", () => manager?.Delete()); + AddStep("delete all beatmaps", () => manager.Delete()); AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Visible); } + [Test] + public void TestPlaceholderStarDifficulty() + { + addRulesetImportStep(0); + AddStep("change star filter", () => config.SetValue(OsuSetting.DisplayStarsMinimum, 10.0)); + + createSongSelect(); + + AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Visible); + + AddStep("click link in placeholder", () => getPlaceholder().ChildrenOfType().First().TriggerClick()); + + AddUntilStep("star filter reset", () => config.Get(OsuSetting.DisplayStarsMinimum) == 0.0); + AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Hidden); + } + [Test] public void TestPlaceholderConvertSetting() { - changeRuleset(2); addRulesetImportStep(0); AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, false)); createSongSelect(); + changeRuleset(2); + AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Visible); AddStep("click link in placeholder", () => getPlaceholder().ChildrenOfType().First().TriggerClick()); @@ -120,7 +141,7 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); - AddAssert("filter count is 1", () => songSelect.FilterCount == 1); + AddAssert("filter count is 1", () => songSelect?.FilterCount == 1); } [Test] @@ -132,7 +153,7 @@ namespace osu.Game.Tests.Visual.SongSelect waitForInitialSelection(); - WorkingBeatmap selected = null; + WorkingBeatmap? selected = null; AddStep("store selected beatmap", () => selected = Beatmap.Value); @@ -142,7 +163,7 @@ namespace osu.Game.Tests.Visual.SongSelect InputManager.Key(Key.Enter); }); - AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); AddAssert("ensure selection changed", () => selected != Beatmap.Value); } @@ -155,7 +176,7 @@ namespace osu.Game.Tests.Visual.SongSelect waitForInitialSelection(); - WorkingBeatmap selected = null; + WorkingBeatmap? selected = null; AddStep("store selected beatmap", () => selected = Beatmap.Value); @@ -165,7 +186,7 @@ namespace osu.Game.Tests.Visual.SongSelect InputManager.Key(Key.Down); }); - AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); AddAssert("ensure selection didn't change", () => selected == Beatmap.Value); } @@ -178,23 +199,23 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("wait for initial selection", () => !Beatmap.IsDefault); - WorkingBeatmap selected = null; + WorkingBeatmap? selected = null; AddStep("store selected beatmap", () => selected = Beatmap.Value); - AddUntilStep("wait for beatmaps to load", () => songSelect.Carousel.ChildrenOfType().Any()); + AddUntilStep("wait for beatmaps to load", () => songSelect!.Carousel.ChildrenOfType().Any()); AddStep("select next and enter", () => { - InputManager.MoveMouseTo(songSelect.Carousel.ChildrenOfType() - .First(b => !((CarouselBeatmap)b.Item).BeatmapInfo.Equals(songSelect.Carousel.SelectedBeatmapInfo))); + InputManager.MoveMouseTo(songSelect!.Carousel.ChildrenOfType() + .First(b => !((CarouselBeatmap)b.Item!).BeatmapInfo.Equals(songSelect!.Carousel.SelectedBeatmapInfo))); InputManager.Click(MouseButton.Left); InputManager.Key(Key.Enter); }); - AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); AddAssert("ensure selection changed", () => selected != Beatmap.Value); } @@ -207,14 +228,14 @@ namespace osu.Game.Tests.Visual.SongSelect waitForInitialSelection(); - WorkingBeatmap selected = null; + WorkingBeatmap? selected = null; AddStep("store selected beatmap", () => selected = Beatmap.Value); AddStep("select next and enter", () => { - InputManager.MoveMouseTo(songSelect.Carousel.ChildrenOfType() - .First(b => !((CarouselBeatmap)b.Item).BeatmapInfo.Equals(songSelect.Carousel.SelectedBeatmapInfo))); + InputManager.MoveMouseTo(songSelect!.Carousel.ChildrenOfType() + .First(b => !((CarouselBeatmap)b.Item!).BeatmapInfo.Equals(songSelect!.Carousel.SelectedBeatmapInfo))); InputManager.PressButton(MouseButton.Left); @@ -223,7 +244,7 @@ namespace osu.Game.Tests.Visual.SongSelect InputManager.ReleaseButton(MouseButton.Left); }); - AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); AddAssert("ensure selection didn't change", () => selected == Beatmap.Value); } @@ -236,11 +257,11 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child"))); - AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); - AddStep("return", () => songSelect.MakeCurrent()); - AddUntilStep("wait for current", () => songSelect.IsCurrentScreen()); - AddAssert("filter count is 1", () => songSelect.FilterCount == 1); + AddStep("return", () => songSelect!.MakeCurrent()); + AddUntilStep("wait for current", () => songSelect!.IsCurrentScreen()); + AddAssert("filter count is 1", () => songSelect!.FilterCount == 1); } [Test] @@ -254,13 +275,35 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child"))); - AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); - AddStep("return", () => songSelect.MakeCurrent()); - AddUntilStep("wait for current", () => songSelect.IsCurrentScreen()); - AddAssert("filter count is 2", () => songSelect.FilterCount == 2); + AddStep("return", () => songSelect!.MakeCurrent()); + AddUntilStep("wait for current", () => songSelect!.IsCurrentScreen()); + AddAssert("filter count is 2", () => songSelect!.FilterCount == 2); + } + + [Test] + public void TestCarouselSelectionUpdatesOnResume() + { + addRulesetImportStep(0); + + createSongSelect(); + + AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child"))); + AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); + + AddStep("update beatmap", () => + { + var selectedBeatmap = Beatmap.Value.BeatmapInfo; + var anotherBeatmap = Beatmap.Value.BeatmapSetInfo.Beatmaps.Except(selectedBeatmap.Yield()).First(); + Beatmap.Value = manager.GetWorkingBeatmap(anotherBeatmap); + }); + + AddStep("return", () => songSelect!.MakeCurrent()); + AddUntilStep("wait for current", () => songSelect!.IsCurrentScreen()); + AddAssert("carousel updated", () => songSelect!.Carousel.SelectedBeatmapInfo?.Equals(Beatmap.Value.BeatmapInfo) == true); } [Test] @@ -272,15 +315,15 @@ namespace osu.Game.Tests.Visual.SongSelect addRulesetImportStep(0); checkMusicPlaying(true); - AddStep("select first", () => songSelect.Carousel.SelectBeatmap(songSelect.Carousel.BeatmapSets.First().Beatmaps.First())); + AddStep("select first", () => songSelect!.Carousel.SelectBeatmap(songSelect!.Carousel.BeatmapSets.First().Beatmaps.First())); checkMusicPlaying(true); AddStep("manual pause", () => music.TogglePause()); checkMusicPlaying(false); - AddStep("select next difficulty", () => songSelect.Carousel.SelectNext(skipDifficulties: false)); + AddStep("select next difficulty", () => songSelect!.Carousel.SelectNext(skipDifficulties: false)); checkMusicPlaying(false); - AddStep("select next set", () => songSelect.Carousel.SelectNext()); + AddStep("select next set", () => songSelect!.Carousel.SelectNext()); checkMusicPlaying(true); } @@ -320,13 +363,13 @@ namespace osu.Game.Tests.Visual.SongSelect public void TestDummy() { createSongSelect(); - AddUntilStep("dummy selected", () => songSelect.CurrentBeatmap == defaultBeatmap); + AddUntilStep("dummy selected", () => songSelect!.CurrentBeatmap == defaultBeatmap); - AddUntilStep("dummy shown on wedge", () => songSelect.CurrentBeatmapDetailsBeatmap == defaultBeatmap); + AddUntilStep("dummy shown on wedge", () => songSelect!.CurrentBeatmapDetailsBeatmap == defaultBeatmap); addManyTestMaps(); - AddUntilStep("random map selected", () => songSelect.CurrentBeatmap != defaultBeatmap); + AddUntilStep("random map selected", () => songSelect!.CurrentBeatmap != defaultBeatmap); } [Test] @@ -335,7 +378,7 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); addManyTestMaps(); - AddUntilStep("random map selected", () => songSelect.CurrentBeatmap != defaultBeatmap); + AddUntilStep("random map selected", () => songSelect!.CurrentBeatmap != defaultBeatmap); AddStep(@"Sort by Artist", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Artist)); AddStep(@"Sort by Title", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Title)); @@ -352,7 +395,7 @@ namespace osu.Game.Tests.Visual.SongSelect { createSongSelect(); addRulesetImportStep(2); - AddUntilStep("no selection", () => songSelect.Carousel.SelectedBeatmapInfo == null); + AddUntilStep("no selection", () => songSelect!.Carousel.SelectedBeatmapInfo == null); } [Test] @@ -362,13 +405,73 @@ namespace osu.Game.Tests.Visual.SongSelect changeRuleset(2); addRulesetImportStep(2); addRulesetImportStep(1); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.Ruleset.OnlineID == 2); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Ruleset.OnlineID == 2); changeRuleset(1); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.Ruleset.OnlineID == 1); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Ruleset.OnlineID == 1); changeRuleset(0); - AddUntilStep("no selection", () => songSelect.Carousel.SelectedBeatmapInfo == null); + AddUntilStep("no selection", () => songSelect!.Carousel.SelectedBeatmapInfo == null); + } + + [Test] + public void TestSelectionRetainedOnBeatmapUpdate() + { + createSongSelect(); + changeRuleset(0); + + Live? original = null; + int originalOnlineSetID = 0; + + AddStep(@"Sort by artist", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Artist)); + + AddStep("import original", () => + { + original = manager.Import(new ImportTask(TestResources.GetQuickTestBeatmapForImport())).GetResultSafely(); + + Debug.Assert(original != null); + + originalOnlineSetID = original.Value.OnlineID; + }); + + // This will move the beatmap set to a different location in the carousel. + AddStep("Update original with bogus info", () => + { + Debug.Assert(original != null); + + original.PerformWrite(set => + { + foreach (var beatmap in set.Beatmaps) + { + beatmap.Metadata.Artist = "ZZZZZ"; + beatmap.OnlineID = 12804; + } + }); + }); + + AddRepeatStep("import other beatmaps", () => + { + var testBeatmapSetInfo = TestResources.CreateTestBeatmapSetInfo(); + + foreach (var beatmap in testBeatmapSetInfo.Beatmaps) + beatmap.Metadata.Artist = ((char)RNG.Next('A', 'Z')).ToString(); + + manager.Import(testBeatmapSetInfo); + }, 10); + + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.BeatmapSet?.OnlineID == originalOnlineSetID); + + Task?> updateTask = null!; + + AddStep("update beatmap", () => + { + Debug.Assert(original != null); + + updateTask = manager.ImportAsUpdate(new ProgressNotification(), new ImportTask(TestResources.GetQuickTestBeatmapForImport()), original.Value); + }); + AddUntilStep("wait for update completion", () => updateTask.IsCompleted); + + AddUntilStep("retained selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.BeatmapSet?.OnlineID == originalOnlineSetID); } [Test] @@ -378,13 +481,13 @@ namespace osu.Game.Tests.Visual.SongSelect changeRuleset(2); addRulesetImportStep(2); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.Ruleset.OnlineID == 2); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Ruleset.OnlineID == 2); addRulesetImportStep(0); addRulesetImportStep(0); addRulesetImportStep(0); - BeatmapInfo target = null; + BeatmapInfo? target = null; AddStep("select beatmap/ruleset externally", () => { @@ -395,10 +498,10 @@ namespace osu.Game.Tests.Visual.SongSelect Beatmap.Value = manager.GetWorkingBeatmap(target); }); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.Equals(target)); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Equals(target) == true); // this is an important check, to make sure updateComponentFromBeatmap() was actually run - AddUntilStep("selection shown on wedge", () => songSelect.CurrentBeatmapDetailsBeatmap.BeatmapInfo.MatchesOnlineID(target)); + AddUntilStep("selection shown on wedge", () => songSelect!.CurrentBeatmapDetailsBeatmap.BeatmapInfo.MatchesOnlineID(target)); } [Test] @@ -408,13 +511,13 @@ namespace osu.Game.Tests.Visual.SongSelect changeRuleset(2); addRulesetImportStep(2); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.Ruleset.OnlineID == 2); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Ruleset.OnlineID == 2); addRulesetImportStep(0); addRulesetImportStep(0); addRulesetImportStep(0); - BeatmapInfo target = null; + BeatmapInfo? target = null; AddStep("select beatmap/ruleset externally", () => { @@ -425,42 +528,12 @@ namespace osu.Game.Tests.Visual.SongSelect Ruleset.Value = rulesets.AvailableRulesets.First(r => r.OnlineID == 0); }); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.Equals(target)); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Equals(target) == true); AddUntilStep("has correct ruleset", () => Ruleset.Value.OnlineID == 0); // this is an important check, to make sure updateComponentFromBeatmap() was actually run - AddUntilStep("selection shown on wedge", () => songSelect.CurrentBeatmapDetailsBeatmap.BeatmapInfo.MatchesOnlineID(target)); - } - - [Test] - public void TestRulesetChangeResetsMods() - { - createSongSelect(); - changeRuleset(0); - - changeMods(new OsuModHardRock()); - - int actionIndex = 0; - int modChangeIndex = 0; - int rulesetChangeIndex = 0; - - AddStep("change ruleset", () => - { - SelectedMods.ValueChanged += onModChange; - songSelect.Ruleset.ValueChanged += onRulesetChange; - - Ruleset.Value = new TaikoRuleset().RulesetInfo; - - SelectedMods.ValueChanged -= onModChange; - songSelect.Ruleset.ValueChanged -= onRulesetChange; - }); - - AddAssert("mods changed before ruleset", () => modChangeIndex < rulesetChangeIndex); - AddAssert("empty mods", () => !SelectedMods.Value.Any()); - - void onModChange(ValueChangedEvent> e) => modChangeIndex = actionIndex++; - void onRulesetChange(ValueChangedEvent e) => rulesetChangeIndex = actionIndex++; + AddUntilStep("selection shown on wedge", () => songSelect!.CurrentBeatmapDetailsBeatmap.BeatmapInfo.MatchesOnlineID(target)); } [Test] @@ -484,18 +557,18 @@ namespace osu.Game.Tests.Visual.SongSelect { createSongSelect(); addManyTestMaps(); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo != null); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo != null); bool startRequested = false; AddStep("set filter and finalize", () => { - songSelect.StartRequested = () => startRequested = true; + songSelect!.StartRequested = () => startRequested = true; - songSelect.Carousel.Filter(new FilterCriteria { SearchText = "somestringthatshouldn'tbematchable" }); - songSelect.FinaliseSelection(); + songSelect!.Carousel.Filter(new FilterCriteria { SearchText = "somestringthatshouldn'tbematchable" }); + songSelect!.FinaliseSelection(); - songSelect.StartRequested = null; + songSelect!.StartRequested = null; }); AddAssert("start not requested", () => !startRequested); @@ -515,15 +588,15 @@ namespace osu.Game.Tests.Visual.SongSelect // used for filter check below AddStep("allow convert display", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo != null); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo != null); - AddStep("set filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = "nonono"); + AddStep("set filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = "nonono"); AddUntilStep("dummy selected", () => Beatmap.Value is DummyWorkingBeatmap); - AddUntilStep("has no selection", () => songSelect.Carousel.SelectedBeatmapInfo == null); + AddUntilStep("has no selection", () => songSelect!.Carousel.SelectedBeatmapInfo == null); - BeatmapInfo target = null; + BeatmapInfo? target = null; int targetRuleset = differentRuleset ? 1 : 0; @@ -537,24 +610,24 @@ namespace osu.Game.Tests.Visual.SongSelect Beatmap.Value = manager.GetWorkingBeatmap(target); }); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo != null); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo != null); AddAssert("selected only shows expected ruleset (plus converts)", () => { - var selectedPanel = songSelect.Carousel.ChildrenOfType().First(s => s.Item.State.Value == CarouselItemState.Selected); + var selectedPanel = songSelect!.Carousel.ChildrenOfType().First(s => s.Item!.State.Value == CarouselItemState.Selected); // special case for converts checked here. return selectedPanel.ChildrenOfType().All(i => i.IsFiltered || i.Item.BeatmapInfo.Ruleset.OnlineID == targetRuleset || i.Item.BeatmapInfo.Ruleset.OnlineID == 0); }); - AddUntilStep("carousel has correct", () => songSelect.Carousel.SelectedBeatmapInfo?.MatchesOnlineID(target) == true); + AddUntilStep("carousel has correct", () => songSelect!.Carousel.SelectedBeatmapInfo?.MatchesOnlineID(target) == true); AddUntilStep("game has correct", () => Beatmap.Value.BeatmapInfo.MatchesOnlineID(target)); - AddStep("reset filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = string.Empty); + AddStep("reset filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = string.Empty); AddAssert("game still correct", () => Beatmap.Value?.BeatmapInfo.MatchesOnlineID(target) == true); - AddAssert("carousel still correct", () => songSelect.Carousel.SelectedBeatmapInfo.MatchesOnlineID(target)); + AddAssert("carousel still correct", () => songSelect!.Carousel.SelectedBeatmapInfo.MatchesOnlineID(target)); } [Test] @@ -567,15 +640,15 @@ namespace osu.Game.Tests.Visual.SongSelect changeRuleset(0); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo != null); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo != null); - AddStep("set filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = "nonono"); + AddStep("set filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = "nonono"); AddUntilStep("dummy selected", () => Beatmap.Value is DummyWorkingBeatmap); - AddUntilStep("has no selection", () => songSelect.Carousel.SelectedBeatmapInfo == null); + AddUntilStep("has no selection", () => songSelect!.Carousel.SelectedBeatmapInfo == null); - BeatmapInfo target = null; + BeatmapInfo? target = null; AddStep("select beatmap externally", () => { @@ -587,15 +660,15 @@ namespace osu.Game.Tests.Visual.SongSelect Beatmap.Value = manager.GetWorkingBeatmap(target); }); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo != null); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo != null); - AddUntilStep("carousel has correct", () => songSelect.Carousel.SelectedBeatmapInfo?.MatchesOnlineID(target) == true); + AddUntilStep("carousel has correct", () => songSelect!.Carousel.SelectedBeatmapInfo?.MatchesOnlineID(target) == true); AddUntilStep("game has correct", () => Beatmap.Value.BeatmapInfo.MatchesOnlineID(target)); - AddStep("set filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = "nononoo"); + AddStep("set filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = "nononoo"); AddUntilStep("game lost selection", () => Beatmap.Value is DummyWorkingBeatmap); - AddAssert("carousel lost selection", () => songSelect.Carousel.SelectedBeatmapInfo == null); + AddAssert("carousel lost selection", () => songSelect!.Carousel.SelectedBeatmapInfo == null); } [Test] @@ -616,11 +689,11 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("wait for player", () => Stack.CurrentScreen is PlayerLoader); - AddAssert("autoplay selected", () => songSelect.Mods.Value.Single() is ModAutoplay); + AddAssert("autoplay selected", () => songSelect!.Mods.Value.Single() is ModAutoplay); - AddUntilStep("wait for return to ss", () => songSelect.IsCurrentScreen()); + AddUntilStep("wait for return to ss", () => songSelect!.IsCurrentScreen()); - AddAssert("no mods selected", () => songSelect.Mods.Value.Count == 0); + AddAssert("no mods selected", () => songSelect!.Mods.Value.Count == 0); } [Test] @@ -643,11 +716,11 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("wait for player", () => Stack.CurrentScreen is PlayerLoader); - AddAssert("autoplay selected", () => songSelect.Mods.Value.Single() is ModAutoplay); + AddAssert("autoplay selected", () => songSelect!.Mods.Value.Single() is ModAutoplay); - AddUntilStep("wait for return to ss", () => songSelect.IsCurrentScreen()); + AddUntilStep("wait for return to ss", () => songSelect!.IsCurrentScreen()); - AddAssert("autoplay still selected", () => songSelect.Mods.Value.Single() is ModAutoplay); + AddAssert("autoplay still selected", () => songSelect!.Mods.Value.Single() is ModAutoplay); } [Test] @@ -670,11 +743,11 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("wait for player", () => Stack.CurrentScreen is PlayerLoader); - AddAssert("only autoplay selected", () => songSelect.Mods.Value.Single() is ModAutoplay); + AddAssert("only autoplay selected", () => songSelect!.Mods.Value.Single() is ModAutoplay); - AddUntilStep("wait for return to ss", () => songSelect.IsCurrentScreen()); + AddUntilStep("wait for return to ss", () => songSelect!.IsCurrentScreen()); - AddAssert("relax returned", () => songSelect.Mods.Value.Single() is ModRelax); + AddAssert("relax returned", () => songSelect!.Mods.Value.Single() is ModRelax); } [Test] @@ -683,10 +756,10 @@ namespace osu.Game.Tests.Visual.SongSelect Guid? previousID = null; createSongSelect(); addRulesetImportStep(0); - AddStep("Move to last difficulty", () => songSelect.Carousel.SelectBeatmap(songSelect.Carousel.BeatmapSets.First().Beatmaps.Last())); - AddStep("Store current ID", () => previousID = songSelect.Carousel.SelectedBeatmapInfo.ID); - AddStep("Hide first beatmap", () => manager.Hide(songSelect.Carousel.SelectedBeatmapSet.Beatmaps.First())); - AddAssert("Selected beatmap has not changed", () => songSelect.Carousel.SelectedBeatmapInfo.ID == previousID); + AddStep("Move to last difficulty", () => songSelect!.Carousel.SelectBeatmap(songSelect!.Carousel.BeatmapSets.First().Beatmaps.Last())); + AddStep("Store current ID", () => previousID = songSelect!.Carousel.SelectedBeatmapInfo!.ID); + AddStep("Hide first beatmap", () => manager.Hide(songSelect!.Carousel.SelectedBeatmapSet!.Beatmaps.First())); + AddAssert("Selected beatmap has not changed", () => songSelect!.Carousel.SelectedBeatmapInfo?.ID == previousID); } [Test] @@ -697,17 +770,24 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("wait for selection", () => !Beatmap.IsDefault); - DrawableCarouselBeatmapSet set = null; + DrawableCarouselBeatmapSet set = null!; AddStep("Find the DrawableCarouselBeatmapSet", () => { - set = songSelect.Carousel.ChildrenOfType().First(); + set = songSelect!.Carousel.ChildrenOfType().First(); }); - FilterableDifficultyIcon difficultyIcon = null; + FilterableDifficultyIcon difficultyIcon = null!; + AddUntilStep("Find an icon", () => { - return (difficultyIcon = set.ChildrenOfType() - .FirstOrDefault(icon => getDifficultyIconIndex(set, icon) != getCurrentBeatmapIndex())) != null; + var foundIcon = set.ChildrenOfType() + .FirstOrDefault(icon => getDifficultyIconIndex(set, icon) != getCurrentBeatmapIndex()); + + if (foundIcon == null) + return false; + + difficultyIcon = foundIcon; + return true; }); AddStep("Click on a difficulty", () => @@ -720,21 +800,24 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("Selected beatmap correct", () => getCurrentBeatmapIndex() == getDifficultyIconIndex(set, difficultyIcon)); double? maxBPM = null; - AddStep("Filter some difficulties", () => songSelect.Carousel.Filter(new FilterCriteria + AddStep("Filter some difficulties", () => songSelect!.Carousel.Filter(new FilterCriteria { BPM = new FilterCriteria.OptionalRange { - Min = maxBPM = songSelect.Carousel.SelectedBeatmapSet.MaxBPM, + Min = maxBPM = songSelect!.Carousel.SelectedBeatmapSet!.MaxBPM, IsLowerInclusive = true } })); - BeatmapInfo filteredBeatmap = null; - FilterableDifficultyIcon filteredIcon = null; + BeatmapInfo? filteredBeatmap = null; + FilterableDifficultyIcon? filteredIcon = null; AddStep("Get filtered icon", () => { - var selectedSet = songSelect.Carousel.SelectedBeatmapSet; + var selectedSet = songSelect!.Carousel.SelectedBeatmapSet; + + Debug.Assert(selectedSet != null); + filteredBeatmap = selectedSet.Beatmaps.First(b => b.BPM < maxBPM); int filteredBeatmapIndex = getBeatmapIndex(selectedSet, filteredBeatmap); filteredIcon = set.ChildrenOfType().ElementAt(filteredBeatmapIndex); @@ -747,7 +830,7 @@ namespace osu.Game.Tests.Visual.SongSelect InputManager.Click(MouseButton.Left); }); - AddAssert("Selected beatmap correct", () => songSelect.Carousel.SelectedBeatmapInfo.Equals(filteredBeatmap)); + AddAssert("Selected beatmap correct", () => songSelect!.Carousel.SelectedBeatmapInfo?.Equals(filteredBeatmap) == true); } [Test] @@ -812,14 +895,14 @@ namespace osu.Game.Tests.Visual.SongSelect manager.Import(TestResources.CreateTestBeatmapSetInfo(3, usableRulesets)); }); - DrawableCarouselBeatmapSet set = null; + DrawableCarouselBeatmapSet? set = null; AddUntilStep("Find the DrawableCarouselBeatmapSet", () => { - set = songSelect.Carousel.ChildrenOfType().FirstOrDefault(); + set = songSelect!.Carousel.ChildrenOfType().FirstOrDefault(); return set != null; }); - FilterableDifficultyIcon difficultyIcon = null; + FilterableDifficultyIcon? difficultyIcon = null; AddUntilStep("Find an icon for different ruleset", () => { difficultyIcon = set.ChildrenOfType() @@ -842,7 +925,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("Check ruleset changed to mania", () => Ruleset.Value.OnlineID == 3); - AddAssert("Selected beatmap still same set", () => songSelect.Carousel.SelectedBeatmapInfo.BeatmapSet?.OnlineID == previousSetID); + AddAssert("Selected beatmap still same set", () => songSelect!.Carousel.SelectedBeatmapInfo?.BeatmapSet?.OnlineID == previousSetID); AddAssert("Selected beatmap is mania", () => Beatmap.Value.BeatmapInfo.Ruleset.OnlineID == 3); } @@ -853,7 +936,7 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); - BeatmapSetInfo imported = null; + BeatmapSetInfo? imported = null; AddStep("import huge difficulty count map", () => { @@ -861,20 +944,27 @@ namespace osu.Game.Tests.Visual.SongSelect imported = manager.Import(TestResources.CreateTestBeatmapSetInfo(50, usableRulesets))?.Value; }); - AddStep("select the first beatmap of import", () => Beatmap.Value = manager.GetWorkingBeatmap(imported.Beatmaps.First())); + AddStep("select the first beatmap of import", () => Beatmap.Value = manager.GetWorkingBeatmap(imported?.Beatmaps.First())); - DrawableCarouselBeatmapSet set = null; + DrawableCarouselBeatmapSet? set = null; AddUntilStep("Find the DrawableCarouselBeatmapSet", () => { - set = songSelect.Carousel.ChildrenOfType().FirstOrDefault(); + set = songSelect!.Carousel.ChildrenOfType().FirstOrDefault(); return set != null; }); - FilterableGroupedDifficultyIcon groupIcon = null; + GroupedDifficultyIcon groupIcon = null!; + AddUntilStep("Find group icon for different ruleset", () => { - return (groupIcon = set.ChildrenOfType() - .FirstOrDefault(icon => icon.Items.First().BeatmapInfo.Ruleset.OnlineID == 3)) != null; + var foundIcon = set.ChildrenOfType() + .FirstOrDefault(icon => icon.Items.First().BeatmapInfo.Ruleset.OnlineID == 3); + + if (foundIcon == null) + return false; + + groupIcon = foundIcon; + return true; }); AddAssert("Check ruleset is osu!", () => Ruleset.Value.OnlineID == 0); @@ -909,7 +999,7 @@ namespace osu.Game.Tests.Visual.SongSelect // this ruleset change should be overridden by the present. Ruleset.Value = getSwitchBeatmap().Ruleset; - songSelect.PresentScore(new ScoreInfo + songSelect!.PresentScore(new ScoreInfo { User = new APIUser { Username = "woo" }, BeatmapInfo = getPresentBeatmap(), @@ -917,7 +1007,7 @@ namespace osu.Game.Tests.Visual.SongSelect }); }); - AddUntilStep("wait for results screen presented", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for results screen presented", () => !songSelect!.IsCurrentScreen()); AddAssert("check beatmap is correct for score", () => Beatmap.Value.BeatmapInfo.MatchesOnlineID(getPresentBeatmap())); AddAssert("check ruleset is correct for score", () => Ruleset.Value.OnlineID == 0); @@ -943,10 +1033,10 @@ namespace osu.Game.Tests.Visual.SongSelect // this beatmap change should be overridden by the present. Beatmap.Value = manager.GetWorkingBeatmap(getSwitchBeatmap()); - songSelect.PresentScore(TestResources.CreateTestScoreInfo(getPresentBeatmap())); + songSelect!.PresentScore(TestResources.CreateTestScoreInfo(getPresentBeatmap())); }); - AddUntilStep("wait for results screen presented", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for results screen presented", () => !songSelect!.IsCurrentScreen()); AddAssert("check beatmap is correct for score", () => Beatmap.Value.BeatmapInfo.MatchesOnlineID(getPresentBeatmap())); AddAssert("check ruleset is correct for score", () => Ruleset.Value.OnlineID == 0); @@ -959,23 +1049,57 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); AddStep("toggle mod overlay on", () => InputManager.Key(Key.F1)); - AddUntilStep("mod overlay shown", () => songSelect.ModSelect.State.Value == Visibility.Visible); + AddUntilStep("mod overlay shown", () => songSelect!.ModSelect.State.Value == Visibility.Visible); AddStep("toggle mod overlay off", () => InputManager.Key(Key.F1)); - AddUntilStep("mod overlay hidden", () => songSelect.ModSelect.State.Value == Visibility.Hidden); + AddUntilStep("mod overlay hidden", () => songSelect!.ModSelect.State.Value == Visibility.Hidden); + } + + [Test] + public void TestBeatmapOptionsDisabled() + { + createSongSelect(); + + addRulesetImportStep(0); + + AddAssert("options enabled", () => songSelect.ChildrenOfType().Single().Enabled.Value); + AddStep("delete all beatmaps", () => manager.Delete()); + AddUntilStep("wait for no beatmap", () => Beatmap.IsDefault); + 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); - AddUntilStep("wait for difficulty panels visible", () => songSelect.Carousel.ChildrenOfType().Any()); + AddUntilStep("wait for difficulty panels visible", () => songSelect!.Carousel.ChildrenOfType().Any()); } private int getBeatmapIndex(BeatmapSetInfo set, BeatmapInfo info) => set.Beatmaps.IndexOf(info); - private NoResultsPlaceholder getPlaceholder() => songSelect.ChildrenOfType().FirstOrDefault(); + private NoResultsPlaceholder? getPlaceholder() => songSelect!.ChildrenOfType().FirstOrDefault(); - private int getCurrentBeatmapIndex() => getBeatmapIndex(songSelect.Carousel.SelectedBeatmapSet, songSelect.Carousel.SelectedBeatmapInfo); + private int getCurrentBeatmapIndex() + { + Debug.Assert(songSelect!.Carousel.SelectedBeatmapSet != null); + Debug.Assert(songSelect!.Carousel.SelectedBeatmapInfo != null); + + return getBeatmapIndex(songSelect!.Carousel.SelectedBeatmapSet, songSelect!.Carousel.SelectedBeatmapInfo); + } private int getDifficultyIconIndex(DrawableCarouselBeatmapSet set, FilterableDifficultyIcon icon) { @@ -984,14 +1108,14 @@ namespace osu.Game.Tests.Visual.SongSelect private void addRulesetImportStep(int id) { - Live imported = null; + Live? imported = null; AddStep($"import test map for ruleset {id}", () => imported = importForRuleset(id)); // This is specifically for cases where the add is happening post song select load. // For cases where song select is null, the assertions are provided by the load checks. - AddUntilStep("wait for imported to arrive in carousel", () => songSelect == null || songSelect.Carousel.BeatmapSets.Any(s => s.ID == imported?.ID)); + AddUntilStep("wait for imported to arrive in carousel", () => songSelect == null || songSelect!.Carousel.BeatmapSets.Any(s => s.ID == imported?.ID)); } - private Live importForRuleset(int id) => manager.Import(TestResources.CreateTestBeatmapSetInfo(3, rulesets.AvailableRulesets.Where(r => r.OnlineID == id).ToArray())); + private Live? importForRuleset(int id) => manager.Import(TestResources.CreateTestBeatmapSetInfo(3, rulesets.AvailableRulesets.Where(r => r.OnlineID == id).ToArray())); private void checkMusicPlaying(bool playing) => AddUntilStep($"music {(playing ? "" : "not ")}playing", () => music.IsPlaying == playing); @@ -1003,8 +1127,8 @@ namespace osu.Game.Tests.Visual.SongSelect private void createSongSelect() { AddStep("create song select", () => LoadScreen(songSelect = new TestSongSelect())); - AddUntilStep("wait for present", () => songSelect.IsCurrentScreen()); - AddUntilStep("wait for carousel loaded", () => songSelect.Carousel.IsAlive); + AddUntilStep("wait for present", () => songSelect!.IsCurrentScreen()); + AddUntilStep("wait for carousel loaded", () => songSelect!.Carousel.IsAlive); } /// @@ -1028,12 +1152,14 @@ namespace osu.Game.Tests.Visual.SongSelect protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - rulesets?.Dispose(); + + if (rulesets.IsNotNull()) + rulesets.Dispose(); } - private class TestSongSelect : PlaySongSelect + private partial class TestSongSelect : PlaySongSelect { - public Action StartRequested; + public Action? StartRequested; public new Bindable Ruleset => base.Ruleset; diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectFooter.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectFooter.cs index f27615eea5..646dedc2be 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectFooter.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectFooter.cs @@ -1,15 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; +using osu.Framework.Testing; using osu.Game.Screens.Select; using osuTK; using osuTK.Input; namespace osu.Game.Tests.Visual.SongSelect { - public class TestSceneSongSelectFooter : OsuManualInputManagerTestScene + public partial class TestSceneSongSelectFooter : OsuManualInputManagerTestScene { private FooterButtonRandom randomButton; @@ -41,6 +45,12 @@ namespace osu.Game.Tests.Visual.SongSelect InputManager.MoveMouseTo(Vector2.Zero); }); + [Test] + public void TestState() + { + AddRepeatStep("toggle options state", () => this.ChildrenOfType().Last().Enabled.Toggle(), 20); + } + [Test] public void TestFooterRandom() { diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectFooterV2.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectFooterV2.cs new file mode 100644 index 0000000000..72adbfc104 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectFooterV2.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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Screens.Select.FooterV2; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.SongSelect +{ + public partial class TestSceneSongSelectFooterV2 : OsuManualInputManagerTestScene + { + private FooterButtonRandomV2 randomButton = null!; + private FooterButtonModsV2 modsButton = null!; + + private bool nextRandomCalled; + private bool previousRandomCalled; + + private DummyOverlay overlay = null!; + + [Cached] + private OverlayColourProvider colourProvider { get; set; } = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + [SetUp] + public void SetUp() => Schedule(() => + { + nextRandomCalled = false; + previousRandomCalled = false; + + FooterV2 footer; + + Children = new Drawable[] + { + footer = new FooterV2 + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }, + overlay = new DummyOverlay() + }; + + footer.AddButton(modsButton = new FooterButtonModsV2(), overlay); + footer.AddButton(randomButton = new FooterButtonRandomV2 + { + NextRandom = () => nextRandomCalled = true, + PreviousRandom = () => previousRandomCalled = true + }); + footer.AddButton(new FooterButtonOptionsV2()); + + overlay.Hide(); + }); + + [Test] + public void TestState() + { + AddToggleStep("set options enabled state", state => this.ChildrenOfType().Last().Enabled.Value = state); + } + + [Test] + public void TestFooterRandom() + { + AddStep("press F2", () => InputManager.Key(Key.F2)); + AddAssert("next random invoked", () => nextRandomCalled && !previousRandomCalled); + } + + [Test] + public void TestFooterRandomViaMouse() + { + AddStep("click button", () => + { + InputManager.MoveMouseTo(randomButton); + InputManager.Click(MouseButton.Left); + }); + AddAssert("next random invoked", () => nextRandomCalled && !previousRandomCalled); + } + + [Test] + public void TestFooterRewind() + { + AddStep("press Shift+F2", () => + { + InputManager.PressKey(Key.LShift); + InputManager.PressKey(Key.F2); + InputManager.ReleaseKey(Key.F2); + InputManager.ReleaseKey(Key.LShift); + }); + AddAssert("previous random invoked", () => previousRandomCalled && !nextRandomCalled); + } + + [Test] + public void TestFooterRewindViaShiftMouseLeft() + { + AddStep("shift + click button", () => + { + InputManager.PressKey(Key.LShift); + InputManager.MoveMouseTo(randomButton); + InputManager.Click(MouseButton.Left); + InputManager.ReleaseKey(Key.LShift); + }); + AddAssert("previous random invoked", () => previousRandomCalled && !nextRandomCalled); + } + + [Test] + public void TestFooterRewindViaMouseRight() + { + AddStep("right click button", () => + { + InputManager.MoveMouseTo(randomButton); + InputManager.Click(MouseButton.Right); + }); + AddAssert("previous random invoked", () => previousRandomCalled && !nextRandomCalled); + } + + [Test] + public void TestOverlayPresent() + { + AddStep("Press F1", () => + { + InputManager.MoveMouseTo(modsButton); + InputManager.Click(MouseButton.Left); + }); + AddAssert("Overlay visible", () => overlay.State.Value == Visibility.Visible); + AddStep("Hide", () => overlay.Hide()); + } + + private partial class DummyOverlay : ShearedOverlayContainer + { + public DummyOverlay() + : base(OverlayColourScheme.Green) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Header.Title = "An overlay"; + } + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneTopLocalRank.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneTopLocalRank.cs index 7bef7c8fce..cf0de14541 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneTopLocalRank.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneTopLocalRank.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Diagnostics; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -11,6 +12,7 @@ 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; @@ -18,19 +20,19 @@ using osuTK; namespace osu.Game.Tests.Visual.SongSelect { - public class TestSceneTopLocalRank : OsuTestScene + public partial class TestSceneTopLocalRank : OsuTestScene { - private RulesetStore rulesets; - private BeatmapManager beatmapManager; - private ScoreManager scoreManager; - private TopLocalRank topLocalRank; + private RulesetStore rulesets = null!; + private BeatmapManager beatmapManager = null!; + private ScoreManager scoreManager = null!; + private TopLocalRank topLocalRank = null!; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); - Dependencies.Cache(scoreManager = new ScoreManager(rulesets, () => beatmapManager, LocalStorage, Realm, Scheduler)); + Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(scoreManager = new ScoreManager(rulesets, () => beatmapManager, LocalStorage, Realm, API)); Dependencies.Cache(Realm); beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); @@ -45,21 +47,21 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("Create local rank", () => { - Add(topLocalRank = new TopLocalRank(importedBeatmap) + Child = topLocalRank = new TopLocalRank(importedBeatmap) { Anchor = Anchor.Centre, Origin = Anchor.Centre, Scale = new Vector2(10), - }); + }; }); + + AddAssert("No rank displayed initially", () => topLocalRank.DisplayedRank == null); } [Test] public void TestBasicImportDelete() { - ScoreInfo testScoreInfo = null; - - AddAssert("Initially not present", () => !topLocalRank.IsPresent); + ScoreInfo testScoreInfo = null!; AddStep("Add score for current user", () => { @@ -71,25 +73,19 @@ namespace osu.Game.Tests.Visual.SongSelect scoreManager.Import(testScoreInfo); }); - AddUntilStep("Became present", () => topLocalRank.IsPresent); - AddAssert("Correct rank", () => topLocalRank.Rank == ScoreRank.B); + AddUntilStep("B rank displayed", () => topLocalRank.DisplayedRank == ScoreRank.B); - AddStep("Delete score", () => - { - scoreManager.Delete(testScoreInfo); - }); + AddStep("Delete score", () => scoreManager.Delete(testScoreInfo)); - AddUntilStep("Became not present", () => !topLocalRank.IsPresent); + AddUntilStep("No rank displayed", () => topLocalRank.DisplayedRank == null); } [Test] public void TestRulesetChange() { - ScoreInfo testScoreInfo; - AddStep("Add score for current user", () => { - testScoreInfo = TestResources.CreateTestScoreInfo(importedBeatmap); + var testScoreInfo = TestResources.CreateTestScoreInfo(importedBeatmap); testScoreInfo.User = API.LocalUser.Value; testScoreInfo.Rank = ScoreRank.B; @@ -97,25 +93,21 @@ namespace osu.Game.Tests.Visual.SongSelect scoreManager.Import(testScoreInfo); }); - AddUntilStep("Wait for initial presence", () => topLocalRank.IsPresent); + AddUntilStep("Wait for initial display", () => topLocalRank.DisplayedRank == ScoreRank.B); AddStep("Change ruleset", () => Ruleset.Value = rulesets.GetRuleset("fruits")); - AddUntilStep("Became not present", () => !topLocalRank.IsPresent); + AddUntilStep("No rank displayed", () => topLocalRank.DisplayedRank == null); AddStep("Change ruleset back", () => Ruleset.Value = rulesets.GetRuleset("osu")); - AddUntilStep("Became present", () => topLocalRank.IsPresent); + AddUntilStep("B rank displayed", () => topLocalRank.DisplayedRank == ScoreRank.B); } [Test] public void TestHigherScoreSet() { - ScoreInfo testScoreInfo = null; - - AddAssert("Initially not present", () => !topLocalRank.IsPresent); - AddStep("Add score for current user", () => { - testScoreInfo = TestResources.CreateTestScoreInfo(importedBeatmap); + var testScoreInfo = TestResources.CreateTestScoreInfo(importedBeatmap); testScoreInfo.User = API.LocalUser.Value; testScoreInfo.Rank = ScoreRank.B; @@ -123,21 +115,58 @@ namespace osu.Game.Tests.Visual.SongSelect scoreManager.Import(testScoreInfo); }); - AddUntilStep("Became present", () => topLocalRank.IsPresent); - AddUntilStep("Correct rank", () => topLocalRank.Rank == ScoreRank.B); + AddUntilStep("B rank displayed", () => topLocalRank.DisplayedRank == ScoreRank.B); AddStep("Add higher score for current user", () => { var testScoreInfo2 = TestResources.CreateTestScoreInfo(importedBeatmap); testScoreInfo2.User = API.LocalUser.Value; - testScoreInfo2.Rank = ScoreRank.S; - testScoreInfo2.TotalScore = testScoreInfo.TotalScore + 1; + testScoreInfo2.Rank = ScoreRank.X; + testScoreInfo2.TotalScore = 1000000; + testScoreInfo2.Statistics = testScoreInfo2.MaximumStatistics; scoreManager.Import(testScoreInfo2); }); - AddUntilStep("Correct rank", () => topLocalRank.Rank == ScoreRank.S); + AddUntilStep("SS rank displayed", () => topLocalRank.DisplayedRank == ScoreRank.X); + } + + [Test] + public void TestLegacyScore() + { + ScoreInfo testScoreInfo = null!; + + AddStep("Add legacy score for current user", () => + { + testScoreInfo = TestResources.CreateTestScoreInfo(importedBeatmap); + + 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", () => + { + 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); + + scoreManager.Import(testScoreInfo2); + }); + + AddUntilStep("SS rank displayed", () => topLocalRank.DisplayedRank == ScoreRank.X); } } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs new file mode 100644 index 0000000000..11d55bc0bd --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs @@ -0,0 +1,207 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; +using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Carousel; +using osu.Game.Tests.Online; +using osu.Game.Tests.Resources; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.SongSelect +{ + [TestFixture] + public partial class TestSceneUpdateBeatmapSetButton : OsuManualInputManagerTestScene + { + private BeatmapCarousel carousel = null!; + + private TestSceneOnlinePlayBeatmapAvailabilityTracker.TestBeatmapModelDownloader beatmapDownloader = null!; + + private BeatmapSetInfo testBeatmapSetInfo = null!; + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + var importer = parent.Get(); + + dependencies.CacheAs(beatmapDownloader = new TestSceneOnlinePlayBeatmapAvailabilityTracker.TestBeatmapModelDownloader(importer, API)); + return dependencies; + } + + private UpdateBeatmapSetButton? getUpdateButton() => carousel.ChildrenOfType().SingleOrDefault(); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create carousel", () => Child = createCarousel()); + + AddUntilStep("wait for load", () => carousel.BeatmapSetsLoaded); + + AddAssert("update button not visible", () => getUpdateButton() == null); + } + + [Test] + public void TestDownloadToCompletion() + { + ArchiveDownloadRequest? downloadRequest = null; + + AddStep("update online hash", () => + { + testBeatmapSetInfo.Beatmaps.First().OnlineMD5Hash = "different hash"; + testBeatmapSetInfo.Beatmaps.First().LastOnlineUpdate = DateTimeOffset.Now; + + carousel.UpdateBeatmapSet(testBeatmapSetInfo); + }); + + AddUntilStep("only one set visible", () => carousel.ChildrenOfType().Count() == 1); + AddUntilStep("update button visible", () => getUpdateButton() != null); + + AddStep("click button", () => getUpdateButton()?.TriggerClick()); + + AddUntilStep("wait for download started", () => + { + downloadRequest = beatmapDownloader.GetExistingDownload(testBeatmapSetInfo); + return downloadRequest != null; + }); + + AddUntilStep("wait for button disabled", () => getUpdateButton()?.Enabled.Value == false); + + AddUntilStep("progress download to completion", () => + { + if (downloadRequest is TestSceneOnlinePlayBeatmapAvailabilityTracker.TestDownloadRequest testRequest) + { + testRequest.SetProgress(testRequest.Progress + 0.1f); + + if (testRequest.Progress >= 1) + { + testRequest.TriggerSuccess(); + + // usually this would be done by the import process. + testBeatmapSetInfo.Beatmaps.First().MD5Hash = "different hash"; + testBeatmapSetInfo.Beatmaps.First().LastOnlineUpdate = DateTimeOffset.Now; + + // usually this would be done by a realm subscription. + carousel.UpdateBeatmapSet(testBeatmapSetInfo); + return true; + } + } + + return false; + }); + } + + [Test] + public void TestDownloadFailed() + { + ArchiveDownloadRequest? downloadRequest = null; + + AddStep("update online hash", () => + { + testBeatmapSetInfo.Beatmaps.First().OnlineMD5Hash = "different hash"; + testBeatmapSetInfo.Beatmaps.First().LastOnlineUpdate = DateTimeOffset.Now; + + carousel.UpdateBeatmapSet(testBeatmapSetInfo); + }); + + AddUntilStep("only one set visible", () => carousel.ChildrenOfType().Count() == 1); + AddUntilStep("update button visible", () => getUpdateButton() != null); + + AddStep("click button", () => getUpdateButton()?.TriggerClick()); + + AddUntilStep("wait for download started", () => + { + downloadRequest = beatmapDownloader.GetExistingDownload(testBeatmapSetInfo); + return downloadRequest != null; + }); + + AddUntilStep("wait for button disabled", () => getUpdateButton()?.Enabled.Value == false); + + AddUntilStep("progress download to failure", () => + { + if (downloadRequest is TestSceneOnlinePlayBeatmapAvailabilityTracker.TestDownloadRequest testRequest) + { + testRequest.SetProgress(testRequest.Progress + 0.1f); + + if (testRequest.Progress >= 0.5f) + { + testRequest.TriggerFailure(new InvalidOperationException()); + return true; + } + } + + return false; + }); + + AddUntilStep("wait for button enabled", () => getUpdateButton()?.Enabled.Value == true); + } + + [Test] + public void TestUpdateLocalBeatmap() + { + DialogOverlay dialogOverlay = null!; + UpdateBeatmapSetButton? updateButton = null; + + AddStep("create carousel with dialog overlay", () => + { + dialogOverlay = new DialogOverlay(); + + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] { (typeof(IDialogOverlay), dialogOverlay), }, + Children = new Drawable[] + { + createCarousel(), + dialogOverlay, + }, + }; + }); + + AddStep("setup beatmap state", () => + { + testBeatmapSetInfo.Beatmaps.First().OnlineMD5Hash = "different hash"; + testBeatmapSetInfo.Beatmaps.First().LastOnlineUpdate = DateTimeOffset.Now; + testBeatmapSetInfo.Status = BeatmapOnlineStatus.LocallyModified; + + carousel.UpdateBeatmapSet(testBeatmapSetInfo); + }); + + AddUntilStep("wait for update button", () => (updateButton = getUpdateButton()) != null); + AddStep("click button", () => updateButton.AsNonNull().TriggerClick()); + + AddAssert("dialog displayed", () => dialogOverlay.CurrentDialog is UpdateLocalConfirmationDialog); + AddStep("click confirmation", () => + { + InputManager.MoveMouseTo(dialogOverlay.CurrentDialog.ChildrenOfType().First()); + InputManager.PressButton(MouseButton.Left); + }); + + AddUntilStep("update started", () => beatmapDownloader.GetExistingDownload(testBeatmapSetInfo) != null); + AddStep("release mouse button", () => InputManager.ReleaseButton(MouseButton.Left)); + } + + private BeatmapCarousel createCarousel() + { + return carousel = new BeatmapCarousel + { + RelativeSizeAxes = Axes.Both, + BeatmapSets = new List + { + (testBeatmapSetInfo = TestResources.CreateTestBeatmapSetInfo()), + } + }; + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneUserTopScoreContainer.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneUserTopScoreContainer.cs index c71e54e9a8..0476198e41 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneUserTopScoreContainer.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneUserTopScoreContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -17,7 +19,7 @@ using osu.Game.Users; namespace osu.Game.Tests.Visual.SongSelect { - public class TestSceneUserTopScoreContainer : OsuTestScene + public partial class TestSceneUserTopScoreContainer : OsuTestScene { [Cached(typeof(IDialogOverlay))] private readonly DialogOverlay dialogOverlay; @@ -67,11 +69,7 @@ namespace osu.Game.Tests.Visual.SongSelect { Id = 6602580, Username = @"waaiiru", - Country = new Country - { - FullName = @"Spain", - FlagName = @"ES", - }, + CountryCode = CountryCode.ES, }, }, new ScoreInfo @@ -86,11 +84,7 @@ namespace osu.Game.Tests.Visual.SongSelect { Id = 4608074, Username = @"Skycries", - Country = new Country - { - FullName = @"Brazil", - FlagName = @"BR", - }, + CountryCode = CountryCode.BR, }, }, new ScoreInfo @@ -105,11 +99,7 @@ namespace osu.Game.Tests.Visual.SongSelect { Id = 1541390, Username = @"Toukai", - Country = new Country - { - FullName = @"Canada", - FlagName = @"CA", - }, + CountryCode = CountryCode.CA, }, } }; diff --git a/osu.Game.Tests/Visual/TestMultiplayerComponents.cs b/osu.Game.Tests/Visual/TestMultiplayerComponents.cs index 4ab201ef46..1814fb70c8 100644 --- a/osu.Game.Tests/Visual/TestMultiplayerComponents.cs +++ b/osu.Game.Tests/Visual/TestMultiplayerComponents.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Screens; @@ -28,7 +30,7 @@ namespace osu.Game.Tests.Visual /// ///

///
- public class TestMultiplayerComponents : OsuScreen + public partial class TestMultiplayerComponents : OsuScreen { public Screens.OnlinePlay.Multiplayer.Multiplayer MultiplayerScreen => multiplayerScreen; @@ -89,7 +91,7 @@ namespace osu.Game.Tests.Visual return true; } - private class TestMultiplayer : Screens.OnlinePlay.Multiplayer.Multiplayer + private partial class TestMultiplayer : Screens.OnlinePlay.Multiplayer.Multiplayer { public new TestMultiplayerRoomManager RoomManager { get; private set; } public TestRoomRequestsHandler RequestsHandler { get; private set; } diff --git a/osu.Game.Tests/Visual/TestSceneOsuScreenStack.cs b/osu.Game.Tests/Visual/TestSceneOsuScreenStack.cs index 7729ad0ff3..7f01a67903 100644 --- a/osu.Game.Tests/Visual/TestSceneOsuScreenStack.cs +++ b/osu.Game.Tests/Visual/TestSceneOsuScreenStack.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -14,7 +16,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual { [TestFixture] - public class TestSceneOsuScreenStack : OsuTestScene + public partial class TestSceneOsuScreenStack : OsuTestScene { private TestOsuScreenStack stack; @@ -88,7 +90,7 @@ namespace osu.Game.Tests.Visual AddAssert("allows adjustments 11", () => musicController.AllowTrackAdjustments); } - public class TestScreen : ScreenWithBeatmapBackground + public partial class TestScreen : ScreenWithBeatmapBackground { private readonly string screenText; @@ -110,7 +112,7 @@ namespace osu.Game.Tests.Visual } } - private class NoParallaxTestScreen : TestScreen + private partial class NoParallaxTestScreen : TestScreen { public NoParallaxTestScreen(string screenText) : base(screenText) @@ -120,22 +122,22 @@ namespace osu.Game.Tests.Visual public override float BackgroundParallaxAmount => 0.0f; } - private class TestOsuScreenStack : OsuScreenStack + private partial class TestOsuScreenStack : OsuScreenStack { public new float ParallaxAmount => base.ParallaxAmount; } - private class AllowScreen : OsuScreen + private partial class AllowScreen : OsuScreen { public override bool? AllowTrackAdjustments => true; } - public class DisallowScreen : OsuScreen + public partial class DisallowScreen : OsuScreen { public override bool? AllowTrackAdjustments => false; } - private class InheritScreen : OsuScreen + private partial class InheritScreen : OsuScreen { } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBackButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBackButton.cs index 2440911c11..837de60053 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBackButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBackButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -10,7 +12,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneBackButton : OsuTestScene + public partial class TestSceneBackButton : OsuTestScene { public TestSceneBackButton() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs index 3cbb7daf51..5d97714ab5 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; @@ -24,7 +26,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneBeatSyncedContainer : OsuTestScene + public partial class TestSceneBeatSyncedContainer : OsuTestScene { private TestBeatSyncedContainer beatContainer; @@ -68,7 +70,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("Set time before zero", () => { - beatContainer.NewBeat = (i, timingControlPoint, effectControlPoint, channelAmplitudes) => + beatContainer.NewBeat = (i, timingControlPoint, _, _) => { lastActuationTime = gameplayClockContainer.CurrentTime; lastTimingPoint = timingControlPoint; @@ -103,7 +105,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("Set time before zero", () => { - beatContainer.NewBeat = (i, timingControlPoint, effectControlPoint, channelAmplitudes) => + beatContainer.NewBeat = (i, timingControlPoint, _, _) => { lastBeatIndex = i; lastBpm = timingControlPoint.BPM; @@ -124,7 +126,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("bind event", () => { - beatContainer.NewBeat = (i, timingControlPoint, effectControlPoint, channelAmplitudes) => lastBpm = timingControlPoint.BPM; + beatContainer.NewBeat = (_, timingControlPoint, _, _) => lastBpm = timingControlPoint.BPM; }); AddUntilStep("wait for trigger", () => lastBpm != null); @@ -155,7 +157,7 @@ namespace osu.Game.Tests.Visual.UserInterface actualEffectPoint = null; beatContainer.AllowMistimedEventFiring = false; - beatContainer.NewBeat = (i, timingControlPoint, effectControlPoint, channelAmplitudes) => + beatContainer.NewBeat = (_, _, effectControlPoint, _) => { if (Precision.AlmostEquals(gameplayClockContainer.CurrentTime + earlyActivationMilliseconds, expectedEffectPoint.Time, BeatSyncedContainer.MISTIMED_ALLOWANCE)) actualEffectPoint = effectControlPoint; @@ -169,7 +171,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("effect has kiai", () => actualEffectPoint != null && ((EffectControlPoint)actualEffectPoint).KiaiMode); } - private class TestBeatSyncedContainer : BeatSyncedContainer + private partial class TestBeatSyncedContainer : BeatSyncedContainer { private const int flash_layer_height = 150; @@ -267,7 +269,7 @@ namespace osu.Game.Tests.Visual.UserInterface private TimingControlPoint getNextTimingPoint(TimingControlPoint current) { - if (timingPoints[^1] == current) + if (ReferenceEquals(timingPoints[^1], current)) return current; int index = timingPoints.IndexOf(current); // -1 means that this is a "default beat" @@ -279,7 +281,7 @@ namespace osu.Game.Tests.Visual.UserInterface { if (timingPoints.Count == 0) return 0; - if (timingPoints[^1] == current) + if (ReferenceEquals(timingPoints[^1], current)) { Debug.Assert(BeatSyncSource.Clock != null); @@ -319,7 +321,7 @@ namespace osu.Game.Tests.Visual.UserInterface } } - private class InfoString : FillFlowContainer + private partial class InfoString : FillFlowContainer { private const int text_size = 20; private const int margin = 7; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingCardSizeTabControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingCardSizeTabControl.cs index e3d47f08c6..f93c9a3d5d 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingCardSizeTabControl.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingCardSizeTabControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -14,7 +16,7 @@ using osu.Game.Overlays.BeatmapListing; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneBeatmapListingCardSizeTabControl : OsuTestScene + public partial class TestSceneBeatmapListingCardSizeTabControl : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs index f17de75f5c..0aae182db4 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs @@ -1,14 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; -using Humanizer; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Graphics.Sprites; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; @@ -17,7 +19,7 @@ using osuTK; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneBeatmapListingSearchControl : OsuTestScene + public partial class TestSceneBeatmapListingSearchControl : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); @@ -75,13 +77,13 @@ namespace osu.Game.Tests.Visual.UserInterface }; control.Query.BindValueChanged(q => query.Text = $"Query: {q.NewValue}", true); - control.General.BindCollectionChanged((u, v) => general.Text = $"General: {(control.General.Any() ? string.Join('.', control.General.Select(i => i.ToString().Underscore())) : "")}", true); + control.General.BindCollectionChanged((_, _) => general.Text = $"General: {(control.General.Any() ? string.Join('.', control.General.Select(i => i.ToString().ToSnakeCase())) : "")}", true); control.Ruleset.BindValueChanged(r => ruleset.Text = $"Ruleset: {r.NewValue}", true); control.Category.BindValueChanged(c => category.Text = $"Category: {c.NewValue}", true); control.Genre.BindValueChanged(g => genre.Text = $"Genre: {g.NewValue}", true); control.Language.BindValueChanged(l => language.Text = $"Language: {l.NewValue}", true); - control.Extra.BindCollectionChanged((u, v) => extra.Text = $"Extra: {(control.Extra.Any() ? string.Join('.', control.Extra.Select(i => i.ToString().ToLowerInvariant())) : "")}", true); - control.Ranks.BindCollectionChanged((u, v) => ranks.Text = $"Ranks: {(control.Ranks.Any() ? string.Join('.', control.Ranks.Select(i => i.ToString())) : "")}", true); + control.Extra.BindCollectionChanged((_, _) => extra.Text = $"Extra: {(control.Extra.Any() ? string.Join('.', control.Extra.Select(i => i.ToString().ToLowerInvariant())) : "")}", true); + control.Ranks.BindCollectionChanged((_, _) => ranks.Text = $"Ranks: {(control.Ranks.Any() ? string.Join('.', control.Ranks.Select(i => i.ToString())) : "")}", true); control.Played.BindValueChanged(p => played.Text = $"Played: {p.NewValue}", true); control.ExplicitContent.BindValueChanged(e => explicitMap.Text = $"Explicit Maps: {e.NewValue}", true); }); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSortTabControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSortTabControl.cs index 5364f0bef5..dd7bf48791 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSortTabControl.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSortTabControl.cs @@ -1,24 +1,32 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System.Linq; +using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Testing; 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 class TestSceneBeatmapListingSortTabControl : OsuTestScene + public partial class TestSceneBeatmapListingSortTabControl : OsuManualInputManagerTestScene { + private readonly BeatmapListingSortTabControl control; + [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); public TestSceneBeatmapListingSortTabControl() { - BeatmapListingSortTabControl control; OsuSpriteText current; OsuSpriteText direction; @@ -43,5 +51,106 @@ namespace osu.Game.Tests.Visual.UserInterface control.SortDirection.BindValueChanged(sortDirection => direction.Text = $"Sort direction: {sortDirection.NewValue}", true); control.Current.BindValueChanged(criteria => current.Text = $"Criteria: {criteria.NewValue}", true); } + + [Test] + public void TestRankedSort() + { + criteriaShowsOnCategory(true, SortCriteria.Ranked, SearchCategory.Any); + criteriaShowsOnCategory(true, SortCriteria.Ranked, SearchCategory.Leaderboard); + criteriaShowsOnCategory(true, SortCriteria.Ranked, SearchCategory.Ranked); + criteriaShowsOnCategory(true, SortCriteria.Ranked, SearchCategory.Qualified); + criteriaShowsOnCategory(true, SortCriteria.Ranked, SearchCategory.Loved); + criteriaShowsOnCategory(true, SortCriteria.Ranked, SearchCategory.Favourites); + criteriaShowsOnCategory(false, SortCriteria.Ranked, SearchCategory.Pending); + criteriaShowsOnCategory(false, SortCriteria.Ranked, SearchCategory.Wip); + criteriaShowsOnCategory(false, SortCriteria.Ranked, SearchCategory.Graveyard); + criteriaShowsOnCategory(true, SortCriteria.Ranked, SearchCategory.Mine); + } + + [Test] + public void TestUpdatedSort() + { + criteriaShowsOnCategory(true, SortCriteria.Updated, SearchCategory.Any); + criteriaShowsOnCategory(false, SortCriteria.Updated, SearchCategory.Leaderboard); + criteriaShowsOnCategory(false, SortCriteria.Updated, SearchCategory.Ranked); + criteriaShowsOnCategory(false, SortCriteria.Updated, SearchCategory.Qualified); + criteriaShowsOnCategory(false, SortCriteria.Updated, SearchCategory.Loved); + criteriaShowsOnCategory(true, SortCriteria.Updated, SearchCategory.Favourites); + criteriaShowsOnCategory(true, SortCriteria.Updated, SearchCategory.Pending); + criteriaShowsOnCategory(true, SortCriteria.Updated, SearchCategory.Wip); + criteriaShowsOnCategory(true, SortCriteria.Updated, SearchCategory.Graveyard); + criteriaShowsOnCategory(true, SortCriteria.Updated, SearchCategory.Mine); + } + + [Test] + public void TestNominationsSort() + { + criteriaShowsOnCategory(false, SortCriteria.Nominations, SearchCategory.Any); + criteriaShowsOnCategory(false, SortCriteria.Nominations, SearchCategory.Leaderboard); + criteriaShowsOnCategory(false, SortCriteria.Nominations, SearchCategory.Ranked); + criteriaShowsOnCategory(false, SortCriteria.Nominations, SearchCategory.Qualified); + criteriaShowsOnCategory(false, SortCriteria.Nominations, SearchCategory.Loved); + criteriaShowsOnCategory(false, SortCriteria.Nominations, SearchCategory.Favourites); + criteriaShowsOnCategory(true, SortCriteria.Nominations, SearchCategory.Pending); + criteriaShowsOnCategory(false, SortCriteria.Nominations, SearchCategory.Wip); + criteriaShowsOnCategory(false, SortCriteria.Nominations, SearchCategory.Graveyard); + criteriaShowsOnCategory(false, SortCriteria.Nominations, SearchCategory.Mine); + } + + [Test] + public void TestResetNoQuery() + { + resetUsesCriteriaOnCategory(SortCriteria.Ranked, SearchCategory.Any); + resetUsesCriteriaOnCategory(SortCriteria.Ranked, SearchCategory.Leaderboard); + resetUsesCriteriaOnCategory(SortCriteria.Ranked, SearchCategory.Ranked); + resetUsesCriteriaOnCategory(SortCriteria.Ranked, SearchCategory.Qualified); + resetUsesCriteriaOnCategory(SortCriteria.Ranked, SearchCategory.Loved); + resetUsesCriteriaOnCategory(SortCriteria.Ranked, SearchCategory.Favourites); + resetUsesCriteriaOnCategory(SortCriteria.Updated, SearchCategory.Pending); + resetUsesCriteriaOnCategory(SortCriteria.Updated, SearchCategory.Wip); + resetUsesCriteriaOnCategory(SortCriteria.Updated, SearchCategory.Graveyard); + 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()}", () => + { + control.Reset(category, false); + return control.ChildrenOfType>().Single().Items.Contains(criteria) == expected; + }); + } + + private void resetUsesCriteriaOnCategory(SortCriteria criteria, SearchCategory category) + { + AddAssert($"reset uses {criteria.ToString().ToLowerInvariant()} on {category.ToString().ToLowerInvariant()}", () => + { + control.Reset(category, false); + return control.Current.Value == criteria; + }); + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapSearchFilter.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapSearchFilter.cs index 37b7b64615..7f7ba6966b 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapSearchFilter.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapSearchFilter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osuTK; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneBeatmapSearchFilter : OsuTestScene + public partial class TestSceneBeatmapSearchFilter : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbControl.cs index 3967b62c95..eeb2d1e70f 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbControl.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -12,7 +14,7 @@ using osu.Game.Graphics.UserInterface; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneBreadcrumbControl : OsuTestScene + public partial class TestSceneBreadcrumbControl : OsuTestScene { private readonly TestBreadcrumbControl breadcrumbs; @@ -60,7 +62,7 @@ namespace osu.Game.Tests.Visual.UserInterface Circles, } - private class TestBreadcrumbControl : BreadcrumbControl + private partial class TestBreadcrumbControl : BreadcrumbControl { public BreadcrumbTabItem GetDrawable(BreadcrumbTab tab) => (BreadcrumbTabItem)TabContainer.First(t => t.Value == tab); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbControlHeader.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbControlHeader.cs index 7bc75f1c44..cc203b3043 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbControlHeader.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbControlHeader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -9,7 +11,7 @@ using osu.Game.Overlays; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneBreadcrumbControlHeader : OsuTestScene + public partial class TestSceneBreadcrumbControlHeader : OsuTestScene { private static readonly string[] items = { "first", "second", "third", "fourth", "fifth" }; @@ -46,7 +48,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep($"Remove {item} item", () => header.RemoveItem(item)); } - private class TestHeader : BreadcrumbControlOverlayHeader + private partial class TestHeader : BreadcrumbControlOverlayHeader { public TestHeader() { @@ -75,7 +77,7 @@ namespace osu.Game.Tests.Visual.UserInterface protected override OverlayTitle CreateTitle() => new TestTitle(); } - private class TestTitle : OverlayTitle + private partial class TestTitle : OverlayTitle { public TestTitle() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs index 1a879e2e70..ac811aeb65 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -15,7 +17,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneButtonSystem : OsuManualInputManagerTestScene + public partial class TestSceneButtonSystem : OsuManualInputManagerTestScene { private OsuLogo logo; private ButtonSystem buttons; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneButtonsInput.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneButtonsInput.cs new file mode 100644 index 0000000000..985f613b63 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneButtonsInput.cs @@ -0,0 +1,129 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Game.Overlays.Settings; +using NUnit.Framework; +using osuTK; +using osu.Game.Overlays; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Graphics.UserInterface; +using osu.Framework.Allocation; +using osu.Game.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osuTK.Graphics; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneButtonsInput : OsuManualInputManagerTestScene + { + private const int width = 500; + + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + + private readonly SettingsButton settingsButton; + private readonly OsuClickableContainer clickableContainer; + private readonly RoundedButton roundedButton; + private readonly ShearedButton shearedButton; + + public TestSceneButtonsInput() + { + Add(new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + Width = 500, + Spacing = new Vector2(0, 5), + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + clickableContainer = new OsuClickableContainer + { + RelativeSizeAxes = Axes.X, + Height = 40, + Enabled = { Value = true }, + Masking = true, + CornerRadius = 20, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Red + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Rounded clickable container" + } + } + }, + settingsButton = new SettingsButton + { + Enabled = { Value = true }, + Text = "Settings button" + }, + roundedButton = new RoundedButton + { + RelativeSizeAxes = Axes.X, + Enabled = { Value = true }, + Text = "Rounded button" + }, + shearedButton = new ShearedButton(width) + { + Text = "Sheared button", + LighterColour = Colour4.FromHex("#FFFFFF"), + DarkerColour = Colour4.FromHex("#FFCC22"), + TextColour = Colour4.Black, + Height = 40, + Enabled = { Value = true }, + Padding = new MarginPadding(0) + } + } + }); + } + + [Test] + public void TestSettingsButtonInput() + { + AddStep("Move cursor to button", () => InputManager.MoveMouseTo(settingsButton)); + AddAssert("Button is hovered", () => settingsButton.IsHovered); + AddStep("Move cursor to padded area", () => InputManager.MoveMouseTo(settingsButton.ScreenSpaceDrawQuad.TopLeft + new Vector2(SettingsPanel.CONTENT_MARGINS / 2f, 10))); + AddAssert("Cursor within a button", () => settingsButton.ScreenSpaceDrawQuad.Contains(InputManager.CurrentState.Mouse.Position)); + AddAssert("Button is not hovered", () => !settingsButton.IsHovered); + } + + [Test] + public void TestRoundedButtonInput() + { + AddStep("Move cursor to button", () => InputManager.MoveMouseTo(roundedButton)); + AddAssert("Button is hovered", () => roundedButton.IsHovered); + AddStep("Move cursor to corner", () => InputManager.MoveMouseTo(roundedButton.ScreenSpaceDrawQuad.TopLeft + Vector2.One)); + AddAssert("Cursor within a button", () => roundedButton.ScreenSpaceDrawQuad.Contains(InputManager.CurrentState.Mouse.Position)); + AddAssert("Button is not hovered", () => !roundedButton.IsHovered); + } + + [Test] + public void TestShearedButtonInput() + { + AddStep("Move cursor to button", () => InputManager.MoveMouseTo(shearedButton)); + AddAssert("Button is hovered", () => shearedButton.IsHovered); + AddStep("Move cursor to corner", () => InputManager.MoveMouseTo(shearedButton.ScreenSpaceDrawQuad.TopLeft + Vector2.One)); + AddAssert("Cursor within a button", () => shearedButton.ScreenSpaceDrawQuad.Contains(InputManager.CurrentState.Mouse.Position)); + AddAssert("Button is not hovered", () => !shearedButton.IsHovered); + } + + [Test] + public void TestRoundedClickableContainerInput() + { + AddStep("Move cursor to button", () => InputManager.MoveMouseTo(clickableContainer)); + AddAssert("Button is hovered", () => clickableContainer.IsHovered); + AddStep("Move cursor to corner", () => InputManager.MoveMouseTo(clickableContainer.ScreenSpaceDrawQuad.TopLeft + Vector2.One)); + AddAssert("Cursor within a button", () => clickableContainer.ScreenSpaceDrawQuad.Contains(InputManager.CurrentState.Mouse.Position)); + AddAssert("Button is not hovered", () => !clickableContainer.IsHovered); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneColourPicker.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneColourPicker.cs index fa9179443d..d2acf89dc8 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneColourPicker.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneColourPicker.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -13,7 +15,7 @@ using osu.Game.Overlays; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneColourPicker : OsuTestScene + public partial class TestSceneColourPicker : OsuTestScene { private readonly Bindable colour = new Bindable(Colour4.Aquamarine); @@ -77,7 +79,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("set red", () => colour.Value = Colour4.Red); } - private class ColourProvidingContainer : Container + private partial class ColourProvidingContainer : Container { [Cached] private OverlayColourProvider provider { get; } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs index 920b437f57..e7840d4a2a 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs @@ -1,11 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osu.Game.Overlays.Comments; using osuTK; @@ -13,13 +18,13 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneCommentEditor : OsuManualInputManagerTestScene + public partial class TestSceneCommentEditor : OsuManualInputManagerTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); - private TestCommentEditor commentEditor; - private TestCancellableCommentEditor cancellableCommentEditor; + private TestCommentEditor commentEditor = null!; + private TestCancellableCommentEditor cancellableCommentEditor = null!; [SetUp] public void SetUp() => Schedule(() => @@ -43,15 +48,16 @@ namespace osu.Game.Tests.Visual.UserInterface { AddStep("click on text box", () => { - InputManager.MoveMouseTo(commentEditor); + InputManager.MoveMouseTo(commentEditor.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); AddStep("enter text", () => commentEditor.Current.Value = "text"); AddStep("press Enter", () => InputManager.Key(Key.Enter)); + AddUntilStep("button is loading", () => commentEditor.IsSpinnerShown); AddAssert("text committed", () => commentEditor.CommittedText == "text"); - AddAssert("button is loading", () => commentEditor.IsLoading); + AddUntilStep("button is not loading", () => !commentEditor.IsSpinnerShown); } [Test] @@ -59,14 +65,14 @@ namespace osu.Game.Tests.Visual.UserInterface { AddStep("click on text box", () => { - InputManager.MoveMouseTo(commentEditor); + InputManager.MoveMouseTo(commentEditor.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); AddStep("press Enter", () => InputManager.Key(Key.Enter)); - AddAssert("no text committed", () => commentEditor.CommittedText == null); - AddAssert("button is not loading", () => !commentEditor.IsLoading); + AddAssert("button is not loading", () => !commentEditor.IsSpinnerShown); + AddAssert("no text committed", () => commentEditor.CommittedText.Length == 0); } [Test] @@ -74,7 +80,7 @@ namespace osu.Game.Tests.Visual.UserInterface { AddStep("click on text box", () => { - InputManager.MoveMouseTo(commentEditor); + InputManager.MoveMouseTo(commentEditor.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); AddStep("enter text", () => commentEditor.Current.Value = "some other text"); @@ -85,8 +91,9 @@ namespace osu.Game.Tests.Visual.UserInterface InputManager.Click(MouseButton.Left); }); + AddUntilStep("button is loading", () => commentEditor.IsSpinnerShown); AddAssert("text committed", () => commentEditor.CommittedText == "some other text"); - AddAssert("button is loading", () => commentEditor.IsLoading); + AddUntilStep("button is not loading", () => !commentEditor.IsSpinnerShown); } [Test] @@ -94,40 +101,39 @@ namespace osu.Game.Tests.Visual.UserInterface { AddStep("click cancel button", () => { - InputManager.MoveMouseTo(cancellableCommentEditor.ButtonsContainer); + InputManager.MoveMouseTo(cancellableCommentEditor.ButtonsContainer[1]); InputManager.Click(MouseButton.Left); }); AddAssert("cancel action fired", () => cancellableCommentEditor.Cancelled); } - private class TestCommentEditor : CommentEditor + private partial class TestCommentEditor : CommentEditor { public new Bindable Current => base.Current; public new FillFlowContainer ButtonsContainer => base.ButtonsContainer; - public string CommittedText { get; private set; } + public string CommittedText { get; private set; } = string.Empty; - public TestCommentEditor() - { - OnCommit = onCommit; - } + public bool IsSpinnerShown => this.ChildrenOfType().Single().IsPresent; - private void onCommit(string value) + protected override void OnCommit(string value) { + ShowLoadingSpinner = true; CommittedText = value; - Scheduler.AddDelayed(() => IsLoading = false, 1000); + Scheduler.AddDelayed(() => ShowLoadingSpinner = false, 1000); } - protected override string FooterText => @"Footer text. And it is pretty long. Cool."; - protected override string CommitButtonText => @"Commit"; - protected override string TextBoxPlaceholder => @"This text box is empty"; + protected override LocalisableString FooterText => @"Footer text. And it is pretty long. Cool."; + protected override LocalisableString CommitButtonText => @"Commit"; + protected override LocalisableString TextBoxPlaceholder => @"This text box is empty"; } - private class TestCancellableCommentEditor : CancellableCommentEditor + private partial class TestCancellableCommentEditor : CancellableCommentEditor { public new FillFlowContainer ButtonsContainer => base.ButtonsContainer; - protected override string FooterText => @"Wow, another one. Sicc"; + + protected override LocalisableString FooterText => @"Wow, another one. Sicc"; public bool Cancelled { get; private set; } @@ -136,8 +142,12 @@ namespace osu.Game.Tests.Visual.UserInterface OnCancel = () => Cancelled = true; } - protected override string CommitButtonText => @"Save"; - protected override string TextBoxPlaceholder => @"Multiline textboxes soon"; + protected override void OnCommit(string text) + { + } + + protected override LocalisableString CommitButtonText => @"Save"; + protected override LocalisableString TextBoxPlaceholder => @"Multiline textboxes soon"; } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneCommentRepliesButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentRepliesButton.cs index c2ac5179c9..1bfa389a25 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneCommentRepliesButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentRepliesButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Overlays.Comments.Buttons; using osu.Framework.Graphics; using osu.Framework.Allocation; @@ -14,7 +16,7 @@ using osu.Framework.Testing; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneCommentRepliesButton : OsuTestScene + public partial class TestSceneCommentRepliesButton : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); @@ -52,7 +54,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("Icon facing downwards", () => button.Icon.Scale.Y == 1); } - private class TestButton : CommentRepliesButton + private partial class TestButton : CommentRepliesButton { public SpriteIcon Icon => this.ChildrenOfType().First(); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneContextMenu.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneContextMenu.cs index 3b43f8485a..3491b7dbc1 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneContextMenu.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneContextMenu.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -17,7 +19,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneContextMenu : OsuTestScene + public partial class TestSceneContextMenu : OsuTestScene { private const int start_time = 0; private const int duration = 1000; @@ -111,12 +113,12 @@ namespace osu.Game.Tests.Visual.UserInterface }; } - private class MyContextMenuContainer : Container, IHasContextMenu + private partial class MyContextMenuContainer : Container, IHasContextMenu { public MenuItem[] ContextMenuItems => makeMenu(); } - private class AnotherContextMenuContainer : Container, IHasContextMenu + private partial class AnotherContextMenuContainer : Container, IHasContextMenu { public MenuItem[] ContextMenuItems { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneCursors.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneCursors.cs index 5b74852259..01d4eb83f3 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneCursors.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneCursors.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; @@ -13,18 +15,19 @@ using osu.Game.Graphics.Cursor; using osu.Game.Graphics.Sprites; using osuTK; using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneCursors : OsuManualInputManagerTestScene + public partial class TestSceneCursors : OsuManualInputManagerTestScene { - private readonly MenuCursorContainer menuCursorContainer; + private readonly GlobalCursorDisplay globalCursorDisplay; private readonly CustomCursorBox[] cursorBoxes = new CustomCursorBox[6]; public TestSceneCursors() { - Child = menuCursorContainer = new MenuCursorContainer + Child = globalCursorDisplay = new GlobalCursorDisplay { RelativeSizeAxes = Axes.Both, Children = new[] @@ -79,26 +82,25 @@ namespace osu.Game.Tests.Visual.UserInterface }; AddToggleStep("Smooth transitions", b => cursorBoxes.ForEach(box => box.SmoothTransition = b)); - - testUserCursor(); - testLocalCursor(); - testUserCursorOverride(); - testMultipleLocalCursors(); } + [SetUp] + public void SetUp() => Schedule(moveOut); + /// /// -- Green Box -- /// Tests whether hovering in and out of a drawable that provides the user cursor (green) /// results in the correct visibility state for that cursor. /// - private void testUserCursor() + [Test] + public void TestUserCursor() { AddStep("Move to green area", () => InputManager.MoveMouseTo(cursorBoxes[0])); AddAssert("Check green cursor visible", () => checkVisible(cursorBoxes[0].Cursor)); AddAssert("Check green cursor at mouse", () => checkAtMouse(cursorBoxes[0].Cursor)); AddStep("Move out", moveOut); AddAssert("Check green cursor invisible", () => !checkVisible(cursorBoxes[0].Cursor)); - AddAssert("Check global cursor visible", () => checkVisible(menuCursorContainer.Cursor)); + AddAssert("Check global cursor visible", () => checkVisible(globalCursorDisplay.MenuCursor)); } /// @@ -106,16 +108,17 @@ namespace osu.Game.Tests.Visual.UserInterface /// Tests whether hovering in and out of a drawable that provides a local cursor (purple) /// results in the correct visibility and state for that cursor. /// - private void testLocalCursor() + [Test] + public void TestLocalCursor() { AddStep("Move to purple area", () => InputManager.MoveMouseTo(cursorBoxes[3])); AddAssert("Check purple cursor visible", () => checkVisible(cursorBoxes[3].Cursor)); AddAssert("Check purple cursor at mouse", () => checkAtMouse(cursorBoxes[3].Cursor)); - AddAssert("Check global cursor visible", () => checkVisible(menuCursorContainer.Cursor)); - AddAssert("Check global cursor at mouse", () => checkAtMouse(menuCursorContainer.Cursor)); + AddAssert("Check global cursor visible", () => checkVisible(globalCursorDisplay.MenuCursor)); + AddAssert("Check global cursor at mouse", () => checkAtMouse(globalCursorDisplay.MenuCursor)); AddStep("Move out", moveOut); AddAssert("Check purple cursor visible", () => checkVisible(cursorBoxes[3].Cursor)); - AddAssert("Check global cursor visible", () => checkVisible(menuCursorContainer.Cursor)); + AddAssert("Check global cursor visible", () => checkVisible(globalCursorDisplay.MenuCursor)); } /// @@ -123,7 +126,8 @@ namespace osu.Game.Tests.Visual.UserInterface /// Tests whether overriding a user cursor (green) with another user cursor (blue) /// results in the correct visibility and states for the cursors. /// - private void testUserCursorOverride() + [Test] + public void TestUserCursorOverride() { AddStep("Move to blue-green boundary", () => InputManager.MoveMouseTo(cursorBoxes[1].ScreenSpaceDrawQuad.BottomRight - new Vector2(10))); AddAssert("Check blue cursor visible", () => checkVisible(cursorBoxes[1].Cursor)); @@ -138,7 +142,8 @@ namespace osu.Game.Tests.Visual.UserInterface /// -- Yellow-Purple Box Boundary -- /// Tests whether multiple local cursors (purple + yellow) may be visible and at the mouse position at the same time. ///
- private void testMultipleLocalCursors() + [Test] + public void TestMultipleLocalCursors() { AddStep("Move to yellow-purple boundary", () => InputManager.MoveMouseTo(cursorBoxes[5].ScreenSpaceDrawQuad.BottomRight - new Vector2(10))); AddAssert("Check purple cursor visible", () => checkVisible(cursorBoxes[3].Cursor)); @@ -154,9 +159,10 @@ namespace osu.Game.Tests.Visual.UserInterface /// -- Yellow-Blue Box Boundary -- /// Tests whether a local cursor (yellow) may be displayed along with a user cursor override (blue). ///
- private void testUserOverrideWithLocal() + [Test] + public void TestUserOverrideWithLocal() { - AddStep("Move to yellow-blue boundary", () => InputManager.MoveMouseTo(cursorBoxes[5].ScreenSpaceDrawQuad.TopRight - new Vector2(10))); + AddStep("Move to yellow-blue boundary", () => InputManager.MoveMouseTo(cursorBoxes[5].ScreenSpaceDrawQuad.TopRight - new Vector2(10, 0))); AddAssert("Check blue cursor visible", () => checkVisible(cursorBoxes[1].Cursor)); AddAssert("Check blue cursor at mouse", () => checkAtMouse(cursorBoxes[1].Cursor)); AddAssert("Check yellow cursor visible", () => checkVisible(cursorBoxes[5].Cursor)); @@ -166,6 +172,54 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("Check yellow cursor visible", () => checkVisible(cursorBoxes[5].Cursor)); } + /// + /// Ensures non-mouse input hides global cursor on a "local cursor" area (which doesn't hide global cursor). + /// + [Test] + public void TestKeyboardLocalCursor([Values] bool clickToShow) + { + AddStep("Enable cursor hiding", () => globalCursorDisplay.MenuCursor.HideCursorOnNonMouseInput = true); + AddStep("Move to purple area", () => InputManager.MoveMouseTo(cursorBoxes[3].ScreenSpaceDrawQuad.Centre + new Vector2(10, 0))); + AddAssert("Check purple cursor visible", () => checkVisible(cursorBoxes[3].Cursor)); + AddAssert("Check global cursor alpha is 1", () => globalCursorDisplay.MenuCursor.Alpha == 1); + + AddStep("Press key", () => InputManager.Key(Key.A)); + AddAssert("Check purple cursor still visible", () => checkVisible(cursorBoxes[3].Cursor)); + AddUntilStep("Check global cursor alpha is 0", () => globalCursorDisplay.MenuCursor.ActiveCursor.Alpha == 0); + + if (clickToShow) + AddStep("Click mouse", () => InputManager.Click(MouseButton.Left)); + else + AddStep("Move mouse", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + Vector2.One)); + + AddAssert("Check purple cursor still visible", () => checkVisible(cursorBoxes[3].Cursor)); + AddUntilStep("Check global cursor alpha is 1", () => globalCursorDisplay.MenuCursor.ActiveCursor.Alpha == 1); + } + + /// + /// Ensures mouse input after non-mouse input doesn't show global cursor on a "user cursor" area (which hides global cursor). + /// + [Test] + public void TestKeyboardUserCursor([Values] bool clickToShow) + { + AddStep("Enable cursor hiding", () => globalCursorDisplay.MenuCursor.HideCursorOnNonMouseInput = true); + AddStep("Move to green area", () => InputManager.MoveMouseTo(cursorBoxes[0])); + AddAssert("Check green cursor visible", () => checkVisible(cursorBoxes[0].Cursor)); + AddAssert("Check global cursor alpha is 0", () => !checkVisible(globalCursorDisplay.MenuCursor) && globalCursorDisplay.MenuCursor.ActiveCursor.Alpha == 0); + + AddStep("Press key", () => InputManager.Key(Key.A)); + AddAssert("Check green cursor still visible", () => checkVisible(cursorBoxes[0].Cursor)); + AddAssert("Check global cursor alpha is still 0", () => !checkVisible(globalCursorDisplay.MenuCursor) && globalCursorDisplay.MenuCursor.ActiveCursor.Alpha == 0); + + if (clickToShow) + AddStep("Click mouse", () => InputManager.Click(MouseButton.Left)); + else + AddStep("Move mouse", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + Vector2.One)); + + AddAssert("Check green cursor still visible", () => checkVisible(cursorBoxes[0].Cursor)); + AddAssert("Check global cursor alpha is still 0", () => !checkVisible(globalCursorDisplay.MenuCursor) && globalCursorDisplay.MenuCursor.ActiveCursor.Alpha == 0); + } + /// /// Moves the cursor to a point not covered by any cursor containers. /// @@ -185,7 +239,7 @@ namespace osu.Game.Tests.Visual.UserInterface private bool checkAtMouse(CursorContainer cursorContainer) => Precision.AlmostEquals(InputManager.CurrentState.Mouse.Position, cursorContainer.ToScreenSpace(cursorContainer.ActiveCursor.DrawPosition)); - private class CustomCursorBox : Container, IProvideCursor + private partial class CustomCursorBox : Container, IProvideCursor { public bool SmoothTransition; @@ -236,11 +290,11 @@ namespace osu.Game.Tests.Visual.UserInterface } } - private class TestCursorContainer : CursorContainer + private partial class TestCursorContainer : CursorContainer { protected override Drawable CreateCursor() => new TestCursor(); - private class TestCursor : CircularContainer + private partial class TestCursor : CircularContainer { public TestCursor() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDashboardBeatmapListing.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDashboardBeatmapListing.cs index 17ac812eb0..6092f35050 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDashboardBeatmapListing.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDashboardBeatmapListing.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Containers; using osu.Framework.Graphics; using osu.Game.Overlays.Dashboard.Home; @@ -15,7 +17,7 @@ using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneDashboardBeatmapListing : OsuTestScene + public partial class TestSceneDashboardBeatmapListing : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs index 1350052ae6..529874b71e 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -16,6 +18,7 @@ using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; +using osu.Game.Models; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Leaderboards; using osu.Game.Overlays; @@ -29,12 +32,11 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneDeleteLocalScore : OsuManualInputManagerTestScene + public partial class TestSceneDeleteLocalScore : OsuManualInputManagerTestScene { private readonly ContextMenuContainer contextMenuContainer; private readonly BeatmapLeaderboard leaderboard; - private RulesetStore rulesetStore; private BeatmapManager beatmapManager; private ScoreManager scoreManager; @@ -69,9 +71,9 @@ namespace osu.Game.Tests.Visual.UserInterface { var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - dependencies.Cache(rulesetStore = new RealmRulesetStore(Realm)); - dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, rulesetStore, null, dependencies.Get(), Resources, dependencies.Get(), Beatmap.Default)); - dependencies.Cache(scoreManager = new ScoreManager(dependencies.Get(), () => beatmapManager, LocalStorage, Realm, Scheduler)); + dependencies.Cache(new RealmRulesetStore(Realm)); + dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, dependencies.Get(), Resources, dependencies.Get(), Beatmap.Default)); + dependencies.Cache(scoreManager = new ScoreManager(dependencies.Get(), () => beatmapManager, LocalStorage, Realm, API)); Dependencies.Cache(Realm); return dependencies; @@ -92,12 +94,14 @@ namespace osu.Game.Tests.Visual.UserInterface { OnlineID = i, BeatmapInfo = beatmapInfo, + BeatmapHash = beatmapInfo.Hash, Accuracy = RNG.NextDouble(), TotalScore = RNG.Next(1, 1000000), MaxCombo = RNG.Next(1, 1000), Rank = ScoreRank.XH, User = new APIUser { Username = "TestUser" }, Ruleset = new OsuRuleset().RulesetInfo, + Files = { new RealmNamedFileUsage(new RealmFile { Hash = $"{i}" }, string.Empty) } }; importedScores.Add(scoreManager.Import(score).Value); @@ -160,7 +164,7 @@ namespace osu.Game.Tests.Visual.UserInterface InputManager.PressButton(MouseButton.Left); }); - AddUntilStep("wait for fetch", () => leaderboard.Scores != null); + AddUntilStep("wait for fetch", () => leaderboard.Scores.Any()); AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineID != scoreBeingDeleted.OnlineID)); // "Clean up" @@ -171,7 +175,7 @@ namespace osu.Game.Tests.Visual.UserInterface public void TestDeleteViaDatabase() { AddStep("delete top score", () => scoreManager.Delete(importedScores[0])); - AddUntilStep("wait for fetch", () => leaderboard.Scores != null); + AddUntilStep("wait for fetch", () => leaderboard.Scores.Any()); AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineID != importedScores[0].OnlineID)); } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs index 54cdeaf956..81b692004b 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Threading; using NUnit.Framework; @@ -13,7 +15,7 @@ using osu.Game.Overlays.Dialog; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneDialogOverlay : OsuTestScene + public partial class TestSceneDialogOverlay : OsuTestScene { private DialogOverlay overlay; @@ -112,7 +114,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("dialog displayed", () => overlay.CurrentDialog == dialog); } - public class SlowLoadingDialogOverlay : DialogOverlay + public partial class SlowLoadingDialogOverlay : DialogOverlay { public ManualResetEventSlim LoadEvent = new ManualResetEventSlim(); @@ -164,7 +166,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("dialog is not part of hierarchy", () => testDialog.Parent == null); } - private class TestPopupDialog : PopupDialog + private partial class TestPopupDialog : PopupDialog { } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDifficultyMultiplierDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDifficultyMultiplierDisplay.cs index cd84f8b380..890c7295b4 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDifficultyMultiplierDisplay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDifficultyMultiplierDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osu.Game.Overlays.Mods; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneDifficultyMultiplierDisplay : OsuTestScene + public partial class TestSceneDifficultyMultiplierDisplay : OsuTestScene { [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDrawableDate.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDrawableDate.cs index 19097f33bb..108ad8b7c1 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDrawableDate.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDrawableDate.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -11,7 +13,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneDrawableDate : OsuTestScene + public partial class TestSceneDrawableDate : OsuTestScene { public TestSceneDrawableDate() { @@ -34,7 +36,7 @@ namespace osu.Game.Tests.Visual.UserInterface }; } - private class PokeyDrawableDate : CompositeDrawable + private partial class PokeyDrawableDate : CompositeDrawable { public PokeyDrawableDate(DateTimeOffset date) { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneEditorSidebar.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneEditorSidebar.cs index f2f475e063..72dacb7558 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneEditorSidebar.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneEditorSidebar.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -14,7 +16,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneEditorSidebar : OsuTestScene + public partial class TestSceneEditorSidebar : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneExpandingBar.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneExpandingBar.cs index f92aae43d2..9d850c0fc5 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneExpandingBar.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneExpandingBar.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -9,7 +11,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneExpandingBar : OsuTestScene + public partial class TestSceneExpandingBar : OsuTestScene { public TestSceneExpandingBar() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneExpandingContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneExpandingContainer.cs index 2bb6e58448..3f4f86e424 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneExpandingContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneExpandingContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osuTK; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneExpandingContainer : OsuManualInputManagerTestScene + public partial class TestSceneExpandingContainer : OsuManualInputManagerTestScene { private TestExpandingContainer container; private SettingsToolboxGroup toolboxGroup; @@ -147,7 +149,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("container still expanded", () => container.Expanded.Value); } - private class TestExpandingContainer : ExpandingContainer + private partial class TestExpandingContainer : ExpandingContainer { public TestExpandingContainer() : base(120, 250) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFPSCounter.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFPSCounter.cs new file mode 100644 index 0000000000..a91e6e3350 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFPSCounter.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneFPSCounter : OsuTestScene + { + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create display", () => + { + Children = new Drawable[] + { + new Box + { + Colour = Color4.White, + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new FPSCounter(), + new FPSCounter { Scale = new Vector2(2) }, + new FPSCounter { Scale = new Vector2(4) }, + } + }, + }; + }); + } + + [Test] + public void TestBasic() + { + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenBehaviour.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenBehaviour.cs index 9747b5cc53..ec8ef0ad50 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenBehaviour.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenBehaviour.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Screens; using osu.Game.Overlays; @@ -8,7 +10,7 @@ using osu.Game.Overlays.FirstRunSetup; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneFirstRunScreenBehaviour : OsuManualInputManagerTestScene + public partial class TestSceneFirstRunScreenBehaviour : OsuManualInputManagerTestScene { [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenBundledBeatmaps.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenBundledBeatmaps.cs index 51065939f0..e9460e45d3 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenBundledBeatmaps.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenBundledBeatmaps.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Screens; using osu.Game.Overlays; @@ -8,7 +10,7 @@ using osu.Game.Overlays.FirstRunSetup; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneFirstRunScreenBundledBeatmaps : OsuManualInputManagerTestScene + public partial class TestSceneFirstRunScreenBundledBeatmaps : OsuManualInputManagerTestScene { [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenImportFromStable.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenImportFromStable.cs index 081b240795..e6fc889a70 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenImportFromStable.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenImportFromStable.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Threading; using System.Threading.Tasks; using Moq; @@ -15,7 +17,7 @@ using osu.Game.Overlays.FirstRunSetup; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneFirstRunScreenImportFromStable : OsuManualInputManagerTestScene + public partial class TestSceneFirstRunScreenImportFromStable : OsuManualInputManagerTestScene { [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenUIScale.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenUIScale.cs index 64ad4ff119..8ba94cf9ae 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenUIScale.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunScreenUIScale.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Screens; using osu.Game.Overlays; @@ -8,7 +10,7 @@ using osu.Game.Overlays.FirstRunSetup; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneFirstRunScreenUIScale : OsuManualInputManagerTestScene + public partial class TestSceneFirstRunScreenUIScale : OsuManualInputManagerTestScene { [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs index d09efdc925..77ed97e3ed 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFirstRunSetupOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -23,7 +25,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneFirstRunSetupOverlay : OsuManualInputManagerTestScene + public partial class TestSceneFirstRunSetupOverlay : OsuManualInputManagerTestScene { private FirstRunSetupOverlay overlay; @@ -52,7 +54,7 @@ namespace osu.Game.Tests.Visual.UserInterface notificationOverlay.Reset(); performer.Setup(g => g.PerformFromScreen(It.IsAny>(), It.IsAny>())) - .Callback((Action action, IEnumerable types) => action(null)); + .Callback((Action action, IEnumerable _) => action(null)); notificationOverlay.Setup(n => n.Post(It.IsAny())) .Callback((Notification n) => lastNotification = n); @@ -192,7 +194,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("notification arrived", () => notificationOverlay.Verify(n => n.Post(It.IsAny()), Times.Once)); - AddStep("run notification action", () => lastNotification.Activated()); + AddStep("run notification action", () => lastNotification.Activated?.Invoke()); AddAssert("overlay shown", () => overlay.State.Value == Visibility.Visible); AddAssert("is resumed", () => overlay.CurrentScreen is ScreenUIScale); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs index 0631059d1a..24b4060a42 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -12,7 +14,7 @@ using osu.Game.Screens.Select; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneFooterButtonMods : OsuTestScene + public partial class TestSceneFooterButtonMods : OsuTestScene { private readonly TestFooterButtonMods footerButtonMods; @@ -79,7 +81,7 @@ namespace osu.Game.Tests.Visual.UserInterface return expectedValue == footerButtonMods.MultiplierText.Current.Value; } - private class TestFooterButtonMods : FooterButtonMods + private partial class TestFooterButtonMods : FooterButtonMods { public new OsuSpriteText MultiplierText => base.MultiplierText; } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFriendsOnlineStatusControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFriendsOnlineStatusControl.cs index ea3cfbd497..c75c2a7877 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFriendsOnlineStatusControl.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFriendsOnlineStatusControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -12,7 +14,7 @@ using osu.Game.Overlays.Dashboard.Friends; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneFriendsOnlineStatusControl : OsuTestScene + public partial class TestSceneFriendsOnlineStatusControl : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneHistoryTextBox.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneHistoryTextBox.cs new file mode 100644 index 0000000000..ed59572cab --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneHistoryTextBox.cs @@ -0,0 +1,178 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + [TestFixture] + public partial class TestSceneHistoryTextBox : OsuManualInputManagerTestScene + { + private const string temp = "Temp message"; + + private int messageCounter; + + private HistoryTextBox box = null!; + private OsuSpriteText text = null!; + + [SetUp] + public void SetUp() + { + Schedule(() => + { + Children = new Drawable[] + { + box = new HistoryTextBox(5) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Width = 0.99f, + }, + text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Width = 0.99f, + Y = -box.Height, + Font = OsuFont.Default.With(size: 20), + } + }; + + box.OnCommit += (_, _) => + { + if (string.IsNullOrEmpty(box.Text)) + return; + + text.Text = $"{nameof(box.OnCommit)}: {box.Text}"; + box.Text = string.Empty; + box.TakeFocus(); + text.FadeOutFromOne(1000, Easing.InQuint); + }; + + messageCounter = 0; + + box.TakeFocus(); + }); + } + + [Test] + public void TestEmptyHistory() + { + AddStep("Set text", () => box.Text = temp); + + AddStep("Move down", () => InputManager.Key(Key.Down)); + AddAssert("Text is unchanged", () => box.Text == temp); + + AddStep("Move up", () => InputManager.Key(Key.Up)); + AddAssert("Text is unchanged", () => box.Text == temp); + } + + [Test] + public void TestPartialHistory() + { + addMessages(3); + AddStep("Set text", () => box.Text = temp); + + AddStep("Move down", () => InputManager.Key(Key.Down)); + AddAssert("Text is unchanged", () => box.Text == temp); + + AddRepeatStep("Move up", () => InputManager.Key(Key.Up), 3); + AddAssert("Same as 1st message", () => box.Text == "Message 1"); + + AddStep("Move up", () => InputManager.Key(Key.Up)); + AddAssert("Same as 1st message", () => box.Text == "Message 1"); + + AddStep("Move down", () => InputManager.Key(Key.Down)); + AddAssert("Same as 2nd message", () => box.Text == "Message 2"); + + AddRepeatStep("Move down", () => InputManager.Key(Key.Down), 2); + AddAssert("Temporary message restored", () => box.Text == temp); + + AddStep("Move down", () => InputManager.Key(Key.Down)); + AddAssert("Text is unchanged", () => box.Text == temp); + } + + [Test] + public void TestFullHistory() + { + addMessages(7); + AddStep("Set text", () => box.Text = temp); + + AddStep("Move down", () => InputManager.Key(Key.Down)); + AddAssert("Text is unchanged", () => box.Text == temp); + + AddRepeatStep("Move up", () => InputManager.Key(Key.Up), 5); + AddAssert("Same as 3rd message", () => box.Text == "Message 3"); + + AddStep("Move up", () => InputManager.Key(Key.Up)); + AddAssert("Same as 3rd message", () => box.Text == "Message 3"); + + AddRepeatStep("Move down", () => InputManager.Key(Key.Down), 4); + AddAssert("Same as 7th message", () => box.Text == "Message 7"); + + AddStep("Move down", () => InputManager.Key(Key.Down)); + AddAssert("Temporary message restored", () => box.Text == temp); + + AddStep("Move down", () => InputManager.Key(Key.Down)); + AddAssert("Text is unchanged", () => box.Text == temp); + } + + [Test] + public void TestChangedHistory() + { + addMessages(2); + AddStep("Set text", () => box.Text = temp); + AddStep("Move up", () => InputManager.Key(Key.Up)); + + AddStep("Change text", () => box.Text = "New message"); + AddStep("Move down", () => InputManager.Key(Key.Down)); + AddStep("Move up", () => InputManager.Key(Key.Up)); + AddAssert("Changes lost", () => box.Text == "Message 2"); + } + + [Test] + public void TestInputOnEdge() + { + addMessages(2); + AddStep("Set text", () => box.Text = temp); + + AddStep("Move down", () => InputManager.Key(Key.Down)); + AddAssert("Text unchanged", () => box.Text == temp); + + AddRepeatStep("Move up", () => InputManager.Key(Key.Up), 2); + AddAssert("Same as 1st message", () => box.Text == "Message 1"); + + AddStep("Move up", () => InputManager.Key(Key.Up)); + AddAssert("Text unchanged", () => box.Text == "Message 1"); + } + + [Test] + public void TestResetIndex() + { + addMessages(2); + + AddRepeatStep("Move Up", () => InputManager.Key(Key.Up), 2); + AddAssert("Same as 1st message", () => box.Text == "Message 1"); + + AddStep("Change text", () => box.Text = "New message"); + AddStep("Move Up", () => InputManager.Key(Key.Up)); + AddAssert("Same as previous message", () => box.Text == "Message 2"); + } + + private void addMessages(int count) + { + AddRepeatStep("Add messages", () => + { + box.Text = $"Message {++messageCounter}"; + InputManager.Key(Key.Enter); + }, count); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneHoldToConfirmOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneHoldToConfirmOverlay.cs index cea91d422e..801bef62c8 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneHoldToConfirmOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneHoldToConfirmOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -8,7 +10,7 @@ using osu.Game.Screens.Menu; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneHoldToConfirmOverlay : OsuTestScene + public partial class TestSceneHoldToConfirmOverlay : OsuTestScene { protected override double TimePerAction => 100; // required for the early exit test, since hold-to-confirm delay is 200ms @@ -57,7 +59,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("wait until fired again", () => overlay.Fired); } - private class TestHoldToConfirmOverlay : ExitConfirmOverlay + private partial class TestHoldToConfirmOverlay : ExitConfirmOverlay { public void Begin() => BeginConfirm(); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneIconButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneIconButton.cs index c80b3e6297..454fa7cd05 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneIconButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneIconButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -14,7 +16,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneIconButton : OsuTestScene + public partial class TestSceneIconButton : OsuTestScene { public TestSceneIconButton() { @@ -36,7 +38,7 @@ namespace osu.Game.Tests.Visual.UserInterface }; } - private class ColouredIconButton : IconButton + private partial class ColouredIconButton : IconButton { public ColouredIconButton() { @@ -45,7 +47,7 @@ namespace osu.Game.Tests.Visual.UserInterface } } - private class NamedIconButton : Container + private partial class NamedIconButton : Container { public NamedIconButton(string name, IconButton button) { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledColourPalette.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledColourPalette.cs index e1ea02ba67..91a60009dc 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledColourPalette.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledColourPalette.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -16,7 +18,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneLabelledColourPalette : OsuManualInputManagerTestScene + public partial class TestSceneLabelledColourPalette : OsuManualInputManagerTestScene { private LabelledColourPalette component; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledDrawable.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledDrawable.cs index d5dda6f6cd..0dffc9da51 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledDrawable.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledDrawable.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Extensions.IEnumerableExtensions; @@ -15,7 +17,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneLabelledDrawable : OsuTestScene + public partial class TestSceneLabelledDrawable : OsuTestScene { [TestCase(false)] [TestCase(true)] @@ -76,7 +78,7 @@ namespace osu.Game.Tests.Visual.UserInterface Origin = Anchor.Centre, Width = 500, AutoSizeAxes = Axes.Y, - Child = component = padded ? (LabelledDrawable)new PaddedLabelledDrawable() : new NonPaddedLabelledDrawable(), + Child = component = padded ? new PaddedLabelledDrawable() : new NonPaddedLabelledDrawable(), }; component.Label = "a sample component"; @@ -86,7 +88,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert($"description {(hasDescription ? "visible" : "hidden")}", () => component.ChildrenOfType().ElementAt(1).IsPresent == hasDescription); } - private class PaddedLabelledDrawable : LabelledDrawable + private partial class PaddedLabelledDrawable : LabelledDrawable { public PaddedLabelledDrawable() : base(true) @@ -102,7 +104,7 @@ namespace osu.Game.Tests.Visual.UserInterface }; } - private class NonPaddedLabelledDrawable : LabelledDrawable + private partial class NonPaddedLabelledDrawable : LabelledDrawable { public NonPaddedLabelledDrawable() : base(false) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledDropdown.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledDropdown.cs index a9aaefdf6c..a2cfae3c7f 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledDropdown.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledDropdown.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneLabelledDropdown : OsuTestScene + public partial class TestSceneLabelledDropdown : OsuTestScene { [Test] public void TestLabelledDropdown() diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSliderBar.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSliderBar.cs index 1b7f65f9a0..6181891e13 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSliderBar.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSliderBar.cs @@ -1,25 +1,52 @@ // 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 NUnit.Framework; 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.Testing; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Overlays; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneLabelledSliderBar : OsuTestScene + public partial class TestSceneLabelledSliderBar : OsuTestScene { - [TestCase(false)] - [TestCase(true)] - public void TestSliderBar(bool hasDescription) => createSliderBar(hasDescription); + [Test] + public void TestBasic() => createSliderBar(); - private void createSliderBar(bool hasDescription = false) + [Test] + public void TestDescription() + { + createSliderBar(); + AddStep("set description", () => this.ChildrenOfType>().ForEach(l => l.Description = "this text describes the component")); + } + + [Test] + public void TestSize() + { + createSliderBar(); + AddStep("set zero width", () => this.ChildrenOfType>().ForEach(l => l.ResizeWidthTo(0, 200, Easing.OutQuint))); + AddStep("set negative width", () => this.ChildrenOfType>().ForEach(l => l.ResizeWidthTo(-1, 200, Easing.OutQuint))); + AddStep("revert back", () => this.ChildrenOfType>().ForEach(l => l.ResizeWidthTo(1, 200, Easing.OutQuint))); + } + + [Test] + public void TestDisable() + { + createSliderBar(); + AddStep("set disabled", () => this.ChildrenOfType>().ForEach(l => l.Current.Disabled = true)); + AddStep("unset disabled", () => this.ChildrenOfType>().ForEach(l => l.Current.Disabled = false)); + } + + private void createSliderBar() { AddStep("create component", () => { @@ -36,6 +63,8 @@ namespace osu.Game.Tests.Visual.UserInterface { new LabelledSliderBar { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, Current = new BindableDouble(5) { MinValue = 0, @@ -43,7 +72,6 @@ namespace osu.Game.Tests.Visual.UserInterface Precision = 1, }, Label = "a sample component", - Description = hasDescription ? "this text describes the component" : string.Empty, }, }, }; @@ -52,10 +80,14 @@ namespace osu.Game.Tests.Visual.UserInterface { flow.Add(new OverlayColourContainer(colour) { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Child = new LabelledSliderBar { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, Current = new BindableDouble(5) { MinValue = 0, @@ -63,14 +95,13 @@ namespace osu.Game.Tests.Visual.UserInterface Precision = 1, }, Label = "a sample component", - Description = hasDescription ? "this text describes the component" : string.Empty, } }); } }); } - private class OverlayColourContainer : Container + private partial class OverlayColourContainer : Container { [Cached] private OverlayColourProvider colourProvider; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSwitchButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSwitchButton.cs index 903f1242b4..c4af47bd0f 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSwitchButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSwitchButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -8,7 +10,7 @@ using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneLabelledSwitchButton : OsuTestScene + public partial class TestSceneLabelledSwitchButton : OsuTestScene { [TestCase(false)] [TestCase(true)] diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledTextBox.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledTextBox.cs index c11ba0aa59..8046554819 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledTextBox.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledTextBox.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneLabelledTextBox : OsuTestScene + public partial class TestSceneLabelledTextBox : OsuTestScene { [TestCase(false)] [TestCase(true)] diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingLayer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingLayer.cs index d426723f0b..dc40ecde43 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingLayer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingLayer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -8,12 +10,13 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Utils; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; using osuTK; using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneLoadingLayer : OsuTestScene + public partial class TestSceneLoadingLayer : OsuTestScene { private TestLoadingLayer overlay; @@ -47,8 +50,8 @@ namespace osu.Game.Tests.Visual.UserInterface Children = new Drawable[] { new OsuSpriteText { Text = "Sample content" }, - new TriangleButton { Text = "can't puush me", Width = 200, }, - new TriangleButton { Text = "puush me", Width = 200, Action = () => { } }, + new RoundedButton { Text = "can't puush me", Width = 200, }, + new RoundedButton { Text = "puush me", Width = 200, Action = () => { } }, } }, overlay = new TestLoadingLayer(true), @@ -85,7 +88,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("hide", () => overlay.Hide()); } - private class TestLoadingLayer : LoadingLayer + private partial class TestLoadingLayer : LoadingLayer { public new Box BackgroundDimLayer => base.BackgroundDimLayer; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingSpinner.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingSpinner.cs index 47f5bdfe17..40e786592a 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingSpinner.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingSpinner.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics.UserInterface; @@ -8,7 +10,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneLoadingSpinner : OsuGridTestScene + public partial class TestSceneLoadingSpinner : OsuGridTestScene { public TestSceneLoadingSpinner() : base(2, 2) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLogoAnimation.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLogoAnimation.cs index 155d043bf9..f9d92aabc6 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLogoAnimation.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLogoAnimation.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osu.Game.Graphics.Sprites; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneLogoAnimation : OsuTestScene + public partial class TestSceneLogoAnimation : OsuTestScene { [BackgroundDependencyLoader] private void load(LargeTextureStore textures) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs index b46d35a84d..5926f07a11 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -16,7 +18,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneLogoTrackingContainer : OsuTestScene + public partial class TestSceneLogoTrackingContainer : OsuTestScene { private OsuLogo logo; private TestLogoTrackingContainer trackingContainer; @@ -257,7 +259,7 @@ namespace osu.Game.Tests.Visual.UserInterface private void removeFacade() { - trackingContainer.Remove(logoFacade); + trackingContainer.Remove(logoFacade, false); visualBox.Colour = Color4.White; moveLogoFacade(); } @@ -275,7 +277,7 @@ namespace osu.Game.Tests.Visual.UserInterface Schedule(moveLogoFacade); } - private class TestLogoTrackingContainer : LogoTrackingContainer + private partial class TestLogoTrackingContainer : LogoTrackingContainer { /// /// Check that the logo is tracking the position of the facade, with an acceptable precision lenience. diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModColumn.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModColumn.cs index 331509e10f..a11000214c 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModColumn.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModColumn.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 enable - using System; using System.Linq; using NUnit.Framework; @@ -11,9 +9,11 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osu.Game.Overlays.Mods; +using osu.Game.Overlays.Mods.Input; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Utils; @@ -22,11 +22,14 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneModColumn : OsuManualInputManagerTestScene + public partial class TestSceneModColumn : OsuManualInputManagerTestScene { [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + [Resolved] + private OsuConfigManager configManager { get; set; } = null!; + [TestCase(ModType.DifficultyReduction)] [TestCase(ModType.DifficultyIncrease)] [TestCase(ModType.Conversion)] @@ -134,14 +137,16 @@ namespace osu.Game.Tests.Visual.UserInterface } [Test] - public void TestKeyboardSelection() + public void TestSequentialKeyboardSelection() { + AddStep("set sequential hotkey mode", () => configManager.SetValue(OsuSetting.ModSelectHotkeyStyle, ModSelectHotkeyStyle.Sequential)); + ModColumn column = null!; AddStep("create content", () => Child = new Container { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding(30), - Child = column = new ModColumn(ModType.DifficultyReduction, true, new[] { Key.Q, Key.W, Key.E, Key.R, Key.T, Key.Y, Key.U, Key.I, Key.O, Key.P }) + Child = column = new ModColumn(ModType.DifficultyReduction, true) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -160,9 +165,12 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("set filter to NF", () => setFilter(mod => mod.Acronym == "NF")); AddStep("press W", () => InputManager.Key(Key.W)); + AddAssert("NF panel not selected", () => !this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "NF").Active.Value); + + AddStep("press Q", () => InputManager.Key(Key.Q)); AddAssert("NF panel selected", () => this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "NF").Active.Value); - AddStep("press W again", () => InputManager.Key(Key.W)); + AddStep("press Q again", () => InputManager.Key(Key.Q)); AddAssert("NF panel deselected", () => !this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "NF").Active.Value); AddStep("filter out everything", () => setFilter(_ => false)); @@ -173,18 +181,125 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("clear filter", () => setFilter(null)); } + [Test] + public void TestClassicKeyboardExclusiveSelection() + { + AddStep("set classic hotkey mode", () => configManager.SetValue(OsuSetting.ModSelectHotkeyStyle, ModSelectHotkeyStyle.Classic)); + + ModColumn column = null!; + AddStep("create content", () => Child = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(30), + Child = column = new ModColumn(ModType.DifficultyIncrease, false) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AvailableMods = getExampleModsFor(ModType.DifficultyIncrease) + } + }); + + AddUntilStep("wait for panel load", () => column.IsLoaded && column.ItemsLoaded); + + AddStep("press A", () => InputManager.Key(Key.A)); + AddAssert("HR panel selected", () => this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "HR").Active.Value); + + AddStep("press A again", () => InputManager.Key(Key.A)); + AddAssert("HR panel deselected", () => !this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "HR").Active.Value); + + AddStep("press D", () => InputManager.Key(Key.D)); + AddAssert("DT panel selected", () => this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "DT").Active.Value); + + AddStep("press D again", () => InputManager.Key(Key.D)); + AddAssert("DT panel deselected", () => !this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "DT").Active.Value); + AddAssert("NC panel selected", () => this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "NC").Active.Value); + + AddStep("press D again", () => InputManager.Key(Key.D)); + AddAssert("DT panel deselected", () => !this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "DT").Active.Value); + AddAssert("NC panel deselected", () => !this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "NC").Active.Value); + + AddStep("press Shift-D", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.D); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + AddAssert("DT panel deselected", () => !this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "DT").Active.Value); + AddAssert("NC panel selected", () => this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "NC").Active.Value); + + AddStep("press J", () => InputManager.Key(Key.J)); + AddAssert("no change", () => this.ChildrenOfType().Single(panel => panel.Active.Value).Mod.Acronym == "NC"); + + AddStep("filter everything but NC", () => setFilter(mod => mod.Acronym == "NC")); + + AddStep("press A", () => InputManager.Key(Key.A)); + AddAssert("no change", () => this.ChildrenOfType().Single(panel => panel.Active.Value).Mod.Acronym == "NC"); + } + + [Test] + public void TestClassicKeyboardIncompatibleSelection() + { + AddStep("set classic hotkey mode", () => configManager.SetValue(OsuSetting.ModSelectHotkeyStyle, ModSelectHotkeyStyle.Classic)); + + ModColumn column = null!; + AddStep("create content", () => Child = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(30), + Child = column = new ModColumn(ModType.DifficultyIncrease, true) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AvailableMods = getExampleModsFor(ModType.DifficultyIncrease) + } + }); + + AddUntilStep("wait for panel load", () => column.IsLoaded && column.ItemsLoaded); + + AddStep("press A", () => InputManager.Key(Key.A)); + AddAssert("HR panel selected", () => this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "HR").Active.Value); + + AddStep("press A again", () => InputManager.Key(Key.A)); + AddAssert("HR panel deselected", () => !this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "HR").Active.Value); + + AddStep("press D", () => InputManager.Key(Key.D)); + AddAssert("DT panel selected", () => this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "DT").Active.Value); + AddAssert("NC panel selected", () => this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "NC").Active.Value); + + AddStep("press D again", () => InputManager.Key(Key.D)); + AddAssert("DT panel deselected", () => !this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "DT").Active.Value); + AddAssert("NC panel deselected", () => !this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "NC").Active.Value); + + AddStep("press Shift-D", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.D); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + AddAssert("DT panel selected", () => this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "DT").Active.Value); + AddAssert("NC panel selected", () => this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "NC").Active.Value); + + AddStep("press J", () => InputManager.Key(Key.J)); + AddAssert("no change", () => this.ChildrenOfType().Count(panel => panel.Active.Value) == 2); + + AddStep("filter everything but NC", () => setFilter(mod => mod.Acronym == "NC")); + + AddStep("press A", () => InputManager.Key(Key.A)); + AddAssert("no change", () => this.ChildrenOfType().Count(panel => panel.Active.Value) == 2); + } + private void setFilter(Func? filter) { foreach (var modState in this.ChildrenOfType().Single().AvailableMods) modState.Filtered.Value = filter?.Invoke(modState.Mod) == false; } - private class TestModColumn : ModColumn + private partial class TestModColumn : ModColumn { public new bool SelectionAnimationRunning => base.SelectionAnimationRunning; - public TestModColumn(ModType modType, bool allowBulkSelection) - : base(modType, allowBulkSelection) + public TestModColumn(ModType modType, bool allowIncompatibleSelection) + : base(modType, allowIncompatibleSelection) { } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModDifficultyAdjustSettings.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModDifficultyAdjustSettings.cs index 8e1f426f7b..307f436f84 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModDifficultyAdjustSettings.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModDifficultyAdjustSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -11,7 +13,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Configuration; -using osu.Game.Overlays.Settings; +using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; using osuTK; @@ -19,7 +21,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneModDifficultyAdjustSettings : OsuManualInputManagerTestScene + public partial class TestSceneModDifficultyAdjustSettings : OsuManualInputManagerTestScene { private OsuModDifficultyAdjust modDifficultyAdjust; @@ -124,6 +126,21 @@ namespace osu.Game.Tests.Visual.UserInterface checkBindableAtValue("Circle Size", 9); } + [Test] + public void TestExtendedLimitsRetainedAfterBoundCopyCreation() + { + setExtendedLimits(true); + setSliderValue("Circle Size", 11); + + checkSliderAtValue("Circle Size", 11); + checkBindableAtValue("Circle Size", 11); + + AddStep("create bound copy", () => _ = modDifficultyAdjust.CircleSize.GetBoundCopy()); + + checkSliderAtValue("Circle Size", 11); + checkBindableAtValue("Circle Size", 11); + } + [Test] public void TestResetToDefault() { @@ -244,7 +261,7 @@ namespace osu.Game.Tests.Visual.UserInterface { AddStep($"Set {name} slider to {value}", () => this.ChildrenOfType().First(c => c.LabelText == name) - .ChildrenOfType>().First().Current.Value = value); + .ChildrenOfType>().First().Current.Value = value); } private void checkBindableAtValue(string name, float? expectedValue) @@ -258,7 +275,7 @@ namespace osu.Game.Tests.Visual.UserInterface { AddAssert($"Slider {name} at {expectedValue}", () => this.ChildrenOfType().First(c => c.LabelText == name) - .ChildrenOfType>().First().Current.Value == expectedValue); + .ChildrenOfType>().First().Current.Value == expectedValue); } private void setBeatmapWithDifficultyParameters(float value) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModDisplay.cs index b8f5ee5e86..bd5a0d8645 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModDisplay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Rulesets.Mods; @@ -9,7 +11,7 @@ using osu.Game.Screens.Play.HUD; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneModDisplay : OsuTestScene + public partial class TestSceneModDisplay : OsuTestScene { [Test] public void TestMode([Values] ExpansionMode mode) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModFlowDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModFlowDisplay.cs index 10eab148de..f0efabe2f7 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModFlowDisplay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModFlowDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -9,7 +11,7 @@ using osu.Game.Screens.Play.HUD; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneModFlowDisplay : OsuTestScene + public partial class TestSceneModFlowDisplay : OsuTestScene { private ModFlowDisplay modFlow; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs index 513eb2fafc..897d5fd9f5 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs @@ -3,18 +3,34 @@ using System.Linq; using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.UI; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneModIcon : OsuTestScene + public partial class TestSceneModIcon : OsuTestScene { + [Test] + public void TestShowAllMods() + { + AddStep("create mod icons", () => + { + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Full, + ChildrenEnumerable = Ruleset.Value.CreateInstance().CreateAllMods().Select(m => new ModIcon(m)), + }; + }); + } + [Test] public void TestChangeModType() { - ModIcon icon = null; + ModIcon icon = null!; AddStep("create mod icon", () => Child = icon = new ModIcon(new OsuModDoubleTime())); AddStep("change mod", () => icon.Mod = new OsuModEasy()); @@ -23,7 +39,7 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestInterfaceModType() { - ModIcon icon = null; + ModIcon icon = null!; var ruleset = new OsuRuleset(); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModPanel.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModPanel.cs index f56d9c8a91..64bdc167c2 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModPanel.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Framework.Allocation; @@ -16,7 +18,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneModPanel : OsuManualInputManagerTestScene + public partial class TestSceneModPanel : OsuManualInputManagerTestScene { [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs new file mode 100644 index 0000000000..1090764788 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs @@ -0,0 +1,314 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Framework.Graphics.Cursor; +using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneModPresetColumn : OsuManualInputManagerTestScene + { + protected override bool UseFreshStoragePerRun => true; + + private Container content = null!; + protected override Container Content => content; + + private RulesetStore rulesets = null!; + + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + + [Cached(typeof(IDialogOverlay))] + private readonly DialogOverlay dialogOverlay = new DialogOverlay(); + + [BackgroundDependencyLoader] + private void load() + { + Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); + Dependencies.Cache(Realm); + + base.Content.AddRange(new Drawable[] + { + new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = content = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(30), + } + }, + dialogOverlay + }); + } + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("clear contents", Clear); + AddStep("reset storage", () => + { + Realm.Write(realm => + { + realm.RemoveAll(); + + var testPresets = createTestPresets(); + foreach (var preset in testPresets) + preset.Ruleset = realm.Find(preset.Ruleset.ShortName); + + realm.Add(testPresets); + }); + }); + } + + [Test] + public void TestBasicOperation() + { + AddStep("set osu! ruleset", () => Ruleset.Value = rulesets.GetRuleset(0)); + AddStep("create content", () => Child = new ModPresetColumn + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + AddUntilStep("3 panels visible", () => this.ChildrenOfType().Count() == 3); + + AddStep("change ruleset to mania", () => Ruleset.Value = rulesets.GetRuleset(3)); + AddUntilStep("1 panel visible", () => this.ChildrenOfType().Count() == 1); + + AddStep("add another mania preset", () => Realm.Write(r => r.Add(new ModPreset + { + Name = "and another one", + Mods = new Mod[] + { + new ManiaModMirror(), + new ManiaModNightcore(), + new ManiaModHardRock() + }, + Ruleset = r.Find("mania") + }))); + AddUntilStep("2 panels visible", () => this.ChildrenOfType().Count() == 2); + + AddStep("add another osu! preset", () => Realm.Write(r => r.Add(new ModPreset + { + Name = "hdhr", + Mods = new Mod[] + { + new OsuModHidden(), + new OsuModHardRock() + }, + Ruleset = r.Find("osu") + }))); + AddUntilStep("2 panels visible", () => this.ChildrenOfType().Count() == 2); + + AddStep("remove mania preset", () => Realm.Write(r => + { + var toRemove = r.All().Single(preset => preset.Name == "Different ruleset"); + r.Remove(toRemove); + })); + AddUntilStep("1 panel visible", () => this.ChildrenOfType().Count() == 1); + + AddStep("set osu! ruleset", () => Ruleset.Value = rulesets.GetRuleset(0)); + AddUntilStep("4 panels visible", () => this.ChildrenOfType().Count() == 4); + } + + [Test] + public void TestSoftDeleteSupport() + { + AddStep("set osu! ruleset", () => Ruleset.Value = rulesets.GetRuleset(0)); + AddStep("clear mods", () => SelectedMods.Value = Array.Empty()); + AddStep("create content", () => Child = new ModPresetColumn + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + AddUntilStep("3 panels visible", () => this.ChildrenOfType().Count() == 3); + + AddStep("soft delete preset", () => Realm.Write(r => + { + var toSoftDelete = r.All().Single(preset => preset.Name == "AR0"); + toSoftDelete.DeletePending = true; + })); + AddUntilStep("2 panels visible", () => this.ChildrenOfType().Count() == 2); + + AddStep("soft delete all presets", () => Realm.Write(r => + { + foreach (var preset in r.All()) + preset.DeletePending = true; + })); + AddUntilStep("no panels visible", () => !this.ChildrenOfType().Any()); + + AddStep("select mods from first preset", () => SelectedMods.Value = new Mod[] { new OsuModDoubleTime(), new OsuModHardRock() }); + + AddStep("undelete presets", () => Realm.Write(r => + { + foreach (var preset in r.All()) + preset.DeletePending = false; + })); + AddUntilStep("3 panels visible", () => this.ChildrenOfType().Count() == 3); + } + + [Test] + public void TestAddingFlow() + { + ModPresetColumn modPresetColumn = 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); + AddAssert("add preset button disabled", () => !this.ChildrenOfType().Single().Enabled.Value); + + AddStep("set mods", () => SelectedMods.Value = new Mod[] { new OsuModDaycore(), new OsuModClassic() }); + AddAssert("add preset button enabled", () => this.ChildrenOfType().Single().Enabled.Value); + + AddStep("click add preset button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + OsuPopover? popover = null; + AddUntilStep("wait for popover", () => (popover = this.ChildrenOfType().FirstOrDefault()) != null); + AddStep("attempt preset creation", () => + { + InputManager.MoveMouseTo(popover.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddWaitStep("wait some", 3); + AddAssert("preset creation did not occur", () => this.ChildrenOfType().Count() == 3); + AddUntilStep("popover is unchanged", () => this.ChildrenOfType().FirstOrDefault() == popover); + + AddStep("fill preset name", () => popover.ChildrenOfType().First().Current.Value = "new preset"); + AddStep("attempt preset creation", () => + { + InputManager.MoveMouseTo(popover.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("popover closed", () => !this.ChildrenOfType().Any()); + AddUntilStep("preset creation occurred", () => this.ChildrenOfType().Count() == 4); + + AddStep("click add preset button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("wait for popover", () => (popover = this.ChildrenOfType().FirstOrDefault()) != null); + AddStep("clear mods", () => SelectedMods.Value = Array.Empty()); + AddUntilStep("popover closed", () => !this.ChildrenOfType().Any()); + } + + [Test] + public void TestDeleteFlow() + { + ModPresetColumn modPresetColumn = null!; + + 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(); + InputManager.MoveMouseTo(panel); + InputManager.Click(MouseButton.Right); + }); + + AddUntilStep("wait for context menu", () => this.ChildrenOfType().Any()); + AddStep("click delete", () => + { + var deleteItem = this.ChildrenOfType().Single(); + InputManager.MoveMouseTo(deleteItem); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("wait for dialog", () => dialogOverlay.CurrentDialog is DeleteModPresetDialog); + AddStep("hold confirm", () => + { + var confirmButton = this.ChildrenOfType().Single(); + InputManager.MoveMouseTo(confirmButton); + InputManager.PressButton(MouseButton.Left); + }); + AddUntilStep("wait for dialog to close", () => dialogOverlay.CurrentDialog == null); + AddStep("release mouse", () => InputManager.ReleaseButton(MouseButton.Left)); + AddUntilStep("preset deletion occurred", () => this.ChildrenOfType().Count() == 2); + AddAssert("preset soft-deleted", () => Realm.Run(r => r.All().Count(preset => preset.DeletePending) == 1)); + } + + private ICollection createTestPresets() => new[] + { + new ModPreset + { + Name = "First preset", + Description = "Please ignore", + Mods = new Mod[] + { + new OsuModHardRock(), + new OsuModDoubleTime() + }, + Ruleset = rulesets.GetRuleset(0).AsNonNull() + }, + new ModPreset + { + Name = "AR0", + Description = "For good readers", + Mods = new Mod[] + { + new OsuModDifficultyAdjust + { + ApproachRate = { Value = 0 } + } + }, + Ruleset = rulesets.GetRuleset(0).AsNonNull() + }, + new ModPreset + { + Name = "This preset is going to have an extraordinarily long name", + Description = "This is done so that the capability to truncate overlong texts may be demonstrated", + Mods = new Mod[] + { + new OsuModFlashlight(), + new OsuModSpinIn() + }, + Ruleset = rulesets.GetRuleset(0).AsNonNull() + }, + new ModPreset + { + Name = "Different ruleset", + Description = "Just to shake things up", + Mods = new Mod[] + { + new ManiaModKey4(), + new ManiaModFadeIn() + }, + Ruleset = rulesets.GetRuleset(3).AsNonNull() + } + }; + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetPanel.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetPanel.cs new file mode 100644 index 0000000000..35e352534b --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetPanel.cs @@ -0,0 +1,160 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Database; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + [TestFixture] + public partial class TestSceneModPresetPanel : OsuTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("reset selected mods", () => SelectedMods.SetDefault()); + } + + [Test] + public void TestVariousModPresets() + { + AddStep("create content", () => Child = new FillFlowContainer + { + Width = 300, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(0, 5), + ChildrenEnumerable = createTestPresets().Select(preset => new ModPresetPanel(preset.ToLiveUnmanaged())) + }); + } + + [Test] + public void TestPresetSelectionStateAfterExternalModChanges() + { + ModPresetPanel? panel = null; + + AddStep("create panel", () => Child = panel = new ModPresetPanel(createTestPresets().First().ToLiveUnmanaged()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f + }); + AddAssert("panel is not active", () => !panel.AsNonNull().Active.Value); + + AddStep("set mods to HR", () => SelectedMods.Value = new[] { new OsuModHardRock() }); + AddAssert("panel is not active", () => !panel.AsNonNull().Active.Value); + + AddStep("set mods to DT", () => SelectedMods.Value = new[] { new OsuModDoubleTime() }); + AddAssert("panel is not active", () => !panel.AsNonNull().Active.Value); + + AddStep("set mods to HR+DT", () => SelectedMods.Value = new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() }); + AddAssert("panel is active", () => panel.AsNonNull().Active.Value); + + AddStep("set mods to HR+customised DT", () => SelectedMods.Value = new Mod[] + { + new OsuModHardRock(), + new OsuModDoubleTime + { + SpeedChange = { Value = 1.25 } + } + }); + AddAssert("panel is not active", () => !panel.AsNonNull().Active.Value); + + AddStep("set mods to HR+DT", () => SelectedMods.Value = new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() }); + AddAssert("panel is active", () => panel.AsNonNull().Active.Value); + + AddStep("customise mod in place", () => SelectedMods.Value.OfType().Single().SpeedChange.Value = 1.33); + AddAssert("panel is not active", () => !panel.AsNonNull().Active.Value); + + AddStep("set mods to HD+HR+DT", () => SelectedMods.Value = new Mod[] { new OsuModHidden(), new OsuModHardRock(), new OsuModDoubleTime() }); + AddAssert("panel is not active", () => !panel.AsNonNull().Active.Value); + } + + [Test] + public void TestActivatingPresetTogglesIncludedMods() + { + ModPresetPanel? panel = null; + + AddStep("create panel", () => Child = panel = new ModPresetPanel(createTestPresets().First().ToLiveUnmanaged()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f + }); + + AddStep("activate panel", () => panel.AsNonNull().TriggerClick()); + assertSelectedModsEquivalentTo(new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() }); + + AddStep("deactivate panel", () => panel.AsNonNull().TriggerClick()); + assertSelectedModsEquivalentTo(Array.Empty()); + + AddStep("set different mod", () => SelectedMods.Value = new[] { new OsuModHidden() }); + AddStep("activate panel", () => panel.AsNonNull().TriggerClick()); + assertSelectedModsEquivalentTo(new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() }); + + AddStep("set customised mod", () => SelectedMods.Value = new[] { new OsuModDoubleTime { SpeedChange = { Value = 1.25 } } }); + AddStep("activate panel", () => panel.AsNonNull().TriggerClick()); + assertSelectedModsEquivalentTo(new Mod[] { new OsuModHardRock(), new OsuModDoubleTime { SpeedChange = { Value = 1.5 } } }); + } + + private void assertSelectedModsEquivalentTo(IEnumerable mods) + => AddAssert("selected mods changed correctly", () => new HashSet(SelectedMods.Value).SetEquals(mods)); + + private static IEnumerable createTestPresets() => new[] + { + new ModPreset + { + Name = "First preset", + Description = "Please ignore", + Mods = new Mod[] + { + new OsuModHardRock(), + new OsuModDoubleTime() + }, + Ruleset = new OsuRuleset().RulesetInfo + }, + new ModPreset + { + Name = "AR0", + Description = "For good readers", + Mods = new Mod[] + { + new OsuModDifficultyAdjust + { + ApproachRate = { Value = 0 } + } + }, + Ruleset = new OsuRuleset().RulesetInfo + }, + new ModPreset + { + Name = "This preset is going to have an extraordinarily long name", + Description = "This is done so that the capability to truncate overlong texts may be demonstrated", + Mods = new Mod[] + { + new OsuModFlashlight(), + new OsuModSpinIn() + }, + Ruleset = new OsuRuleset().RulesetInfo + } + }; + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index 9bb02c3e75..5ccaebd721 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -7,14 +7,17 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Mods; using osu.Game.Overlays.Settings; using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch.Mods; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; @@ -25,12 +28,20 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneModSelectOverlay : OsuManualInputManagerTestScene + public partial class TestSceneModSelectOverlay : OsuManualInputManagerTestScene { - [Resolved] - private RulesetStore rulesetStore { get; set; } + protected override bool UseFreshStoragePerRun => true; - private UserModSelectOverlay modSelectOverlay; + private RulesetStore rulesetStore = null!; + + private TestModSelectOverlay modSelectOverlay = null!; + + [BackgroundDependencyLoader] + private void load() + { + Dependencies.Cache(rulesetStore = new RealmRulesetStore(Realm)); + Dependencies.Cache(Realm); + } [SetUpSteps] public void SetUpSteps() @@ -38,11 +49,31 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("clear contents", Clear); AddStep("reset ruleset", () => Ruleset.Value = rulesetStore.GetRuleset(0)); AddStep("reset mods", () => SelectedMods.SetDefault()); + AddStep("set up presets", () => + { + Realm.Write(r => + { + r.RemoveAll(); + r.Add(new ModPreset + { + Name = "AR0", + Description = "Too... many... circles...", + Ruleset = r.Find(OsuRuleset.SHORT_NAME), + Mods = new[] + { + new OsuModDifficultyAdjust + { + ApproachRate = { Value = 0 } + } + } + }); + }); + }); } private void createScreen() { - AddStep("create screen", () => Child = modSelectOverlay = new UserModSelectOverlay + AddStep("create screen", () => Child = modSelectOverlay = new TestModSelectOverlay { RelativeSizeAxes = Axes.Both, State = { Value = Visibility.Visible }, @@ -135,7 +166,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("any column dimmed", () => this.ChildrenOfType().Any(column => !column.Active.Value)); - ModColumn lastColumn = null; + ModSelectColumn lastColumn = null!; AddAssert("last column dimmed", () => !this.ChildrenOfType().Last().Active.Value); AddStep("request scroll to last column", () => @@ -163,18 +194,22 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("select customisable mod", () => SelectedMods.Value = new[] { new OsuModDoubleTime() }); assertCustomisationToggleState(disabled: false, active: false); - AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); + AddStep("select mod requiring configuration externally", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); + assertCustomisationToggleState(disabled: false, active: false); + + AddStep("reset mods", () => SelectedMods.SetDefault()); + AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); assertCustomisationToggleState(disabled: false, active: true); AddStep("dismiss mod customisation via toggle", () => { - InputManager.MoveMouseTo(modSelectOverlay.ChildrenOfType().Single()); + InputManager.MoveMouseTo(modSelectOverlay.CustomisationButton); InputManager.Click(MouseButton.Left); }); assertCustomisationToggleState(disabled: false, active: false); AddStep("reset mods", () => SelectedMods.SetDefault()); - AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); + AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); assertCustomisationToggleState(disabled: false, active: true); AddStep("dismiss mod customisation via keyboard", () => InputManager.Key(Key.Escape)); @@ -186,11 +221,18 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("select mod without configuration", () => SelectedMods.Value = new[] { new OsuModAutoplay() }); assertCustomisationToggleState(disabled: true, active: false); - AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); + AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); assertCustomisationToggleState(disabled: false, active: true); AddStep("select mod without configuration", () => SelectedMods.Value = new[] { new OsuModAutoplay() }); assertCustomisationToggleState(disabled: true, active: false); // config was dismissed without explicit user action. + + AddStep("select mod preset with mod requiring configuration", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + assertCustomisationToggleState(disabled: false, active: false); } [Test] @@ -199,7 +241,7 @@ namespace osu.Game.Tests.Visual.UserInterface createScreen(); assertCustomisationToggleState(disabled: true, active: false); - AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); + AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); assertCustomisationToggleState(disabled: false, active: true); AddStep("move mouse to settings area", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); @@ -222,13 +264,13 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestSettingsNotCrossPolluting() { - Bindable> selectedMods2 = null; - ModSelectOverlay modSelectOverlay2 = null; + Bindable> selectedMods2 = null!; + ModSelectOverlay modSelectOverlay2 = null!; createScreen(); - AddStep("select diff adjust", () => SelectedMods.Value = new Mod[] { new OsuModDifficultyAdjust() }); + AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); - AddStep("set setting", () => modSelectOverlay.ChildrenOfType>().First().Current.Value = 8); + AddStep("set setting", () => modSelectOverlay.ChildrenOfType>().First().Current.Value = 8); AddAssert("ensure setting is propagated", () => SelectedMods.Value.OfType().Single().CircleSize.Value == 8); @@ -297,26 +339,36 @@ namespace osu.Game.Tests.Visual.UserInterface } [Test] - public void TestRulesetChanges() + public void TestCommonModsMaintainedOnRulesetChange() { createScreen(); changeRuleset(0); - var noFailMod = new OsuRuleset().GetModsFor(ModType.DifficultyReduction).FirstOrDefault(m => m is OsuModNoFail); - - AddStep("set mods externally", () => { SelectedMods.Value = new[] { noFailMod }; }); + AddStep("select relax mod", () => SelectedMods.Value = new[] { Ruleset.Value.CreateInstance().CreateMod() }); changeRuleset(0); + AddAssert("ensure mod still selected", () => SelectedMods.Value.SingleOrDefault() is OsuModRelax); - AddAssert("ensure mods still selected", () => SelectedMods.Value.SingleOrDefault(m => m is OsuModNoFail) != null); + changeRuleset(2); + AddAssert("catch variant selected", () => SelectedMods.Value.SingleOrDefault() is CatchModRelax); changeRuleset(3); + AddAssert("no mod selected", () => SelectedMods.Value.Count == 0); + } - AddAssert("ensure mods not selected", () => SelectedMods.Value.Count == 0); - + [Test] + public void TestUncommonModsDiscardedOnRulesetChange() + { + createScreen(); changeRuleset(0); - AddAssert("ensure mods not selected", () => SelectedMods.Value.Count == 0); + AddStep("select single tap mod", () => SelectedMods.Value = new[] { new OsuModSingleTap() }); + + changeRuleset(0); + AddAssert("ensure mod still selected", () => SelectedMods.Value.SingleOrDefault() is OsuModSingleTap); + + changeRuleset(3); + AddAssert("no mod selected", () => SelectedMods.Value.Count == 0); } [Test] @@ -351,7 +403,7 @@ namespace osu.Game.Tests.Visual.UserInterface public void TestExternallySetModIsReplacedByOverlayInstance() { Mod external = new OsuModDoubleTime(); - Mod overlayButtonMod = null; + Mod overlayButtonMod = null!; createScreen(); changeRuleset(0); @@ -385,7 +437,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("double time not visible", () => modSelectOverlay.ChildrenOfType().Where(panel => panel.Mod is OsuModDoubleTime).All(panel => panel.Filtered.Value)); AddAssert("nightcore still visible", () => modSelectOverlay.ChildrenOfType().Where(panel => panel.Mod is OsuModNightcore).Any(panel => !panel.Filtered.Value)); - AddStep("make double time valid again", () => modSelectOverlay.IsValidMod = m => true); + AddStep("make double time valid again", () => modSelectOverlay.IsValidMod = _ => true); AddUntilStep("double time visible", () => modSelectOverlay.ChildrenOfType().Where(panel => panel.Mod is OsuModDoubleTime).Any(panel => !panel.Filtered.Value)); AddAssert("nightcore still visible", () => modSelectOverlay.ChildrenOfType().Where(b => b.Mod is OsuModNightcore).Any(panel => !panel.Filtered.Value)); } @@ -456,14 +508,14 @@ namespace osu.Game.Tests.Visual.UserInterface createScreen(); changeRuleset(0); - AddStep("select difficulty adjust", () => SelectedMods.Value = new Mod[] { new OsuModDifficultyAdjust() }); + AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); assertCustomisationToggleState(disabled: false, active: true); - AddAssert("back button disabled", () => !this.ChildrenOfType().First().Enabled.Value); + AddAssert("back button disabled", () => !modSelectOverlay.BackButton.Enabled.Value); AddStep("dismiss customisation area", () => InputManager.Key(Key.Escape)); AddStep("click back button", () => { - InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.MoveMouseTo(modSelectOverlay.BackButton); InputManager.Click(MouseButton.Left); }); AddAssert("mod select hidden", () => modSelectOverlay.State.Value == Visibility.Hidden); @@ -472,7 +524,7 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestColumnHiding() { - AddStep("create screen", () => Child = modSelectOverlay = new UserModSelectOverlay + AddStep("create screen", () => Child = modSelectOverlay = new TestModSelectOverlay { RelativeSizeAxes = Axes.Both, State = { Value = Visibility.Visible }, @@ -525,20 +577,26 @@ namespace osu.Game.Tests.Visual.UserInterface private void assertCustomisationToggleState(bool disabled, bool active) { - ShearedToggleButton getToggle() => modSelectOverlay.ChildrenOfType().Single(); - - AddAssert($"customisation toggle is {(disabled ? "" : "not ")}disabled", () => getToggle().Active.Disabled == disabled); - AddAssert($"customisation toggle is {(active ? "" : "not ")}active", () => getToggle().Active.Value == active); + AddAssert($"customisation toggle is {(disabled ? "" : "not ")}disabled", () => modSelectOverlay.CustomisationButton.AsNonNull().Active.Disabled == disabled); + AddAssert($"customisation toggle is {(active ? "" : "not ")}active", () => modSelectOverlay.CustomisationButton.AsNonNull().Active.Value == active); } private ModPanel getPanelForMod(Type modType) => modSelectOverlay.ChildrenOfType().Single(panel => panel.Mod.GetType() == modType); + private partial class TestModSelectOverlay : UserModSelectOverlay + { + protected override bool ShowPresets => true; + + public new ShearedButton BackButton => base.BackButton; + public new ShearedToggleButton? CustomisationButton => base.CustomisationButton; + } + private class TestUnimplementedMod : Mod { public override string Name => "Unimplemented mod"; public override string Acronym => "UM"; - public override string Description => "A mod that is not implemented."; + public override LocalisableString Description => "A mod that is not implemented."; public override double ScoreMultiplier => 1; public override ModType Type => ModType.Conversion; } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettingsArea.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettingsArea.cs index ddc1c8c128..dac1f94c28 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettingsArea.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettingsArea.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Framework.Allocation; @@ -14,7 +16,7 @@ using osu.Game.Rulesets.Osu.Mods; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneModSettingsArea : OsuTestScene + public partial class TestSceneModSettingsArea : OsuTestScene { [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSwitchSmall.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSwitchSmall.cs index 447352b7a6..07312379b3 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSwitchSmall.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSwitchSmall.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -20,7 +22,7 @@ using osuTK; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneModSwitchSmall : OsuTestScene + public partial class TestSceneModSwitchSmall : OsuTestScene { [Test] public void TestOsu() => createSwitchTestFor(new OsuRuleset()); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSwitchTiny.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSwitchTiny.cs index dbde7ce425..34dd139428 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSwitchTiny.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSwitchTiny.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -20,7 +22,7 @@ using osuTK; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneModSwitchTiny : OsuTestScene + public partial class TestSceneModSwitchTiny : OsuTestScene { [Test] public void TestOsu() => createSwitchTestFor(new OsuRuleset()); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModsEffectDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModsEffectDisplay.cs new file mode 100644 index 0000000000..a1c8bef1de --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModsEffectDisplay.cs @@ -0,0 +1,68 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Framework.Testing; +using osu.Game.Graphics; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets.Mods; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.UserInterface +{ + [TestFixture] + public partial class TestSceneModsEffectDisplay : OsuTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Test] + public void TestModsEffectDisplay() + { + TestDisplay testDisplay = null!; + Box background = null!; + + AddStep("add display", () => + { + Add(testDisplay = new TestDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }); + var boxes = testDisplay.ChildrenOfType(); + background = boxes.First(); + }); + + AddStep("set value to default", () => testDisplay.Current.Value = 50); + AddUntilStep("colours are correct", () => testDisplay.Container.Colour == Color4.White && background.Colour == colourProvider.Background3); + + AddStep("set value to less", () => testDisplay.Current.Value = 40); + AddUntilStep("colours are correct", () => testDisplay.Container.Colour == colourProvider.Background5 && background.Colour == colours.ForModType(ModType.DifficultyReduction)); + + AddStep("set value to bigger", () => testDisplay.Current.Value = 60); + AddUntilStep("colours are correct", () => testDisplay.Container.Colour == colourProvider.Background5 && background.Colour == colours.ForModType(ModType.DifficultyIncrease)); + } + + private partial class TestDisplay : ModsEffectDisplay + { + public Container Container => Content; + + protected override LocalisableString Label => "Test display"; + + public TestDisplay() + { + Current.Default = 50; + } + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs index a2aa0499d2..3cd5daf7a1 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs @@ -1,34 +1,45 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Graphics.Sprites; +using osu.Game.Online.Multiplayer; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; +using osu.Game.Updater; +using osuTK; +using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneNotificationOverlay : OsuTestScene + public partial class TestSceneNotificationOverlay : OsuManualInputManagerTestScene { - private NotificationOverlay notificationOverlay; + private NotificationOverlay notificationOverlay = null!; private readonly List progressingNotifications = new List(); - private SpriteText displayedCount; + private SpriteText displayedCount = null!; + + public double TimeToCompleteProgress { get; set; } = 2000; [SetUp] public void SetUp() => Schedule(() => { + InputManager.MoveMouseTo(Vector2.Zero); + + TimeToCompleteProgress = 2000; progressingNotifications.Clear(); - Content.Children = new Drawable[] + Children = new Drawable[] { notificationOverlay = new NotificationOverlay { @@ -38,13 +49,258 @@ namespace osu.Game.Tests.Visual.UserInterface displayedCount = new OsuSpriteText() }; - notificationOverlay.UnreadCount.ValueChanged += count => { displayedCount.Text = $"displayed count: {count.NewValue}"; }; + notificationOverlay.UnreadCount.ValueChanged += count => { displayedCount.Text = $"unread count: {count.NewValue}"; }; }); + [Test] + public void TestForwardWithFlingRight() + { + bool activated = false; + SimpleNotification notification = null!; + + AddStep("post", () => + { + activated = false; + notificationOverlay.Post(notification = new SimpleNotification + { + Text = @"Welcome to osu!. Enjoy your stay!", + Activated = () => activated = true, + }); + }); + + AddStep("start drag", () => + { + InputManager.MoveMouseTo(notification.ChildrenOfType().Single()); + InputManager.PressButton(MouseButton.Left); + InputManager.MoveMouseTo(notification.ChildrenOfType().Single().ScreenSpaceDrawQuad.Centre + new Vector2(500, 0)); + }); + + AddStep("fling away", () => + { + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddAssert("was not closed", () => !notification.WasClosed); + AddAssert("was not activated", () => !activated); + AddAssert("is not read", () => !notification.Read); + AddAssert("is not toast", () => !notification.IsInToastTray); + + AddStep("reset mouse position", () => InputManager.MoveMouseTo(Vector2.Zero)); + AddAssert("unread count one", () => notificationOverlay.UnreadCount.Value == 1); + } + + [Test] + public void TestDismissWithoutActivationFling() + { + bool activated = false; + SimpleNotification notification = null!; + + AddStep("post", () => + { + activated = false; + notificationOverlay.Post(notification = new SimpleNotification + { + Text = @"Welcome to osu!. Enjoy your stay!", + Activated = () => activated = true, + }); + }); + + AddStep("start drag", () => + { + InputManager.MoveMouseTo(notificationOverlay.ChildrenOfType().Single()); + InputManager.PressButton(MouseButton.Left); + InputManager.MoveMouseTo(notificationOverlay.ChildrenOfType().Single().ScreenSpaceDrawQuad.Centre + new Vector2(-500, 0)); + }); + + AddStep("fling away", () => + { + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddUntilStep("wait for closed", () => notification.WasClosed); + AddAssert("was not activated", () => !activated); + AddStep("reset mouse position", () => InputManager.MoveMouseTo(Vector2.Zero)); + AddAssert("unread count zero", () => notificationOverlay.UnreadCount.Value == 0); + } + + [Test] + public void TestProgressNotificationCantBeFlung() + { + bool activated = false; + ProgressNotification notification = null!; + + AddStep("post", () => + { + activated = false; + notificationOverlay.Post(notification = new ProgressNotification + { + Text = @"Uploading to BSS...", + CompletionText = "Uploaded to BSS!", + Activated = () => activated = true, + }); + + progressingNotifications.Add(notification); + }); + + AddStep("start drag", () => + { + InputManager.MoveMouseTo(notificationOverlay.ChildrenOfType().Single()); + InputManager.PressButton(MouseButton.Left); + InputManager.MoveMouseTo(notificationOverlay.ChildrenOfType().Single().ScreenSpaceDrawQuad.Centre + new Vector2(-500, 0)); + }); + + AddStep("attempt fling", () => + { + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddUntilStep("was not closed", () => !notification.WasClosed); + AddUntilStep("was not cancelled", () => notification.State == ProgressNotificationState.Active); + AddAssert("was not activated", () => !activated); + AddStep("reset mouse position", () => InputManager.MoveMouseTo(Vector2.Zero)); + + AddUntilStep("was completed", () => notification.State == ProgressNotificationState.Completed); + } + + [Test] + public void TestDismissWithoutActivationCloseButton() + { + bool activated = false; + SimpleNotification notification = null!; + + AddStep("post", () => + { + activated = false; + notificationOverlay.Post(notification = new SimpleNotification + { + Text = @"Welcome to osu!. Enjoy your stay!", + Activated = () => activated = true, + }); + }); + + AddStep("click to activate", () => + { + InputManager.MoveMouseTo(notificationOverlay + .ChildrenOfType().Single() + .ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("wait for closed", () => notification.WasClosed); + AddAssert("was not activated", () => !activated); + AddStep("reset mouse position", () => InputManager.MoveMouseTo(Vector2.Zero)); + AddAssert("unread count zero", () => notificationOverlay.UnreadCount.Value == 0); + } + + [Test] + public void TestDismissWithoutActivationRightClick() + { + bool activated = false; + SimpleNotification notification = null!; + + AddStep("post", () => + { + activated = false; + notificationOverlay.Post(notification = new SimpleNotification + { + Text = @"Welcome to osu!. Enjoy your stay!", + Activated = () => activated = true, + }); + }); + + AddStep("click to activate", () => + { + InputManager.MoveMouseTo(notificationOverlay.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Right); + }); + + AddUntilStep("wait for closed", () => notification.WasClosed); + AddAssert("was not activated", () => !activated); + AddStep("reset mouse position", () => InputManager.MoveMouseTo(Vector2.Zero)); + } + + [Test] + public void TestActivate() + { + bool activated = false; + SimpleNotification notification = null!; + + AddStep("post", () => + { + activated = false; + notificationOverlay.Post(notification = new SimpleNotification + { + Text = @"Welcome to osu!. Enjoy your stay!", + Activated = () => activated = true, + }); + }); + + AddStep("click to activate", () => + { + InputManager.MoveMouseTo(notificationOverlay.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("wait for closed", () => notification.WasClosed); + AddAssert("was activated", () => activated); + AddStep("reset mouse position", () => InputManager.MoveMouseTo(Vector2.Zero)); + } + + [Test] + public void TestPresence() + { + AddAssert("tray not present", () => !notificationOverlay.ChildrenOfType().Single().IsPresent); + AddAssert("overlay not present", () => !notificationOverlay.IsPresent); + + AddStep(@"post notification", sendBackgroundNotification); + + AddUntilStep("wait tray not present", () => !notificationOverlay.ChildrenOfType().Single().IsPresent); + AddUntilStep("wait overlay not present", () => !notificationOverlay.IsPresent); + } + + [Test] + public void TestPresenceWithManualDismiss() + { + AddAssert("tray not present", () => !notificationOverlay.ChildrenOfType().Single().IsPresent); + AddAssert("overlay not present", () => !notificationOverlay.IsPresent); + + AddStep(@"post notification", sendBackgroundNotification); + AddStep("click notification", () => notificationOverlay.ChildrenOfType().Single().TriggerClick()); + + AddUntilStep("wait tray not present", () => !notificationOverlay.ChildrenOfType().Single().IsPresent); + AddUntilStep("wait overlay not present", () => !notificationOverlay.IsPresent); + } + + [Test] + public void TestProgressClick() + { + ProgressNotification notification = null!; + + AddStep("add progress notification", () => + { + notification = new ProgressNotification + { + Text = @"Uploading to BSS...", + CompletionText = "Uploaded to BSS!", + }; + notificationOverlay.Post(notification); + progressingNotifications.Add(notification); + }); + + AddStep("hover over notification", () => InputManager.MoveMouseTo(notificationOverlay.ChildrenOfType().Single())); + + AddStep("left click", () => InputManager.Click(MouseButton.Left)); + AddAssert("not cancelled", () => notification.State == ProgressNotificationState.Active); + + AddStep("right click", () => InputManager.Click(MouseButton.Right)); + AddAssert("cancelled", () => notification.State == ProgressNotificationState.Cancelled); + } + [Test] public void TestCompleteProgress() { - ProgressNotification notification = null; + ProgressNotification notification = null!; + AddStep("add progress notification", () => { notification = new ProgressNotification @@ -57,12 +313,39 @@ namespace osu.Game.Tests.Visual.UserInterface }); AddUntilStep("wait completion", () => notification.State == ProgressNotificationState.Completed); + + AddAssert("Completion toast shown", () => notificationOverlay.ToastCount == 1); + AddUntilStep("wait forwarded", () => notificationOverlay.ToastCount == 0); + } + + [Test] + public void TestCompleteProgressSlow() + { + ProgressNotification notification = null!; + + AddStep("Set progress slow", () => TimeToCompleteProgress *= 2); + AddStep("add progress notification", () => + { + notification = new ProgressNotification + { + Text = @"Uploading to BSS...", + CompletionText = "Uploaded to BSS!", + }; + notificationOverlay.Post(notification); + progressingNotifications.Add(notification); + }); + + AddUntilStep("wait completion", () => notification.State == ProgressNotificationState.Completed); + + AddAssert("Completion toast shown", () => notificationOverlay.ToastCount == 1); + AddUntilStep("wait forwarded", () => notificationOverlay.ToastCount == 0); + AddAssert("only one unread", () => notificationOverlay.UnreadCount.Value == 1); } [Test] public void TestCancelProgress() { - ProgressNotification notification = null; + ProgressNotification notification = null!; AddStep("add progress notification", () => { notification = new ProgressNotification @@ -79,6 +362,55 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("cancel notification", () => notification.State = ProgressNotificationState.Cancelled); } + [Test] + public void TestReadState() + { + SimpleNotification notification = null!; + AddStep(@"post", () => notificationOverlay.Post(notification = new BackgroundNotification { Text = @"Welcome to osu!. Enjoy your stay!" })); + AddUntilStep("check is toast", () => notification.IsInToastTray); + AddAssert("light is not visible", () => notification.ChildrenOfType().Single().Alpha == 0); + + AddUntilStep("wait for forward to overlay", () => !notification.IsInToastTray); + + setState(Visibility.Visible); + AddAssert("state is not read", () => !notification.Read); + AddUntilStep("light is visible", () => notification.ChildrenOfType().Single().Alpha == 1); + + setState(Visibility.Hidden); + setState(Visibility.Visible); + AddAssert("state is read", () => notification.Read); + AddUntilStep("light is not visible", () => notification.ChildrenOfType().Single().Alpha == 0); + } + + [Test] + public void TestUpdateNotificationFlow() + { + bool applyUpdate = false; + + AddStep(@"post update", () => + { + applyUpdate = false; + + var updateNotification = new UpdateManager.UpdateProgressNotification + { + CompletionClickAction = () => applyUpdate = true + }; + + notificationOverlay.Post(updateNotification); + progressingNotifications.Add(updateNotification); + }); + + checkProgressingCount(1); + waitForCompletion(); + + UpdateManager.UpdateApplicationCompleteNotification? completionNotification = null; + AddUntilStep("wait for completion notification", + () => (completionNotification = notificationOverlay.ChildrenOfType().SingleOrDefault()) != null); + AddStep("click notification", () => completionNotification?.TriggerClick()); + + AddUntilStep("wait for update applied", () => applyUpdate); + } + [Test] public void TestBasicFlow() { @@ -110,7 +442,8 @@ namespace osu.Game.Tests.Visual.UserInterface { AddStep(@"simple #1", sendHelloNotification); - AddAssert("Is visible", () => notificationOverlay.State.Value == Visibility.Visible); + AddAssert("toast displayed", () => notificationOverlay.ToastCount == 1); + AddAssert("is not visible", () => notificationOverlay.State.Value == Visibility.Hidden); checkDisplayedCount(1); @@ -159,11 +492,19 @@ namespace osu.Game.Tests.Visual.UserInterface AddRepeatStep("send barrage", sendBarrage, 10); } + [Test] + public void TestServerShuttingDownNotification() + { + AddStep("post with 5 seconds", () => notificationOverlay.Post(new ServerShutdownNotification(TimeSpan.FromSeconds(5)))); + AddStep("post with 30 seconds", () => notificationOverlay.Post(new ServerShutdownNotification(TimeSpan.FromSeconds(30)))); + AddStep("post with 6 hours", () => notificationOverlay.Post(new ServerShutdownNotification(TimeSpan.FromHours(6)))); + } + protected override void Update() { base.Update(); - progressingNotifications.RemoveAll(n => n.State == ProgressNotificationState.Completed); + progressingNotifications.RemoveAll(n => n.State == ProgressNotificationState.Completed && n.WasClosed); if (progressingNotifications.Count(n => n.State == ProgressNotificationState.Active) < 3) { @@ -176,14 +517,14 @@ namespace osu.Game.Tests.Visual.UserInterface foreach (var n in progressingNotifications.FindAll(n => n.State == ProgressNotificationState.Active)) { if (n.Progress < 1) - n.Progress += (float)(Time.Elapsed / 2000); + n.Progress += (float)(Time.Elapsed / TimeToCompleteProgress); else n.State = ProgressNotificationState.Completed; } } private void checkDisplayedCount(int expected) => - AddAssert($"Displayed count is {expected}", () => notificationOverlay.UnreadCount.Value == expected); + AddUntilStep($"Displayed count is {expected}", () => notificationOverlay.UnreadCount.Value == expected); private void sendDownloadProgress() { @@ -276,12 +617,12 @@ namespace osu.Game.Tests.Visual.UserInterface notificationOverlay.Post(new SimpleNotification { Text = @"Spam incoming!!" }); } - private class BackgroundNotification : SimpleNotification + private partial class BackgroundNotification : SimpleNotification { public override bool IsImportant => false; } - private class BackgroundProgressNotification : ProgressNotification + private partial class BackgroundProgressNotification : ProgressNotification { public override bool IsImportant => false; } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneNowPlayingOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneNowPlayingOverlay.cs index 475ab0c414..d07b90025f 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneNowPlayingOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneNowPlayingOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osu.Game.Rulesets.Osu; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneNowPlayingOverlay : OsuTestScene + public partial class TestSceneNowPlayingOverlay : OsuTestScene { [Cached] private MusicController musicController = new MusicController(); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs index 544581082e..f2123061e5 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Configuration; @@ -12,7 +14,7 @@ using osu.Game.Overlays.OSD; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneOnScreenDisplay : OsuTestScene + public partial class TestSceneOnScreenDisplay : OsuTestScene { [BackgroundDependencyLoader] private void load() @@ -93,7 +95,7 @@ namespace osu.Game.Tests.Visual.UserInterface Setting4 } - private class EmptyToast : Toast + private partial class EmptyToast : Toast { public EmptyToast() : base("", "", "") @@ -101,7 +103,7 @@ namespace osu.Game.Tests.Visual.UserInterface } } - private class LengthyToast : Toast + private partial class LengthyToast : Toast { public LengthyToast() : base("Toast with a very very very long text", "A very very very very very very long text also", "A very very very very very long shortcut") @@ -109,7 +111,7 @@ namespace osu.Game.Tests.Visual.UserInterface } } - private class TestOnScreenDisplay : OnScreenDisplay + private partial class TestOnScreenDisplay : OnScreenDisplay { protected override void DisplayTemporarily(Drawable toDisplay) => toDisplay.FadeIn().ResizeHeightTo(110); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuAnimatedButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuAnimatedButton.cs index 2eb5a8014e..eba3885a2b 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuAnimatedButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuAnimatedButton.cs @@ -1,7 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Graphics.Sprites; @@ -10,7 +11,7 @@ using osuTK; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneOsuAnimatedButton : OsuTestScene + public partial class TestSceneOsuAnimatedButton : OsuTestScene { [Test] public void TestRelativeSized() @@ -87,7 +88,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddToggleStep("toggle enabled", toggle => { for (int i = 0; i < 6; i++) - button.Action = toggle ? () => { } : (Action)null; + button.Action = toggle ? () => { } : null; }); } @@ -103,7 +104,7 @@ namespace osu.Game.Tests.Visual.UserInterface }); } - public class BaseContainer : OsuAnimatedButton + public partial class BaseContainer : OsuAnimatedButton { public BaseContainer(string text) { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuButton.cs deleted file mode 100644 index 9d086cce5c..0000000000 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuButton.cs +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using NUnit.Framework; -using osu.Framework.Graphics; -using osu.Game.Graphics.UserInterface; -using osuTK; - -namespace osu.Game.Tests.Visual.UserInterface -{ - public class TestSceneOsuButton : OsuTestScene - { - [Test] - public void TestToggleEnabled() - { - OsuButton button = null; - - AddStep("add button", () => Child = button = new OsuButton - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(200), - Text = "Button" - }); - - AddToggleStep("toggle enabled", toggle => - { - for (int i = 0; i < 6; i++) - button.Action = toggle ? () => { } : (Action)null; - }); - } - - [Test] - public void TestInitiallyDisabled() - { - AddStep("add button", () => Child = new OsuButton - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(200), - Text = "Button" - }); - } - } -} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuDropdown.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuDropdown.cs index cce0295413..770b9dece1 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuDropdown.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuDropdown.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneOsuDropdown : ThemeComparisonTestScene + public partial class TestSceneOsuDropdown : ThemeComparisonTestScene { protected override Drawable CreateContent() => new OsuEnumDropdown diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuFont.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuFont.cs index eedafce271..7b3f97a16e 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuFont.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuFont.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -11,7 +13,7 @@ using osu.Game.Graphics.Sprites; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneOsuFont : OsuTestScene + public partial class TestSceneOsuFont : OsuTestScene { private OsuSpriteText spriteText; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuHoverContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuHoverContainer.cs index 396bec51b6..ab5a70f448 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuHoverContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuHoverContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -12,7 +14,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneOsuHoverContainer : OsuManualInputManagerTestScene + public partial class TestSceneOsuHoverContainer : OsuManualInputManagerTestScene { private OsuHoverTestContainer hoverContainer; private Box colourContainer; @@ -179,7 +181,7 @@ namespace osu.Game.Tests.Visual.UserInterface private void doMoveOut() => InputManager.MoveMouseTo(new Vector2(InputManager.ScreenSpaceDrawQuad.TopLeft.X, InputManager.ScreenSpaceDrawQuad.TopLeft.Y)); - private sealed class OsuHoverTestContainer : OsuHoverContainer + private sealed partial class OsuHoverTestContainer : OsuHoverContainer { public static readonly Color4 HOVER_COLOUR = Color4.Red; public static readonly Color4 IDLE_COLOUR = Color4.Green; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.cs index 0bc4ac12d6..a1254678b7 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Diagnostics; using System.Reflection; using NUnit.Framework; @@ -19,7 +21,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneOsuIcon : OsuTestScene + public partial class TestSceneOsuIcon : OsuTestScene { public TestSceneOsuIcon() { @@ -58,7 +60,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("change icons", () => flow.Children.ForEach(i => i.SpriteIcon.Icon = new IconUsage((char)(i.SpriteIcon.Icon.Icon + 1)))); } - private class Icon : Container, IHasTooltip + private partial class Icon : Container, IHasTooltip { public LocalisableString TooltipText { get; } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuLogo.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuLogo.cs index 8b91339479..24a27f71e8 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuLogo.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuLogo.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Screens.Menu; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneOsuLogo : OsuTestScene + public partial class TestSceneOsuLogo : OsuTestScene { [Test] public void TestBasic() diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMarkdownContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMarkdownContainer.cs index 82e26cb87d..1e2485c6e2 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMarkdownContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMarkdownContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osu.Game.Overlays; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneOsuMarkdownContainer : OsuTestScene + public partial class TestSceneOsuMarkdownContainer : OsuTestScene { private OsuMarkdownContainer markdownContainer; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMenu.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMenu.cs index 387deea76c..addaab5d35 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMenu.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMenu.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneOsuMenu : OsuManualInputManagerTestScene + public partial class TestSceneOsuMenu : OsuManualInputManagerTestScene { private OsuMenu menu; private bool actionPerformed; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuPopover.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuPopover.cs index 1848cf6a5e..4ff7befe71 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuPopover.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuPopover.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Graphics; @@ -16,7 +18,7 @@ using osuTK; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneOsuPopover : OsuGridTestScene + public partial class TestSceneOsuPopover : OsuGridTestScene { public TestSceneOsuPopover() : base(1, 2) @@ -31,7 +33,7 @@ namespace osu.Game.Tests.Visual.UserInterface Text = @"No OverlayColourProvider", Font = OsuFont.Default.With(size: 40) }, - new TriangleButtonWithPopover() + new RoundedButtonWithPopover() } }; @@ -48,15 +50,15 @@ namespace osu.Game.Tests.Visual.UserInterface Text = @"With OverlayColourProvider (orange)", Font = OsuFont.Default.With(size: 40) }, - new TriangleButtonWithPopover() + new RoundedButtonWithPopover() } } }; } - private class TriangleButtonWithPopover : TriangleButton, IHasPopover + private partial class RoundedButtonWithPopover : RoundedButton, IHasPopover { - public TriangleButtonWithPopover() + public RoundedButtonWithPopover() { Width = 100; Height = 30; @@ -89,7 +91,7 @@ namespace osu.Game.Tests.Visual.UserInterface }; } - private class ColourProvidingContainer : Container + private partial class ColourProvidingContainer : Container { [Cached] private OverlayColourProvider provider { get; } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs index 353f84c546..929537e675 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -14,7 +16,7 @@ using osuTK; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneOsuTextBox : ThemeComparisonTestScene + public partial class TestSceneOsuTextBox : ThemeComparisonTestScene { private IEnumerable numberBoxes => this.ChildrenOfType(); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayContainer.cs new file mode 100644 index 0000000000..d9c2774611 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayContainer.cs @@ -0,0 +1,106 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays.Volume; +using osuTK; +using osuTK.Graphics; +using osuTK.Input; +using Box = osu.Framework.Graphics.Shapes.Box; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneOverlayContainer : OsuManualInputManagerTestScene + { + [SetUp] + public void SetUp() => Schedule(() => Child = new TestOverlay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.5f) + }); + + [Test] + public void TestScrollBlocked() + { + OsuScrollContainer scroll = null!; + + AddStep("add scroll container", () => + { + Add(scroll = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue, + Child = new Box + { + RelativeSizeAxes = Axes.X, + Height = DrawHeight * 10, + Colour = ColourInfo.GradientVertical(Colour4.Black, Colour4.White), + } + }); + }); + + AddStep("perform scroll", () => + { + InputManager.MoveMouseTo(Content); + InputManager.ScrollVerticalBy(-10); + }); + + AddAssert("scroll didn't receive input", () => scroll.Current == 0); + } + + [Test] + public void TestAltScrollNotBlocked() + { + bool scrollReceived = false; + + AddStep("add volume control receptor", () => Add(new VolumeControlReceptor + { + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue, + ScrollActionRequested = (_, _, _) => scrollReceived = true, + })); + + AddStep("hold alt", () => InputManager.PressKey(Key.AltLeft)); + AddStep("perform scroll", () => + { + InputManager.MoveMouseTo(Content); + InputManager.ScrollVerticalBy(10); + }); + + AddAssert("receptor received scroll input", () => scrollReceived); + AddStep("release alt", () => InputManager.ReleaseKey(Key.AltLeft)); + } + + private partial class TestOverlay : OsuFocusedOverlayContainer + { + [BackgroundDependencyLoader] + private void load() + { + State.Value = Visibility.Visible; + + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Overlay content", + Colour = Color4.Black, + }, + }; + } + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeader.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeader.cs index 2a76b8e265..e90041774e 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeader.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Containers; using osu.Game.Overlays; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneOverlayHeader : OsuTestScene + public partial class TestSceneOverlayHeader : OsuTestScene { private readonly FillFlowContainer flow; @@ -68,7 +70,7 @@ namespace osu.Game.Tests.Visual.UserInterface }); } - private class ColourProvidedContainer : Container + private partial class ColourProvidedContainer : Container { [Cached] private readonly OverlayColourProvider colourProvider; @@ -83,7 +85,7 @@ namespace osu.Game.Tests.Visual.UserInterface } } - private class TestNoBackgroundHeader : OverlayHeader + private partial class TestNoBackgroundHeader : OverlayHeader { protected override OverlayTitle CreateTitle() => new TestTitle(); @@ -93,20 +95,20 @@ namespace osu.Game.Tests.Visual.UserInterface } } - private class TestNoControlHeader : OverlayHeader + private partial class TestNoControlHeader : OverlayHeader { protected override Drawable CreateBackground() => new OverlayHeaderBackground(@"Headers/changelog"); protected override OverlayTitle CreateTitle() => new TestTitle(); } - private class TestStringTabControlHeader : TabControlOverlayHeader + private partial class TestStringTabControlHeader : TabControlOverlayHeader { protected override Drawable CreateBackground() => new OverlayHeaderBackground(@"Headers/news"); protected override OverlayTitle CreateTitle() => new TestTitle(); - protected override Drawable CreateTitleContent() => new OverlayRulesetSelector(); + protected override Drawable CreateTabControlContent() => new OverlayRulesetSelector(); public TestStringTabControlHeader() { @@ -115,7 +117,7 @@ namespace osu.Game.Tests.Visual.UserInterface } } - private class TestEnumTabControlHeader : TabControlOverlayHeader + private partial class TestEnumTabControlHeader : TabControlOverlayHeader { public TestEnumTabControlHeader() { @@ -134,7 +136,7 @@ namespace osu.Game.Tests.Visual.UserInterface Tabs } - private class TestBreadcrumbControlHeader : BreadcrumbControlOverlayHeader + private partial class TestBreadcrumbControlHeader : BreadcrumbControlOverlayHeader { protected override OverlayTitle CreateTitle() => new TestTitle(); @@ -148,7 +150,7 @@ namespace osu.Game.Tests.Visual.UserInterface } } - private class TestTitle : OverlayTitle + private partial class TestTitle : OverlayTitle { public TestTitle() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeaderBackground.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeaderBackground.cs index db414d23a0..7a445427f5 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeaderBackground.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeaderBackground.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Containers; using osu.Game.Overlays; using osu.Framework.Graphics; @@ -8,7 +10,7 @@ using osuTK; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneOverlayHeaderBackground : OsuTestScene + public partial class TestSceneOverlayHeaderBackground : OsuTestScene { public TestSceneOverlayHeaderBackground() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayRulesetSelector.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayRulesetSelector.cs index f4fa41a3b7..432e448038 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayRulesetSelector.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayRulesetSelector.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; @@ -16,7 +18,7 @@ using osu.Framework.Allocation; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneOverlayRulesetSelector : OsuTestScene + public partial class TestSceneOverlayRulesetSelector : OsuTestScene { private readonly OverlayRulesetSelector selector; private readonly Bindable ruleset = new Bindable(); @@ -42,7 +44,7 @@ namespace osu.Game.Tests.Visual.UserInterface }); } - private class ColourProvidedContainer : Container + private partial class ColourProvidedContainer : Container { [Cached] private readonly OverlayColourProvider colourProvider; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayScrollContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayScrollContainer.cs index 7fa730e02b..77e7178c9e 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayScrollContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayScrollContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Containers; using osu.Game.Overlays; using osu.Framework.Graphics; @@ -13,7 +15,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneOverlayScrollContainer : OsuManualInputManagerTestScene + public partial class TestSceneOverlayScrollContainer : OsuManualInputManagerTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); @@ -59,6 +61,18 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("scroll to 500", () => scroll.ScrollTo(500)); AddUntilStep("scrolled to 500", () => Precision.AlmostEquals(scroll.Current, 500, 0.1f)); AddAssert("button is visible", () => scroll.Button.State == Visibility.Visible); + + AddStep("click button", () => + { + InputManager.MoveMouseTo(scroll.Button); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("button is visible", () => scroll.Button.State == Visibility.Visible); + + AddStep("user scroll down by 1", () => InputManager.ScrollVerticalBy(-1)); + + AddAssert("button is hidden", () => scroll.Button.State == Visibility.Hidden); } [Test] @@ -69,6 +83,10 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("invoke action", () => scroll.Button.Action.Invoke()); AddUntilStep("scrolled back to start", () => Precision.AlmostEquals(scroll.Current, 0, 0.1f)); + + AddStep("invoke action", () => scroll.Button.Action.Invoke()); + + AddAssert("scrolled to end", () => scroll.IsScrolledToEnd()); } [Test] @@ -83,6 +101,14 @@ namespace osu.Game.Tests.Visual.UserInterface }); AddUntilStep("scrolled back to start", () => Precision.AlmostEquals(scroll.Current, 0, 0.1f)); + + AddStep("click button", () => + { + InputManager.MoveMouseTo(scroll.Button); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("scrolled to end", () => scroll.IsScrolledToEnd()); } [Test] @@ -95,12 +121,12 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("hover button", () => InputManager.MoveMouseTo(scroll.Button)); AddRepeatStep("click button", () => InputManager.Click(MouseButton.Left), 3); - AddAssert("invocation count is 1", () => invocationCount == 1); + AddAssert("invocation count is 3", () => invocationCount == 3); } - private class TestScrollContainer : OverlayScrollContainer + private partial class TestScrollContainer : OverlayScrollContainer { - public new ScrollToTopButton Button => base.Button; + public new ScrollBackButton Button => base.Button; } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestScenePageSelector.cs b/osu.Game.Tests/Visual/UserInterface/TestScenePageSelector.cs index c99ac52cb1..b9e3592389 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestScenePageSelector.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestScenePageSelector.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -11,7 +13,7 @@ using osu.Game.Overlays; namespace osu.Game.Tests.Visual.UserInterface { - public class TestScenePageSelector : OsuTestScene + public partial class TestScenePageSelector : OsuTestScene { [Cached] private OverlayColourProvider provider { get; } = new OverlayColourProvider(OverlayColourScheme.Green); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneParallaxContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneParallaxContainer.cs index 588b25c02d..92d4981d4a 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneParallaxContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneParallaxContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Game.Graphics.Containers; @@ -8,7 +10,7 @@ using osu.Game.Screens.Backgrounds; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneParallaxContainer : OsuTestScene + public partial class TestSceneParallaxContainer : OsuTestScene { public TestSceneParallaxContainer() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestScenePlaylistOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestScenePlaylistOverlay.cs index 84a06d97df..c723988d6a 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestScenePlaylistOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestScenePlaylistOverlay.cs @@ -1,29 +1,44 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using System.Linq; using NUnit.Framework; -using osu.Framework.Bindables; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Collections; using osu.Game.Database; -using osu.Game.Graphics.Containers; using osu.Game.Overlays.Music; +using osu.Game.Rulesets; using osu.Game.Tests.Resources; using osuTK; using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - public class TestScenePlaylistOverlay : OsuManualInputManagerTestScene + public partial class TestScenePlaylistOverlay : OsuManualInputManagerTestScene { - private readonly BindableList> beatmapSets = new BindableList>(); + protected override bool UseFreshStoragePerRun => true; - private PlaylistOverlay playlistOverlay; + private PlaylistOverlay playlistOverlay = null!; - private Live first; + private BeatmapManager beatmapManager = null!; + + private const int item_count = 20; + + private List beatmapSets => beatmapManager.GetAllUsableBeatmapSets(); + + [BackgroundDependencyLoader] + private void load(GameHost host) + { + Dependencies.Cache(new RealmRulesetStore(Realm)); + Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, Audio, Resources, host, Beatmap.Default)); + Dependencies.Cache(Realm); + } [SetUp] public void Setup() => Schedule(() => @@ -42,26 +57,36 @@ namespace osu.Game.Tests.Visual.UserInterface } }; - beatmapSets.Clear(); - - for (int i = 0; i < 100; i++) + for (int i = 0; i < item_count; i++) { - beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo().ToLiveUnmanaged()); + beatmapManager.Import(TestResources.CreateTestBeatmapSetInfo()); } - first = beatmapSets.First(); + beatmapSets.First().ToLive(Realm); - playlistOverlay.BeatmapSets.BindTo(beatmapSets); + // Ensure all the initial imports are present before running any tests. + Realm.Run(r => r.Refresh()); }); [Test] public void TestRearrangeItems() { + AddUntilStep("wait for load complete", () => + { + return this + .ChildrenOfType() + .Count(i => i.ChildrenOfType().First().DelayedLoadCompleted) > 6; + }); + AddUntilStep("wait for animations to complete", () => !playlistOverlay.Transforms.Any()); + PlaylistItem firstItem = null!; + AddStep("hold 1st item handle", () => { - var handle = this.ChildrenOfType>.PlaylistItemHandle>().First(); + firstItem = this.ChildrenOfType().First(); + var handle = firstItem.ChildrenOfType().First(); + InputManager.MoveMouseTo(handle.ScreenSpaceDrawQuad.Centre); InputManager.PressButton(MouseButton.Left); }); @@ -72,7 +97,7 @@ namespace osu.Game.Tests.Visual.UserInterface InputManager.MoveMouseTo(item.ScreenSpaceDrawQuad.BottomLeft); }); - AddAssert("song 1 is 5th", () => beatmapSets[4].Equals(first)); + AddAssert("first is moved", () => playlistOverlay.ChildrenOfType().Single().Items.ElementAt(4).Value.Equals(firstItem.Model.Value)); AddStep("release handle", () => InputManager.ReleaseButton(MouseButton.Left)); } @@ -90,6 +115,68 @@ namespace osu.Game.Tests.Visual.UserInterface () => playlistOverlay.ChildrenOfType() .Where(item => item.MatchingFilter) .All(item => item.FilterTerms.Any(term => term.ToString().Contains("10")))); + + AddStep("Import new non-matching beatmap", () => + { + var testBeatmapSetInfo = TestResources.CreateTestBeatmapSetInfo(1); + testBeatmapSetInfo.Beatmaps.Single().Metadata.Title = "no guid"; + beatmapManager.Import(testBeatmapSetInfo); + }); + + AddStep("Force realm refresh", () => Realm.Run(r => r.Refresh())); + + AddAssert("results filtered correctly", + () => playlistOverlay.ChildrenOfType() + .Where(item => item.MatchingFilter) + .All(item => item.FilterTerms.Any(term => term.ToString().Contains("10")))); + } + + [Test] + public void TestCollectionFiltering() + { + NowPlayingCollectionDropdown collectionDropdown() => playlistOverlay.ChildrenOfType().Single(); + + AddStep("Add collection", () => + { + Realm.Write(r => + { + r.RemoveAll(); + r.Add(new BeatmapCollection("wang")); + }); + }); + + AddUntilStep("wait for dropdown to have new collection", () => collectionDropdown().Items.Count() == 2); + + AddStep("Filter to collection", () => + { + collectionDropdown().Current.Value = collectionDropdown().Items.Last(); + }); + + AddUntilStep("No items present", () => !playlistOverlay.ChildrenOfType().Any(i => i.MatchingFilter)); + + AddStep("Import new non-matching beatmap", () => + { + beatmapManager.Import(TestResources.CreateTestBeatmapSetInfo(1)); + }); + + AddStep("Force realm refresh", () => Realm.Run(r => r.Refresh())); + + AddUntilStep("No items matching", () => !playlistOverlay.ChildrenOfType().Any(i => i.MatchingFilter)); + + BeatmapSetInfo collectionAddedBeatmapSet = null!; + + AddStep("Import new matching beatmap", () => + { + collectionAddedBeatmapSet = TestResources.CreateTestBeatmapSetInfo(1); + + beatmapManager.Import(collectionAddedBeatmapSet); + Realm.Write(r => r.All().First().BeatmapMD5Hashes.Add(collectionAddedBeatmapSet.Beatmaps.First().MD5Hash)); + }); + + AddStep("Force realm refresh", () => Realm.Run(r => r.Refresh())); + + AddUntilStep("Only matching item", + () => playlistOverlay.ChildrenOfType().Where(i => i.MatchingFilter).Select(i => i.Model.ID), () => Is.EquivalentTo(new[] { collectionAddedBeatmapSet.ID })); } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs b/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs index b5f2544071..9537ab63be 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; @@ -11,7 +13,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - public class TestScenePopupDialog : OsuManualInputManagerTestScene + public partial class TestScenePopupDialog : OsuManualInputManagerTestScene { private TestPopupDialog dialog; @@ -50,7 +52,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("release button", () => InputManager.ReleaseButton(MouseButton.Left)); } - private class TestPopupDialog : PopupDialog + private partial class TestPopupDialog : PopupDialog { public PopupDialogDangerousButton DangerousButton { get; } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneProfileSubsectionHeader.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneProfileSubsectionHeader.cs index 4ce684d5af..c51095f360 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneProfileSubsectionHeader.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneProfileSubsectionHeader.cs @@ -11,12 +11,12 @@ using osu.Framework.Allocation; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneProfileSubsectionHeader : OsuTestScene + public partial class TestSceneProfileSubsectionHeader : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); - private ProfileSubsectionHeader header; + private ProfileSubsectionHeader header = null!; [Test] public void TestHiddenCounter() diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneRangeSlider.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneRangeSlider.cs new file mode 100644 index 0000000000..b780764e7f --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneRangeSlider.cs @@ -0,0 +1,79 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneRangeSlider : OsuTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Red); + + private readonly BindableNumber customStart = new BindableNumber + { + MinValue = 0, + MaxValue = 100, + Precision = 0.1f + }; + + private readonly BindableNumber customEnd = new BindableNumber(100) + { + MinValue = 0, + MaxValue = 100, + Precision = 0.1f + }; + + private RangeSlider rangeSlider = null!; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create control", () => Child = rangeSlider = new RangeSlider + { + Width = 200, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(3), + LowerBound = customStart, + UpperBound = customEnd, + TooltipSuffix = "suffix", + NubWidth = Nub.HEIGHT * 2, + DefaultStringLowerBound = "Start", + DefaultStringUpperBound = "End", + MinRange = 10 + }); + } + + [Test] + public void TestAdjustRange() + { + AddAssert("Initial lower bound is correct", () => rangeSlider.LowerBound.Value, () => Is.EqualTo(0).Within(0.1f)); + AddAssert("Initial upper bound is correct", () => rangeSlider.UpperBound.Value, () => Is.EqualTo(100).Within(0.1f)); + + AddStep("Adjust range", () => + { + customStart.Value = 50; + customEnd.Value = 75; + }); + + AddAssert("Adjusted lower bound is correct", () => rangeSlider.LowerBound.Value, () => Is.EqualTo(50).Within(0.1f)); + AddAssert("Adjusted upper bound is correct", () => rangeSlider.UpperBound.Value, () => Is.EqualTo(75).Within(0.1f)); + + AddStep("Test nub pushing", () => + { + customStart.Value = 90; + }); + + AddAssert("Pushed lower bound is correct", () => rangeSlider.LowerBound.Value, () => Is.EqualTo(90).Within(0.1f)); + AddAssert("Pushed upper bound is correct", () => rangeSlider.UpperBound.Value, () => Is.EqualTo(100).Within(0.1f)); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneRankingsSortTabControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneRankingsSortTabControl.cs index 24bc0dbc97..f364a48616 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneRankingsSortTabControl.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneRankingsSortTabControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Overlays; @@ -8,7 +10,7 @@ using osu.Game.Overlays.Rankings; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneRankingsSortTabControl : OsuTestScene + public partial class TestSceneRankingsSortTabControl : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneRoundedButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneRoundedButton.cs index 454a71e6d2..cbf67c49a6 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneRoundedButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneRoundedButton.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.Linq; using NUnit.Framework; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; -using osu.Game.Graphics; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Overlays; using osu.Game.Overlays.Settings; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneRoundedButton : ThemeComparisonTestScene + public partial class TestSceneRoundedButton : ThemeComparisonTestScene { private readonly BindableBool enabled = new BindableBool(true); @@ -35,7 +36,7 @@ namespace osu.Game.Tests.Visual.UserInterface }, new SettingsButton { - Text = "Test button", + Text = "Test settings button", Anchor = Anchor.Centre, Origin = Anchor.Centre, Enabled = { BindTarget = enabled }, @@ -54,8 +55,8 @@ namespace osu.Game.Tests.Visual.UserInterface public void TestBackgroundColour() { AddStep("set red scheme", () => CreateThemedContent(OverlayColourScheme.Red)); - AddAssert("rounded button has correct colour", () => Cell(0, 1).ChildrenOfType().First().BackgroundColour == new OsuColour().Blue3); - AddAssert("settings button has correct colour", () => Cell(0, 1).ChildrenOfType().First().BackgroundColour == new OverlayColourProvider(OverlayColourScheme.Red).Highlight1); + AddAssert("rounded button has correct colour", () => Cell(0, 1).ChildrenOfType().First().BackgroundColour == new OverlayColourProvider(OverlayColourScheme.Red).Colour3); + AddAssert("settings button has correct colour", () => Cell(0, 1).ChildrenOfType().First().BackgroundColour == new OverlayColourProvider(OverlayColourScheme.Red).Colour3); } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSafeAreaHandling.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSafeAreaHandling.cs index 8b4e3f6d3a..453cdd1357 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneSafeAreaHandling.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSafeAreaHandling.cs @@ -1,17 +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 NUnit.Framework; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Game.Configuration; using osu.Game.Overlays.Settings; using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneSafeAreaHandling : OsuGameTestScene + public partial class TestSceneSafeAreaHandling : OsuGameTestScene { private SafeAreaDefiningContainer safeAreaContainer; @@ -22,6 +25,8 @@ namespace osu.Game.Tests.Visual.UserInterface private readonly Bindable safeAreaPaddingLeft = new BindableFloat { MinValue = 0, MaxValue = 200 }; private readonly Bindable safeAreaPaddingRight = new BindableFloat { MinValue = 0, MaxValue = 200 }; + private readonly Bindable applySafeAreaConsiderations = new Bindable(true); + protected override void LoadComplete() { base.LoadComplete(); @@ -82,6 +87,11 @@ namespace osu.Game.Tests.Visual.UserInterface Current = safeAreaPaddingRight, LabelText = "Right" }, + new SettingsCheckbox + { + LabelText = "Apply", + Current = applySafeAreaConsiderations, + }, } } } @@ -91,6 +101,7 @@ namespace osu.Game.Tests.Visual.UserInterface safeAreaPaddingBottom.BindValueChanged(_ => updateSafeArea()); safeAreaPaddingLeft.BindValueChanged(_ => updateSafeArea()); safeAreaPaddingRight.BindValueChanged(_ => updateSafeArea()); + applySafeAreaConsiderations.BindValueChanged(_ => updateSafeArea()); }); base.SetUpSteps(); @@ -105,6 +116,8 @@ namespace osu.Game.Tests.Visual.UserInterface Left = safeAreaPaddingLeft.Value, Right = safeAreaPaddingRight.Value, }; + + Game.LocalConfig.SetValue(OsuSetting.SafeAreaConsiderations, applySafeAreaConsiderations.Value); } [Test] diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneScalingContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneScalingContainer.cs index 5d554719a5..26fa26a0b8 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneScalingContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneScalingContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -15,7 +17,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneScalingContainer : OsuTestScene + public partial class TestSceneScalingContainer : OsuTestScene { private OsuConfigManager osuConfigManager { get; set; } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneScreenBreadcrumbControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneScreenBreadcrumbControl.cs index 77a7d819a9..3d35f2c9cc 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneScreenBreadcrumbControl.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneScreenBreadcrumbControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -9,13 +11,14 @@ using osu.Framework.Screens; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Screens; using osuTK; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneScreenBreadcrumbControl : OsuTestScene + public partial class TestSceneScreenBreadcrumbControl : OsuTestScene { private readonly ScreenBreadcrumbControl breadcrumbs; private readonly OsuScreenStack screenStack; @@ -77,7 +80,7 @@ namespace osu.Game.Tests.Visual.UserInterface private void pushNext() => AddStep(@"push next screen", () => ((TestScreen)screenStack.CurrentScreen).PushNext()); private void waitForCurrent() => AddUntilStep("current screen", () => screenStack.CurrentScreen.IsCurrentScreen()); - private abstract class TestScreen : OsuScreen + private abstract partial class TestScreen : OsuScreen { protected abstract string NextTitle { get; } protected abstract TestScreen CreateNextScreen(); @@ -107,7 +110,7 @@ namespace osu.Game.Tests.Visual.UserInterface Origin = Anchor.TopCentre, Text = Title, }, - new TriangleButton + new RoundedButton { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, @@ -120,14 +123,14 @@ namespace osu.Game.Tests.Visual.UserInterface } } - private class TestScreenOne : TestScreen + private partial class TestScreenOne : TestScreen { public override string Title => @"Screen One"; protected override string NextTitle => @"Two"; protected override TestScreen CreateNextScreen() => new TestScreenTwo(); } - private class TestScreenTwo : TestScreen + private partial class TestScreenTwo : TestScreen { public override string Title => @"Screen Two"; protected override string NextTitle => @"One"; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs index 1f3736bd9b..05fffc903d 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Extensions.Color4Extensions; @@ -18,7 +20,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneSectionsContainer : OsuManualInputManagerTestScene + public partial class TestSceneSectionsContainer : OsuManualInputManagerTestScene { private SectionsContainer container; private float custom; @@ -194,7 +196,7 @@ namespace osu.Game.Tests.Visual.UserInterface InputManager.ScrollVerticalBy(direction); } - private class TestSection : TestBox + private partial class TestSection : TestBox { public bool Selected { @@ -208,7 +210,7 @@ namespace osu.Game.Tests.Visual.UserInterface } } - private class TestBox : Container + private partial class TestBox : Container { private readonly Box background; private readonly OsuSpriteText text; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSegmentedGraph.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSegmentedGraph.cs new file mode 100644 index 0000000000..1144b9053d --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSegmentedGraph.cs @@ -0,0 +1,161 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneSegmentedGraph : OsuTestScene + { + private readonly SegmentedGraph graph; + + public TestSceneSegmentedGraph() + { + Children = new Drawable[] + { + graph = new SegmentedGraph(6) + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(1, 0.5f), + }, + }; + + graph.TierColours = new[] + { + Colour4.Red, + Colour4.OrangeRed, + Colour4.Orange, + Colour4.Yellow, + Colour4.YellowGreen, + Colour4.Green + }; + + AddStep("values from 1-10", () => graph.Values = Enumerable.Range(1, 10).ToArray()); + AddStep("values from 1-100", () => graph.Values = Enumerable.Range(1, 100).ToArray()); + AddStep("values from 1-500", () => graph.Values = Enumerable.Range(1, 500).ToArray()); + AddStep("sin() function of size 100", () => sinFunction()); + AddStep("sin() function of size 500", () => sinFunction(500)); + AddStep("bumps of size 100", () => bumps()); + AddStep("bumps of size 500", () => bumps(500)); + AddStep("100 random values", () => randomValues()); + AddStep("500 random values", () => randomValues(500)); + AddStep("beatmap density with granularity of 200", () => beatmapDensity()); + AddStep("beatmap density with granularity of 300", () => beatmapDensity(300)); + AddStep("reversed values from 1-10", () => graph.Values = Enumerable.Range(1, 10).Reverse().ToArray()); + AddStep("change tier colours", () => + { + graph.TierColours = new[] + { + Colour4.White, + Colour4.LightBlue, + Colour4.Aqua, + Colour4.Blue + }; + }); + AddStep("reset tier colours", () => + { + graph.TierColours = new[] + { + Colour4.Red, + Colour4.OrangeRed, + Colour4.Orange, + Colour4.Yellow, + Colour4.YellowGreen, + Colour4.Green + }; + }); + + AddStep("set graph colour to blue", () => graph.Colour = Colour4.Blue); + AddStep("set graph colour to transparent", () => graph.Colour = Colour4.Transparent); + AddStep("set graph colour to vertical gradient", () => graph.Colour = ColourInfo.GradientVertical(Colour4.White, Colour4.Black)); + AddStep("set graph colour to horizontal gradient", () => graph.Colour = ColourInfo.GradientHorizontal(Colour4.White, Colour4.Black)); + AddStep("reset graph colour", () => graph.Colour = Colour4.White); + } + + private void sinFunction(int size = 100) + { + const int max_value = 255; + graph.Values = new int[size]; + + float step = 2 * MathF.PI / size; + float x = 0; + + for (int i = 0; i < size; i++) + { + graph.Values[i] = (int)(max_value * MathF.Sin(x)); + x += step; + } + } + + private void bumps(int size = 100) + { + const int max_value = 255; + graph.Values = new int[size]; + + float step = 2 * MathF.PI / size; + float x = 0; + + for (int i = 0; i < size; i++) + { + graph.Values[i] = (int)(max_value * Math.Abs(MathF.Sin(x))); + x += step; + } + } + + private void randomValues(int size = 100) + { + Random rng = new Random(); + + graph.Values = new int[size]; + + for (int i = 0; i < size; i++) + { + graph.Values[i] = rng.Next(255); + } + } + + private void beatmapDensity(int granularity = 200) + { + var ruleset = new OsuRuleset(); + var beatmap = CreateBeatmap(ruleset.RulesetInfo); + IEnumerable objects = beatmap.HitObjects; + + // Taken from SongProgressGraph + graph.Values = new int[granularity]; + + if (!objects.Any()) + return; + + double firstHit = objects.First().StartTime; + double lastHit = objects.Max(o => o.GetEndTime()); + + if (lastHit == 0) + lastHit = objects.Last().StartTime; + + double interval = (lastHit - firstHit + 1) / granularity; + + foreach (var h in objects) + { + double endTime = h.GetEndTime(); + + Debug.Assert(endTime >= h.StartTime); + + int startRange = (int)((h.StartTime - firstHit) / interval); + int endRange = (int)((endTime - firstHit) / interval); + for (int i = startRange; i <= endRange; i++) + graph.Values[i]++; + } + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsCheckbox.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsCheckbox.cs index fb04c5bad0..a0fe5fce32 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsCheckbox.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsCheckbox.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -13,7 +15,7 @@ using osuTK; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneSettingsCheckbox : OsuTestScene + public partial class TestSceneSettingsCheckbox : OsuTestScene { [TestCase] public void TestCheckbox() @@ -54,7 +56,7 @@ namespace osu.Game.Tests.Visual.UserInterface }); } - private class OverlayColourContainer : Container + private partial class OverlayColourContainer : Container { [Cached] private OverlayColourProvider colourProvider; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsToolboxGroup.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsToolboxGroup.cs index 8ef24e58a0..f96d2feba8 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsToolboxGroup.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsToolboxGroup.cs @@ -1,28 +1,38 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Overlays; using osu.Game.Overlays.Settings; +using osuTK; using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneSettingsToolboxGroup : OsuManualInputManagerTestScene + public partial class TestSceneSettingsToolboxGroup : OsuManualInputManagerTestScene { private SettingsToolboxGroup group; + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + [SetUp] public void SetUp() => Schedule(() => { Child = group = new SettingsToolboxGroup("example") { + Scale = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, Children = new Drawable[] { new RoundedButton @@ -45,6 +55,16 @@ namespace osu.Game.Tests.Visual.UserInterface }; }); + [Test] + public void TestDisplay() + { + AddRepeatStep("toggle expanded state", () => + { + InputManager.MoveMouseTo(group.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }, 5); + } + [Test] public void TestClickExpandButtonMultipleTimes() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs index 5a4eeef4d9..118d32ee70 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedButtons.cs @@ -1,9 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osuTK.Input; @@ -11,7 +17,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneShearedButtons : OsuManualInputManagerTestScene + public partial class TestSceneShearedButtons : OsuManualInputManagerTestScene { [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); @@ -97,7 +103,10 @@ namespace osu.Game.Tests.Visual.UserInterface Origin = Anchor.Centre, Text = "Fixed width" }); + AddAssert("draw width is 200", () => toggleButton.DrawWidth, () => Is.EqualTo(200).Within(Precision.FLOAT_EPSILON)); + AddStep("change text", () => toggleButton.Text = "New text"); + AddAssert("draw width is 200", () => toggleButton.DrawWidth, () => Is.EqualTo(200).Within(Precision.FLOAT_EPSILON)); AddStep("create auto-sizing button", () => Child = toggleButton = new ShearedToggleButton { @@ -105,7 +114,14 @@ namespace osu.Game.Tests.Visual.UserInterface Origin = Anchor.Centre, Text = "This button autosizes to its text!" }); + AddAssert("button is wider than text", () => toggleButton.DrawWidth, () => Is.GreaterThan(toggleButton.ChildrenOfType().Single().DrawWidth)); + + float originalDrawWidth = 0; + AddStep("store button width", () => originalDrawWidth = toggleButton.DrawWidth); + AddStep("change text", () => toggleButton.Text = "New text"); + AddAssert("button is wider than text", () => toggleButton.DrawWidth, () => Is.GreaterThan(toggleButton.ChildrenOfType().Single().DrawWidth)); + AddAssert("button width decreased", () => toggleButton.DrawWidth, () => Is.LessThan(originalDrawWidth)); } [Test] diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedOverlayContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedOverlayContainer.cs index 4dd64c6536..fb06760568 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedOverlayContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedOverlayContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -20,7 +22,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneShearedOverlayContainer : OsuManualInputManagerTestScene + public partial class TestSceneShearedOverlayContainer : OsuManualInputManagerTestScene { private TestShearedOverlayContainer overlay; @@ -64,7 +66,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("overlay dismissed", () => overlay.State.Value == Visibility.Hidden); } - public class TestShearedOverlayContainer : ShearedOverlayContainer + public partial class TestShearedOverlayContainer : ShearedOverlayContainer { public TestShearedOverlayContainer() : base(OverlayColourScheme.Green) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedOverlayHeader.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedOverlayHeader.cs index ef2b25cd92..aeea0681eb 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedOverlayHeader.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedOverlayHeader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -10,7 +12,7 @@ using osu.Game.Overlays; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneShearedOverlayHeader : OsuTestScene + public partial class TestSceneShearedOverlayHeader : OsuTestScene { [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedSearchTextBox.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedSearchTextBox.cs index e1dbf04133..0072864335 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedSearchTextBox.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedSearchTextBox.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -10,7 +12,7 @@ using osu.Game.Overlays; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneShearedSearchTextBox : OsuTestScene + public partial class TestSceneShearedSearchTextBox : OsuTestScene { [Test] public void TestAllColourSchemes() diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneShearedSliderBar.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedSliderBar.cs new file mode 100644 index 0000000000..766f22d867 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneShearedSliderBar.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.Bindables; +using osu.Framework.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneShearedSliderBar : OsuTestScene + { + [Cached] + private OverlayColourProvider colourProvider { get; set; } = new OverlayColourProvider(OverlayColourScheme.Purple); + + private readonly BindableDouble current = new BindableDouble(5) + { + Precision = 0.1f, + MinValue = 0, + MaxValue = 15 + }; + + [BackgroundDependencyLoader] + private void load() + { + Child = new ShearedSliderBar + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Current = current, + RelativeSizeAxes = Axes.X, + Width = 0.4f + }; + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSizePreservingSpriteText.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSizePreservingSpriteText.cs new file mode 100644 index 0000000000..7339ea7b23 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSizePreservingSpriteText.cs @@ -0,0 +1,94 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Globalization; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneSizePreservingSpriteText : OsuGridTestScene + { + private readonly List parentContainers = new List(); + private readonly List childContainers = new List(); + private readonly OsuSpriteText osuSpriteText = new OsuSpriteText(); + private readonly SizePreservingSpriteText sizePreservingSpriteText = new SizePreservingSpriteText(); + + public TestSceneSizePreservingSpriteText() + : base(1, 2) + { + for (int i = 0; i < 2; i++) + { + UprightAspectMaintainingContainer childContainer; + Container parentContainer = new Container + { + Origin = Anchor.BottomRight, + Anchor = Anchor.BottomCentre, + AutoSizeAxes = Axes.Both, + Rotation = 45, + Y = -200, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Red, + }, + childContainer = new UprightAspectMaintainingContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Blue, + }, + } + }, + } + }; + + Container cellInfo = new Container + { + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Margin = new MarginPadding + { + Top = 100, + }, + Child = new OsuSpriteText + { + Text = (i == 0) ? "OsuSpriteText" : "SizePreservingSpriteText", + Font = OsuFont.GetFont(Typeface.Inter, weight: FontWeight.Bold, size: 40), + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + }, + }; + + parentContainers.Add(parentContainer); + childContainers.Add(childContainer); + Cell(i).Add(cellInfo); + Cell(i).Add(parentContainer); + } + + childContainers[0].Add(osuSpriteText); + childContainers[1].Add(sizePreservingSpriteText); + osuSpriteText.Font = sizePreservingSpriteText.Font = OsuFont.GetFont(Typeface.Venera, weight: FontWeight.Bold, size: 20); + } + + protected override void Update() + { + base.Update(); + osuSpriteText.Text = sizePreservingSpriteText.Text = DateTime.Now.ToString(CultureInfo.InvariantCulture); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneStarRatingDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneStarRatingDisplay.cs index 2806e6d347..6988bac2ee 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneStarRatingDisplay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneStarRatingDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osuTK; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneStarRatingDisplay : OsuTestScene + public partial class TestSceneStarRatingDisplay : OsuTestScene { [TestCase(StarRatingDisplaySize.Regular)] [TestCase(StarRatingDisplaySize.Small)] @@ -27,7 +29,7 @@ namespace osu.Game.Tests.Visual.UserInterface AutoSizeAxes = Axes.Both, Spacing = new Vector2(2f), Direction = FillDirection.Horizontal, - ChildrenEnumerable = Enumerable.Range(0, 15).Select(i => new FillFlowContainer + ChildrenEnumerable = Enumerable.Range(-1, 15).Select(i => new FillFlowContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneStatefulMenuItem.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneStatefulMenuItem.cs index 18ec631f37..88187f1808 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneStatefulMenuItem.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneStatefulMenuItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Framework.Bindables; @@ -11,7 +13,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneStatefulMenuItem : OsuManualInputManagerTestScene + public partial class TestSceneStatefulMenuItem : OsuManualInputManagerTestScene { [Test] public void TestTernaryRadioMenuItem() diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSwitchButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSwitchButton.cs index 37fab75aee..f3ab5dbff8 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneSwitchButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSwitchButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -9,7 +11,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneSwitchButton : OsuManualInputManagerTestScene + public partial class TestSceneSwitchButton : OsuManualInputManagerTestScene { private SwitchButton switchButton; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneTabControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneTabControl.cs index a884741ff8..24c4ed79b1 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneTabControl.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneTabControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; using osu.Framework.Graphics; using osu.Game.Graphics.Sprites; @@ -11,7 +13,7 @@ using osuTK; namespace osu.Game.Tests.Visual.UserInterface { [Description("SongSelect filter control")] - public class TestSceneTabControl : OsuTestScene + public partial class TestSceneTabControl : OsuTestScene { public TestSceneTabControl() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneToggleMenuItem.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneToggleMenuItem.cs index 9fb8e747f3..41a6f35624 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneToggleMenuItem.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneToggleMenuItem.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Graphics.UserInterface; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneToggleMenuItem : OsuTestScene + public partial class TestSceneToggleMenuItem : OsuTestScene { public TestSceneToggleMenuItem() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneToolbarRulesetSelector.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneToolbarRulesetSelector.cs index cdfbb14cba..651c5376b5 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneToolbarRulesetSelector.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneToolbarRulesetSelector.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Containers; using osu.Game.Overlays.Toolbar; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osu.Game.Rulesets; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneToolbarRulesetSelector : OsuTestScene + public partial class TestSceneToolbarRulesetSelector : OsuTestScene { [Resolved] private RulesetStore rulesets { get; set; } @@ -63,7 +65,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("mode line has moved", () => selector.ModeButtonLine.DrawPosition.X > 0); } - private class TestSelector : ToolbarRulesetSelector + private partial class TestSelector : ToolbarRulesetSelector { public new Drawable ModeButtonLine => base.ModeButtonLine; } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneTwoLayerButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneTwoLayerButton.cs index 849577186d..20b0ab5801 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneTwoLayerButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneTwoLayerButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -9,7 +11,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneTwoLayerButton : OsuTestScene + public partial class TestSceneTwoLayerButton : OsuTestScene { public TestSceneTwoLayerButton() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs index 7109a55e7e..48fe517f8a 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -19,7 +21,7 @@ using osuTK; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneUpdateableBeatmapBackgroundSprite : OsuTestScene + public partial class TestSceneUpdateableBeatmapBackgroundSprite : OsuTestScene { protected override bool UseOnlineAPI => true; @@ -136,7 +138,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("all unloaded", () => !loadedBackgrounds.Any()); } - private class TestUpdateableBeatmapBackgroundSprite : UpdateableBeatmapBackgroundSprite + private partial class TestUpdateableBeatmapBackgroundSprite : UpdateableBeatmapBackgroundSprite { protected override double UnloadDelay => 2000; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapSetCover.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapSetCover.cs index 7b6774a6b2..a1a546d4a7 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapSetCover.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapSetCover.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -18,7 +20,7 @@ using osuTK; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneUpdateableBeatmapSetCover : OsuTestScene + public partial class TestSceneUpdateableBeatmapSetCover : OsuTestScene { [Test] public void TestLocal([Values] BeatmapSetCoverType coverType) @@ -137,7 +139,7 @@ namespace osu.Game.Tests.Visual.UserInterface Covers = new BeatmapSetOnlineCovers { Cover = coverUrl } }; - private class TestUpdateableOnlineBeatmapSetCover : UpdateableOnlineBeatmapSetCover + private partial class TestUpdateableOnlineBeatmapSetCover : UpdateableOnlineBeatmapSetCover { private readonly int loadDelay; @@ -161,7 +163,7 @@ namespace osu.Game.Tests.Visual.UserInterface } } - private class TestOnlineBeatmapSetCover : OnlineBeatmapSetCover + private partial class TestOnlineBeatmapSetCover : OnlineBeatmapSetCover { private readonly int loadDelay; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneUprightAspectMaintainingContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneUprightAspectMaintainingContainer.cs new file mode 100644 index 0000000000..15570b7927 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneUprightAspectMaintainingContainer.cs @@ -0,0 +1,244 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics; +using osu.Framework.Utils; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics; +using osuTK.Graphics; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneUprightAspectMaintainingContainer : OsuGridTestScene + { + private const int rows = 3; + private const int columns = 4; + + private readonly ScaleMode[] scaleModeValues = { ScaleMode.NoScaling, ScaleMode.Horizontal, ScaleMode.Vertical }; + private readonly float[] scalingFactorValues = { 1.0f / 3, 1.0f / 2, 1.0f, 1.5f }; + + private readonly List> parentContainers = new List>(rows); + private readonly List> childContainers = new List>(rows); + + // Preferably should be set to (4 * 2^n) + private const int rotation_step_count = 3; + + private readonly List flipStates = new List(); + private readonly List rotationSteps = new List(); + private readonly List scaleSteps = new List(); + + public TestSceneUprightAspectMaintainingContainer() + : base(rows, columns) + { + for (int i = 0; i < rows; i++) + { + parentContainers.Add(new List()); + childContainers.Add(new List()); + + for (int j = 0; j < columns; j++) + { + UprightAspectMaintainingContainer child; + Container parent = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Height = 80, + Width = 80, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = new Color4(255, 0, 0, 160), + }, + new OsuSpriteText + { + Text = "Parent", + }, + child = new UprightAspectMaintainingContainer + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + AutoSizeAxes = Axes.Both, + + // These are the parameters being Tested + Scaling = scaleModeValues[i], + ScalingFactor = scalingFactorValues[j], + + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = new Color4(0, 0, 255, 160), + }, + new OsuSpriteText + { + Text = "Text", + Font = OsuFont.Numeric, + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Padding = new MarginPadding + { + Horizontal = 4, + Vertical = 4, + } + }, + } + } + } + }; + + Container cellInfo = new Container + { + Children = new Drawable[] + { + new OsuSpriteText + { + Text = "Scaling: " + scaleModeValues[i].ToString(), + }, + new OsuSpriteText + { + Text = "ScalingFactor: " + scalingFactorValues[j].ToString("0.00"), + Margin = new MarginPadding + { + Top = 15, + }, + }, + }, + }; + + Cell(i * columns + j).Add(cellInfo); + Cell(i * columns + j).Add(parent); + parentContainers[i].Add(parent); + childContainers[i].Add(child); + } + } + + flipStates.AddRange(new[] { 1, -1 }); + rotationSteps.AddRange(Enumerable.Range(0, rotation_step_count).Select(x => 360f * ((float)x / rotation_step_count))); + scaleSteps.AddRange(new[] { 1, 0.3f, 1.5f }); + } + + [Test] + public void ExplicitlySizedParent() + { + var parentStates = from xFlip in flipStates + from yFlip in flipStates + from xScale in scaleSteps + from yScale in scaleSteps + from rotation in rotationSteps + select new { xFlip, yFlip, xScale, yScale, rotation }; + + foreach (var state in parentStates) + { + Vector2 parentScale = new Vector2(state.xFlip * state.xScale, state.yFlip * state.yScale); + float parentRotation = state.rotation; + + AddStep("S: (" + parentScale.X.ToString("0.00") + ", " + parentScale.Y.ToString("0.00") + "), R: " + parentRotation.ToString("0.00"), () => + { + foreach (List list in parentContainers) + { + foreach (Container container in list) + { + container.Scale = parentScale; + container.Rotation = parentRotation; + } + } + }); + + AddAssert("Check if state is valid", () => + { + foreach (int i in Enumerable.Range(0, parentContainers.Count)) + { + foreach (int j in Enumerable.Range(0, parentContainers[i].Count)) + { + if (!uprightAspectMaintainingContainerStateIsValid(parentContainers[i][j], childContainers[i][j])) + return false; + } + } + + return true; + }); + } + } + + private bool uprightAspectMaintainingContainerStateIsValid(Container parent, UprightAspectMaintainingContainer child) + { + Matrix3 parentMatrix = parent.DrawInfo.Matrix; + Matrix3 childMatrix = child.DrawInfo.Matrix; + Vector3 childScale = childMatrix.ExtractScale(); + Vector3 parentScale = parentMatrix.ExtractScale(); + + // Orientation check + if (!(isNearlyZero(MathF.Abs(childMatrix.M21)) && isNearlyZero(MathF.Abs(childMatrix.M12)))) + return false; + + // flip check + if (!(childMatrix.M11 * childMatrix.M22 > 0)) + return false; + + // Aspect ratio check + if (!isNearlyZero(childScale.X - childScale.Y)) + return false; + + // ScalingMode check + switch (child.Scaling) + { + case ScaleMode.NoScaling: + if (!(isNearlyZero(childMatrix.M11 - 1.0f) && isNearlyZero(childMatrix.M22 - 1.0f))) + return false; + + break; + + case ScaleMode.Vertical: + if (!(checkScaling(child.ScalingFactor, parentScale.Y, childScale.Y))) + return false; + + break; + + case ScaleMode.Horizontal: + if (!(checkScaling(child.ScalingFactor, parentScale.X, childScale.X))) + return false; + + break; + } + + return true; + } + + private bool checkScaling(float scalingFactor, float parentScale, float childScale) + { + if (scalingFactor <= 1.0f) + { + if (!isNearlyZero(1.0f + (parentScale - 1.0f) * scalingFactor - childScale)) + return false; + } + else if (scalingFactor > 1.0f) + { + if (parentScale < 1.0f) + { + if (!isNearlyZero((parentScale * (1.0f / scalingFactor)) - childScale)) + return false; + } + else if (!isNearlyZero(parentScale * scalingFactor - childScale)) + return false; + } + + return true; + } + + private bool isNearlyZero(float f, float epsilon = Precision.FLOAT_EPSILON) + { + return f < epsilon; + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneUserListToolbar.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneUserListToolbar.cs index 8f7140ed7c..8737f7312e 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneUserListToolbar.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneUserListToolbar.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -11,7 +13,7 @@ using osuTK; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneUserListToolbar : OsuTestScene + public partial class TestSceneUserListToolbar : OsuTestScene { [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneVolumeOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneVolumeOverlay.cs index 64708c4858..52543c68ce 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneVolumeOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneVolumeOverlay.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Overlays; using osu.Game.Overlays.Volume; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneVolumeOverlay : OsuTestScene + public partial class TestSceneVolumeOverlay : OsuTestScene { private VolumeOverlay volume; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneVolumePieces.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneVolumePieces.cs index c8478c8eca..7cedef96e3 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneVolumePieces.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneVolumePieces.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Overlays.Volume; using osuTK; @@ -8,7 +10,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneVolumePieces : OsuTestScene + public partial class TestSceneVolumePieces : OsuTestScene { protected override void LoadComplete() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneWaveContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneWaveContainer.cs index 5b130b9224..7851571b36 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneWaveContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneWaveContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -15,7 +17,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneWaveContainer : OsuTestScene + public partial class TestSceneWaveContainer : OsuTestScene { [BackgroundDependencyLoader] private void load(OsuColour colours) diff --git a/osu.Game.Tests/Visual/UserInterface/ThemeComparisonTestScene.cs b/osu.Game.Tests/Visual/UserInterface/ThemeComparisonTestScene.cs index 7986f14d1d..05ffd1fbef 100644 --- a/osu.Game.Tests/Visual/UserInterface/ThemeComparisonTestScene.cs +++ b/osu.Game.Tests/Visual/UserInterface/ThemeComparisonTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; @@ -12,7 +14,7 @@ using osu.Game.Overlays; namespace osu.Game.Tests.Visual.UserInterface { - public abstract class ThemeComparisonTestScene : OsuGridTestScene + public abstract partial class ThemeComparisonTestScene : OsuGridTestScene { protected ThemeComparisonTestScene() : base(1, 2) diff --git a/osu.Game.Tests/WaveformTestBeatmap.cs b/osu.Game.Tests/WaveformTestBeatmap.cs index ab7bf7fb73..5e41392560 100644 --- a/osu.Game.Tests/WaveformTestBeatmap.cs +++ b/osu.Game.Tests/WaveformTestBeatmap.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.IO; using System.Linq; diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index a1eef4ce47..59a786a11d 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -1,14 +1,12 @@  - - - + + - - - + + WinExe diff --git a/osu.Game.Tournament.Tests/Components/TestSceneDateTextBox.cs b/osu.Game.Tournament.Tests/Components/TestSceneDateTextBox.cs index 33165d385a..f547acd635 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneDateTextBox.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneDateTextBox.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Game.Tests.Visual; using osu.Game.Tournament.Components; @@ -9,7 +11,7 @@ using osuTK.Input; namespace osu.Game.Tournament.Tests.Components { - public class TestSceneDateTextBox : OsuManualInputManagerTestScene + public partial class TestSceneDateTextBox : OsuManualInputManagerTestScene { private DateTextBox textBox; diff --git a/osu.Game.Tournament.Tests/Components/TestSceneDrawableTournamentMatch.cs b/osu.Game.Tournament.Tests/Components/TestSceneDrawableTournamentMatch.cs index f98f55dfbc..cb923a1f9a 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneDrawableTournamentMatch.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneDrawableTournamentMatch.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Tournament.Models; @@ -8,7 +10,7 @@ using osu.Game.Tournament.Screens.Ladder.Components; namespace osu.Game.Tournament.Tests.Components { - public class TestSceneDrawableTournamentMatch : TournamentTestScene + public partial class TestSceneDrawableTournamentMatch : TournamentTestScene { public TestSceneDrawableTournamentMatch() { diff --git a/osu.Game.Tournament.Tests/Components/TestSceneDrawableTournamentTeam.cs b/osu.Game.Tournament.Tests/Components/TestSceneDrawableTournamentTeam.cs index bb47683be1..dd7c613c6c 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneDrawableTournamentTeam.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneDrawableTournamentTeam.cs @@ -1,9 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Tests.Visual; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; @@ -13,7 +14,7 @@ using osu.Game.Tournament.Screens.Ladder.Components; namespace osu.Game.Tournament.Tests.Components { - public class TestSceneDrawableTournamentTeam : OsuGridTestScene + public partial class TestSceneDrawableTournamentTeam : OsuGridTestScene { public TestSceneDrawableTournamentTeam() : base(4, 3) @@ -24,13 +25,13 @@ namespace osu.Game.Tournament.Tests.Components FullName = { Value = "Australia" }, Players = { - new APIUser { Username = "ASecretBox" }, - new APIUser { Username = "Dereban" }, - new APIUser { Username = "mReKk" }, - new APIUser { Username = "uyghti" }, - new APIUser { Username = "Parkes" }, - new APIUser { Username = "Shiroha" }, - new APIUser { Username = "Jordan The Bear" }, + new TournamentUser { Username = "ASecretBox" }, + new TournamentUser { Username = "Dereban" }, + new TournamentUser { Username = "mReKk" }, + new TournamentUser { Username = "uyghti" }, + new TournamentUser { Username = "Parkes" }, + new TournamentUser { Username = "Shiroha" }, + new TournamentUser { Username = "Jordan The Bear" }, } }; diff --git a/osu.Game.Tournament.Tests/Components/TestSceneMatchHeader.cs b/osu.Game.Tournament.Tests/Components/TestSceneMatchHeader.cs index b29e4964b6..2347c84ba8 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneMatchHeader.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneMatchHeader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; @@ -9,7 +11,7 @@ using osuTK; namespace osu.Game.Tournament.Tests.Components { - public class TestSceneMatchHeader : TournamentTestScene + public partial class TestSceneMatchHeader : TournamentTestScene { public TestSceneMatchHeader() { diff --git a/osu.Game.Tournament.Tests/Components/TestSceneMatchScoreDisplay.cs b/osu.Game.Tournament.Tests/Components/TestSceneMatchScoreDisplay.cs index 11b5cc7556..9b1fc17591 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneMatchScoreDisplay.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneMatchScoreDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Utils; @@ -9,7 +11,7 @@ using osu.Game.Tournament.Screens.Gameplay.Components; namespace osu.Game.Tournament.Tests.Components { - public class TestSceneMatchScoreDisplay : TournamentTestScene + public partial class TestSceneMatchScoreDisplay : TournamentTestScene { [Cached(Type = typeof(MatchIPCInfo))] private MatchIPCInfo matchInfo = new MatchIPCInfo(); diff --git a/osu.Game.Tournament.Tests/Components/TestSceneRoundDisplay.cs b/osu.Game.Tournament.Tests/Components/TestSceneRoundDisplay.cs index 13bca7bea1..cb22e7e7c7 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneRoundDisplay.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneRoundDisplay.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Tests.Components { - public class TestSceneRoundDisplay : TournamentTestScene + public partial class TestSceneRoundDisplay : TournamentTestScene { public TestSceneRoundDisplay() { diff --git a/osu.Game.Tournament.Tests/Components/TestSceneSongBar.cs b/osu.Game.Tournament.Tests/Components/TestSceneSongBar.cs index b6004c651b..f793c33878 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneSongBar.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneSongBar.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Tests.Components { [TestFixture] - public class TestSceneSongBar : OsuTestScene + public partial class TestSceneSongBar : OsuTestScene { [Cached] private readonly LadderInfo ladder = new LadderInfo(); @@ -40,7 +42,7 @@ namespace osu.Game.Tournament.Tests.Components beatmap.Length = 123456; beatmap.BPM = 133; - songBar.Beatmap = beatmap; + songBar.Beatmap = new TournamentBeatmap(beatmap); }); AddStep("set mods to HR", () => songBar.Mods = LegacyMods.HardRock); AddStep("set mods to DT", () => songBar.Mods = LegacyMods.DoubleTime); diff --git a/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs b/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs index b678f69b8f..057566d426 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Online.API; @@ -8,10 +10,11 @@ using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Tests.Visual; using osu.Game.Tournament.Components; +using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Tests.Components { - public class TestSceneTournamentBeatmapPanel : TournamentTestScene + public partial class TestSceneTournamentBeatmapPanel : TournamentTestScene { /// /// Warning: the below API instance is actually the online API, rather than the dummy API provided by the test. @@ -30,7 +33,7 @@ namespace osu.Game.Tournament.Tests.Components private void success(APIBeatmap beatmap) { - Add(new TournamentBeatmapPanel(beatmap) + Add(new TournamentBeatmapPanel(new TournamentBeatmap(beatmap)) { Anchor = Anchor.Centre, Origin = Anchor.Centre diff --git a/osu.Game.Tournament.Tests/Components/TestSceneTournamentMatchChatDisplay.cs b/osu.Game.Tournament.Tests/Components/TestSceneTournamentMatchChatDisplay.cs index 05989566c3..d9ae8df651 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneTournamentMatchChatDisplay.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneTournamentMatchChatDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -13,7 +15,7 @@ using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Tests.Components { - public class TestSceneTournamentMatchChatDisplay : OsuTestScene + public partial class TestSceneTournamentMatchChatDisplay : OsuTestScene { private readonly Channel testChannel = new Channel(); private readonly Channel testChannel2 = new Channel(); @@ -25,16 +27,16 @@ namespace osu.Game.Tournament.Tests.Components Colour = "f2ca34" }; - private readonly APIUser redUser = new APIUser + private readonly TournamentUser redUser = new TournamentUser { Username = "BanchoBot", - Id = 3, + OnlineID = 3, }; - private readonly APIUser blueUser = new APIUser + private readonly TournamentUser blueUser = new TournamentUser { Username = "Zallius", - Id = 4, + OnlineID = 4, }; [Cached] @@ -57,11 +59,11 @@ namespace osu.Game.Tournament.Tests.Components { Team1 = { - Value = new TournamentTeam { Players = new BindableList { redUser } } + Value = new TournamentTeam { Players = new BindableList { redUser } } }, Team2 = { - Value = new TournamentTeam { Players = new BindableList { blueUser } } + Value = new TournamentTeam { Players = new BindableList { blueUser } } } }; @@ -80,19 +82,19 @@ namespace osu.Game.Tournament.Tests.Components AddStep("message from team red", () => testChannel.AddNewMessages(new Message(nextMessageId()) { - Sender = redUser, + Sender = redUser.ToAPIUser(), Content = "I am team red." })); AddStep("message from team red", () => testChannel.AddNewMessages(new Message(nextMessageId()) { - Sender = redUser, + Sender = redUser.ToAPIUser(), Content = "I plan to win!" })); AddStep("message from team blue", () => testChannel.AddNewMessages(new Message(nextMessageId()) { - Sender = blueUser, + Sender = blueUser.ToAPIUser(), Content = "Not on my watch. Prepare to eat saaaaaaaaaand. Lots and lots of saaaaaaand." })); diff --git a/osu.Game.Tournament.Tests/Components/TestSceneTournamentModDisplay.cs b/osu.Game.Tournament.Tests/Components/TestSceneTournamentModDisplay.cs index 9feef36a02..cea4306ff8 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneTournamentModDisplay.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneTournamentModDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -9,11 +11,12 @@ using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; using osu.Game.Tournament.Components; +using osu.Game.Tournament.Models; using osuTK; namespace osu.Game.Tournament.Tests.Components { - public class TestSceneTournamentModDisplay : TournamentTestScene + public partial class TestSceneTournamentModDisplay : TournamentTestScene { [Resolved] private IAPIProvider api { get; set; } @@ -51,7 +54,7 @@ namespace osu.Game.Tournament.Tests.Components foreach (var mod in mods) { - fillFlow.Add(new TournamentBeatmapPanel(beatmap, mod.Acronym) + fillFlow.Add(new TournamentBeatmapPanel(new TournamentBeatmap(beatmap), mod.Acronym) { Anchor = Anchor.Centre, Origin = Anchor.Centre diff --git a/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs index 26fb03bed4..45dffdc94a 100644 --- a/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs +++ b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.IO; using System.Linq; using NUnit.Framework; @@ -37,7 +39,7 @@ namespace osu.Game.Tournament.Tests.NonVisual [Test] public void TestCustomDirectory() { - using (HeadlessGameHost host = new TestRunHeadlessGameHost(nameof(TestCustomDirectory), null)) // don't use clean run as we are writing a config file. + using (HeadlessGameHost host = new TestRunHeadlessGameHost(nameof(TestCustomDirectory))) // don't use clean run as we are writing a config file. { string osuDesktopStorage = Path.Combine(host.UserStoragePaths.First(), nameof(TestCustomDirectory)); const string custom_tournament = "custom"; @@ -47,7 +49,7 @@ namespace osu.Game.Tournament.Tests.NonVisual // manual cleaning so we can prepare a config file. storage.DeleteDirectory(string.Empty); - using (var storageConfig = new TournamentStorageManager(storage)) + using (var storageConfig = new TournamentConfigManager(storage)) storageConfig.SetValue(StorageConfig.CurrentTournament, custom_tournament); try @@ -64,82 +66,5 @@ namespace osu.Game.Tournament.Tests.NonVisual } } } - - [Test] - public void TestMigration() - { - using (HeadlessGameHost host = new TestRunHeadlessGameHost(nameof(TestMigration), null)) // don't use clean run as we are writing test files for migration. - { - string osuRoot = Path.Combine(host.UserStoragePaths.First(), nameof(TestMigration)); - string configFile = Path.Combine(osuRoot, "tournament.ini"); - - if (File.Exists(configFile)) - File.Delete(configFile); - - // Recreate the old setup that uses "tournament" as the base path. - string oldPath = Path.Combine(osuRoot, "tournament"); - - string videosPath = Path.Combine(oldPath, "Videos"); - string modsPath = Path.Combine(oldPath, "Mods"); - string flagsPath = Path.Combine(oldPath, "Flags"); - - Directory.CreateDirectory(videosPath); - Directory.CreateDirectory(modsPath); - Directory.CreateDirectory(flagsPath); - - // Define testing files corresponding to the specific file migrations that are needed - string bracketFile = Path.Combine(osuRoot, TournamentGameBase.BRACKET_FILENAME); - - string drawingsConfig = Path.Combine(osuRoot, "drawings.ini"); - string drawingsFile = Path.Combine(osuRoot, "drawings.txt"); - string drawingsResult = Path.Combine(osuRoot, "drawings_results.txt"); - - // Define sample files to test recursive copying - string videoFile = Path.Combine(videosPath, "video.mp4"); - string modFile = Path.Combine(modsPath, "mod.png"); - string flagFile = Path.Combine(flagsPath, "flag.png"); - - File.WriteAllText(bracketFile, "{}"); - File.WriteAllText(drawingsConfig, "test"); - File.WriteAllText(drawingsFile, "test"); - File.WriteAllText(drawingsResult, "test"); - File.WriteAllText(videoFile, "test"); - File.WriteAllText(modFile, "test"); - File.WriteAllText(flagFile, "test"); - - try - { - var osu = LoadTournament(host); - - var storage = osu.Dependencies.Get(); - - string migratedPath = Path.Combine(host.Storage.GetFullPath("."), "tournaments", "default"); - - videosPath = Path.Combine(migratedPath, "Videos"); - modsPath = Path.Combine(migratedPath, "Mods"); - flagsPath = Path.Combine(migratedPath, "Flags"); - - videoFile = Path.Combine(videosPath, "video.mp4"); - modFile = Path.Combine(modsPath, "mod.png"); - flagFile = Path.Combine(flagsPath, "flag.png"); - - Assert.That(storage.GetFullPath("."), Is.EqualTo(migratedPath)); - - Assert.True(storage.Exists(TournamentGameBase.BRACKET_FILENAME)); - Assert.True(storage.Exists("drawings.txt")); - Assert.True(storage.Exists("drawings_results.txt")); - - Assert.True(storage.Exists("drawings.ini")); - - Assert.True(storage.Exists(videoFile)); - Assert.True(storage.Exists(modFile)); - Assert.True(storage.Exists(flagFile)); - } - finally - { - host.Exit(); - } - } - } } } diff --git a/osu.Game.Tournament.Tests/NonVisual/DataLoadTest.cs b/osu.Game.Tournament.Tests/NonVisual/DataLoadTest.cs index e5c539bbf1..256a984a7c 100644 --- a/osu.Game.Tournament.Tests/NonVisual/DataLoadTest.cs +++ b/osu.Game.Tournament.Tests/NonVisual/DataLoadTest.cs @@ -1,9 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.IO; using System.Threading.Tasks; +using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -14,7 +17,7 @@ using osu.Game.Tests; namespace osu.Game.Tournament.Tests.NonVisual { - public class DataLoadTest : TournamentHostTest + public partial class DataLoadTest : TournamentHostTest { [Test] public void TestRulesetGetsValidOnlineID() @@ -25,7 +28,6 @@ namespace osu.Game.Tournament.Tests.NonVisual { var osu = new TestTournament(runOnLoadComplete: () => { - // ReSharper disable once AccessToDisposedClosure var storage = host.Storage.GetStorageForDirectory(Path.Combine("tournaments", "default")); using (var stream = storage.CreateFileSafely("bracket.json")) @@ -76,14 +78,14 @@ namespace osu.Game.Tournament.Tests.NonVisual } } - public class TestTournament : TournamentGameBase + public partial class TestTournament : TournamentGameBase { private readonly bool resetRuleset; private readonly Action runOnLoadComplete; public new Task BracketLoadTask => base.BracketLoadTask; - public TestTournament(bool resetRuleset = false, Action runOnLoadComplete = null) + public TestTournament(bool resetRuleset = false, [InstantHandle] Action runOnLoadComplete = null) { this.resetRuleset = resetRuleset; this.runOnLoadComplete = runOnLoadComplete; diff --git a/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs b/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs index 80cc9be5c1..ca6354cb48 100644 --- a/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs +++ b/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.IO; using System.Linq; using NUnit.Framework; @@ -19,7 +21,7 @@ namespace osu.Game.Tournament.Tests.NonVisual public void CheckIPCLocation() { // don't use clean run because files are being written before osu! launches. - using (var host = new TestRunHeadlessGameHost(nameof(CheckIPCLocation), null)) + using (var host = new TestRunHeadlessGameHost(nameof(CheckIPCLocation))) { string basePath = Path.Combine(host.UserStoragePaths.First(), nameof(CheckIPCLocation)); diff --git a/osu.Game.Tournament.Tests/NonVisual/LadderInfoSerialisationTest.cs b/osu.Game.Tournament.Tests/NonVisual/LadderInfoSerialisationTest.cs index 8bdf909af3..f1e0966293 100644 --- a/osu.Game.Tournament.Tests/NonVisual/LadderInfoSerialisationTest.cs +++ b/osu.Game.Tournament.Tests/NonVisual/LadderInfoSerialisationTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Newtonsoft.Json; using NUnit.Framework; using osu.Game.Tournament.Models; diff --git a/osu.Game.Tournament.Tests/NonVisual/TournamentHostTest.cs b/osu.Game.Tournament.Tests/NonVisual/TournamentHostTest.cs index bf99f69b2a..8dc0946432 100644 --- a/osu.Game.Tournament.Tests/NonVisual/TournamentHostTest.cs +++ b/osu.Game.Tournament.Tests/NonVisual/TournamentHostTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Threading; using System.Threading.Tasks; diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneDrawingsScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneDrawingsScreen.cs index 0e38c777ba..10ed850002 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneDrawingsScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneDrawingsScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.IO; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osu.Game.Tournament.Screens.Drawings; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneDrawingsScreen : TournamentTestScene + public partial class TestSceneDrawingsScreen : TournamentTestScene { [BackgroundDependencyLoader] private void load(Storage storage) diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs index 6879a71f1d..f127a930a6 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -13,7 +15,7 @@ using osu.Game.Tournament.Screens.Gameplay.Components; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneGameplayScreen : TournamentTestScene + public partial class TestSceneGameplayScreen : TournamentTestScene { [Cached] private TournamentMatchChatDisplay chat = new TournamentMatchChatDisplay { Width = 0.5f }; @@ -51,7 +53,7 @@ namespace osu.Game.Tournament.Tests.Screens { AddStep("setup screen", () => { - Remove(chat); + Remove(chat, false); Children = new Drawable[] { diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneLadderEditorScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneLadderEditorScreen.cs index bceb3e6b74..5c4e1b2a5a 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneLadderEditorScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneLadderEditorScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Graphics.Cursor; @@ -8,7 +10,7 @@ using osu.Game.Tournament.Screens.Editors; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneLadderEditorScreen : TournamentTestScene + public partial class TestSceneLadderEditorScreen : TournamentTestScene { [BackgroundDependencyLoader] private void load() diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneLadderScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneLadderScreen.cs index c4c100d506..20f729bb8d 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneLadderScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneLadderScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Graphics.Cursor; @@ -8,7 +10,7 @@ using osu.Game.Tournament.Screens.Ladder; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneLadderScreen : TournamentTestScene + public partial class TestSceneLadderScreen : TournamentTestScene { [BackgroundDependencyLoader] private void load() diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneMapPoolScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneMapPoolScreen.cs index f732c5582b..5695cb5574 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneMapPoolScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneMapPoolScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -12,7 +14,7 @@ using osu.Game.Tournament.Screens.MapPool; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneMapPoolScreen : TournamentTestScene + public partial class TestSceneMapPoolScreen : TournamentTestScene { private MapPoolScreen screen; diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneRoundEditorScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneRoundEditorScreen.cs index 5c2b59df3a..ebeb69012d 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneRoundEditorScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneRoundEditorScreen.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Tournament.Screens.Editors; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneRoundEditorScreen : TournamentTestScene + public partial class TestSceneRoundEditorScreen : TournamentTestScene { public TestSceneRoundEditorScreen() { diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs index bd1bacd549..fd0de3d63a 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using NUnit.Framework; using osu.Framework.Allocation; @@ -10,7 +12,7 @@ using osu.Game.Tournament.Screens.Schedule; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneScheduleScreen : TournamentTestScene + public partial class TestSceneScheduleScreen : TournamentTestScene { [BackgroundDependencyLoader] private void load() diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneSeedingEditorScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneSeedingEditorScreen.cs index 2722021216..cfb533149d 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneSeedingEditorScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneSeedingEditorScreen.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Game.Tournament.Models; using osu.Game.Tournament.Screens.Editors; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneSeedingEditorScreen : TournamentTestScene + public partial class TestSceneSeedingEditorScreen : TournamentTestScene { [Cached] private readonly LadderInfo ladder = new LadderInfo(); diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs index a18e73e38f..c9620bc0b9 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -12,7 +14,7 @@ using osu.Game.Tournament.Screens.TeamIntro; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneSeedingScreen : TournamentTestScene + public partial class TestSceneSeedingScreen : TournamentTestScene { [Cached] private readonly LadderInfo ladder = new LadderInfo diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneSetupScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneSetupScreen.cs index 70b260c84c..84c8b9a141 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneSetupScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneSetupScreen.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Game.Tournament.Screens.Setup; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneSetupScreen : TournamentTestScene + public partial class TestSceneSetupScreen : TournamentTestScene { [BackgroundDependencyLoader] private void load() diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneShowcaseScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneShowcaseScreen.cs index 8c43e25416..6287679c27 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneShowcaseScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneShowcaseScreen.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Game.Tournament.Screens.Showcase; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneShowcaseScreen : TournamentTestScene + public partial class TestSceneShowcaseScreen : TournamentTestScene { [BackgroundDependencyLoader] private void load() diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneStablePathSelectScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneStablePathSelectScreen.cs index b422227788..dbd9cb2817 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneStablePathSelectScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneStablePathSelectScreen.cs @@ -1,18 +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 osu.Game.Tournament.Screens.Setup; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneStablePathSelectScreen : TournamentTestScene + public partial class TestSceneStablePathSelectScreen : TournamentTestScene { public TestSceneStablePathSelectScreen() { AddStep("Add screen", () => Add(new StablePathSelectTestScreen())); } - private class StablePathSelectTestScreen : StablePathSelectScreen + private partial class StablePathSelectTestScreen : StablePathSelectScreen { protected override void ChangePath() { diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneTeamEditorScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneTeamEditorScreen.cs index fc6574ec8a..63c08800ad 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneTeamEditorScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneTeamEditorScreen.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Tournament.Screens.Editors; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneTeamEditorScreen : TournamentTestScene + public partial class TestSceneTeamEditorScreen : TournamentTestScene { public TestSceneTeamEditorScreen() { diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneTeamIntroScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneTeamIntroScreen.cs index e89aac73fa..5c26bc203c 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneTeamIntroScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneTeamIntroScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -9,7 +11,7 @@ using osu.Game.Tournament.Screens.TeamIntro; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneTeamIntroScreen : TournamentTestScene + public partial class TestSceneTeamIntroScreen : TournamentTestScene { [Cached] private readonly LadderInfo ladder = new LadderInfo(); diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneTeamWinScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneTeamWinScreen.cs index d07cc4c431..43e16873c6 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneTeamWinScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneTeamWinScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -8,7 +10,7 @@ using osu.Game.Tournament.Screens.TeamWin; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneTeamWinScreen : TournamentTestScene + public partial class TestSceneTeamWinScreen : TournamentTestScene { [Test] public void TestBasic() diff --git a/osu.Game.Tournament.Tests/TestSceneTournamentSceneManager.cs b/osu.Game.Tournament.Tests/TestSceneTournamentSceneManager.cs index 53591da07b..859d0591c3 100644 --- a/osu.Game.Tournament.Tests/TestSceneTournamentSceneManager.cs +++ b/osu.Game.Tournament.Tests/TestSceneTournamentSceneManager.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; namespace osu.Game.Tournament.Tests { - public class TestSceneTournamentSceneManager : TournamentTestScene + public partial class TestSceneTournamentSceneManager : TournamentTestScene { [BackgroundDependencyLoader] private void load() diff --git a/osu.Game.Tournament.Tests/TournamentTestBrowser.cs b/osu.Game.Tournament.Tests/TournamentTestBrowser.cs index 50bdcd86c5..f29272fbb8 100644 --- a/osu.Game.Tournament.Tests/TournamentTestBrowser.cs +++ b/osu.Game.Tournament.Tests/TournamentTestBrowser.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Testing; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; namespace osu.Game.Tournament.Tests { - public class TournamentTestBrowser : TournamentGameBase + public partial class TournamentTestBrowser : TournamentGameBase { protected override void LoadComplete() { @@ -19,7 +21,7 @@ namespace osu.Game.Tournament.Tests { Colour = OsuColour.Gray(0.5f), Depth = 10 - }, AddInternal); + }, Add); // Have to construct this here, rather than in the constructor, because // we depend on some dependencies to be loaded within OsuGameBase.load(). diff --git a/osu.Game.Tournament.Tests/TournamentTestRunner.cs b/osu.Game.Tournament.Tests/TournamentTestRunner.cs index 229ab41a1e..f95fcbf487 100644 --- a/osu.Game.Tournament.Tests/TournamentTestRunner.cs +++ b/osu.Game.Tournament.Tests/TournamentTestRunner.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework; using osu.Framework.Platform; diff --git a/osu.Game.Tournament.Tests/TournamentTestScene.cs b/osu.Game.Tournament.Tests/TournamentTestScene.cs index 8c05b4e915..cab78422a2 100644 --- a/osu.Game.Tournament.Tests/TournamentTestScene.cs +++ b/osu.Game.Tournament.Tests/TournamentTestScene.cs @@ -1,24 +1,24 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Framework.Utils; -using osu.Game.Online.API.Requests.Responses; +using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Tests.Visual; using osu.Game.Tournament.IO; using osu.Game.Tournament.IPC; using osu.Game.Tournament.Models; -using osu.Game.Users; -using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Tournament.Tests { - public abstract class TournamentTestScene : OsuTestScene + public abstract partial class TournamentTestScene : OsuTestScene { private TournamentMatch match; @@ -121,11 +121,11 @@ namespace osu.Game.Tournament.Tests }, Players = { - new APIUser { Username = "Hello", Statistics = new UserStatistics { GlobalRank = 12 } }, - new APIUser { Username = "Hello", Statistics = new UserStatistics { GlobalRank = 16 } }, - new APIUser { Username = "Hello", Statistics = new UserStatistics { GlobalRank = 20 } }, - new APIUser { Username = "Hello", Statistics = new UserStatistics { GlobalRank = 24 } }, - new APIUser { Username = "Hello", Statistics = new UserStatistics { GlobalRank = 30 } }, + new TournamentUser { Username = "Hello", Rank = 12 }, + new TournamentUser { Username = "Hello", Rank = 16 }, + new TournamentUser { Username = "Hello", Rank = 20 }, + new TournamentUser { Username = "Hello", Rank = 24 }, + new TournamentUser { Username = "Hello", Rank = 30 }, } } }, @@ -138,11 +138,11 @@ namespace osu.Game.Tournament.Tests FullName = { Value = "United States" }, Players = { - new APIUser { Username = "Hello" }, - new APIUser { Username = "Hello" }, - new APIUser { Username = "Hello" }, - new APIUser { Username = "Hello" }, - new APIUser { Username = "Hello" }, + new TournamentUser { Username = "Hello" }, + new TournamentUser { Username = "Hello" }, + new TournamentUser { Username = "Hello" }, + new TournamentUser { Username = "Hello" }, + new TournamentUser { Username = "Hello" }, } } }, @@ -152,10 +152,10 @@ namespace osu.Game.Tournament.Tests } }; - public static APIBeatmap CreateSampleBeatmap() => - new APIBeatmap + public static TournamentBeatmap CreateSampleBeatmap() => + new TournamentBeatmap { - BeatmapSet = new APIBeatmapSet + Metadata = new BeatmapMetadata { Title = "Test Title", Artist = "Test Artist", @@ -165,7 +165,7 @@ namespace osu.Game.Tournament.Tests protected override ITestSceneTestRunner CreateRunner() => new TournamentTestSceneTestRunner(); - public class TournamentTestSceneTestRunner : TournamentGameBase, ITestSceneTestRunner + public partial class TournamentTestSceneTestRunner : TournamentGameBase, ITestSceneTestRunner { private TestSceneTestRunner.TestRunner runner; diff --git a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj index 6fd53d923b..5847079161 100644 --- a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj +++ b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj @@ -4,10 +4,9 @@ osu.Game.Tournament.Tests.TournamentTestRunner - - + - + WinExe diff --git a/osu.Game.Tournament/Components/ControlPanel.cs b/osu.Game.Tournament/Components/ControlPanel.cs index ef8c8767e0..c3e66e80eb 100644 --- a/osu.Game.Tournament/Components/ControlPanel.cs +++ b/osu.Game.Tournament/Components/ControlPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -14,7 +16,7 @@ namespace osu.Game.Tournament.Components /// An element anchored to the right-hand area of a screen that provides streamer level controls. /// Should be off-screen. /// - public class ControlPanel : Container + public partial class ControlPanel : Container { private readonly FillFlowContainer buttons; @@ -55,7 +57,7 @@ namespace osu.Game.Tournament.Components }; } - public class Spacer : CompositeDrawable + public partial class Spacer : CompositeDrawable { public Spacer(float height = 20) { diff --git a/osu.Game.Tournament/Components/DateTextBox.cs b/osu.Game.Tournament/Components/DateTextBox.cs index 2237e389d7..192d8c9fd1 100644 --- a/osu.Game.Tournament/Components/DateTextBox.cs +++ b/osu.Game.Tournament/Components/DateTextBox.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Bindables; using osu.Game.Graphics.UserInterface; @@ -8,7 +10,7 @@ using osu.Game.Overlays.Settings; namespace osu.Game.Tournament.Components { - public class DateTextBox : SettingsTextBox + public partial class DateTextBox : SettingsTextBox { public new Bindable Current { @@ -28,7 +30,7 @@ namespace osu.Game.Tournament.Components { base.Current = new Bindable(string.Empty); - ((OsuTextBox)Control).OnCommit += (sender, newText) => + ((OsuTextBox)Control).OnCommit += (sender, _) => { try { diff --git a/osu.Game.Tournament/Components/DrawableTeamFlag.cs b/osu.Game.Tournament/Components/DrawableTeamFlag.cs index 75991a1ab8..317d685ee7 100644 --- a/osu.Game.Tournament/Components/DrawableTeamFlag.cs +++ b/osu.Game.Tournament/Components/DrawableTeamFlag.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -13,7 +15,7 @@ using osuTK; namespace osu.Game.Tournament.Components { - public class DrawableTeamFlag : Container + public partial class DrawableTeamFlag : Container { private readonly TournamentTeam team; @@ -32,7 +34,7 @@ namespace osu.Game.Tournament.Components { if (team == null) return; - Size = new Vector2(75, 50); + Size = new Vector2(75, 54); Masking = true; CornerRadius = 5; Child = flagSprite = new Sprite @@ -43,7 +45,7 @@ namespace osu.Game.Tournament.Components FillMode = FillMode.Fill }; - (flag = team.FlagName.GetBoundCopy()).BindValueChanged(acronym => flagSprite.Texture = textures.Get($@"Flags/{team.FlagName}"), true); + (flag = team.FlagName.GetBoundCopy()).BindValueChanged(_ => flagSprite.Texture = textures.Get($@"Flags/{team.FlagName}"), true); } } } diff --git a/osu.Game.Tournament/Components/DrawableTeamHeader.cs b/osu.Game.Tournament/Components/DrawableTeamHeader.cs index 3d9e8a6e00..1648e7373b 100644 --- a/osu.Game.Tournament/Components/DrawableTeamHeader.cs +++ b/osu.Game.Tournament/Components/DrawableTeamHeader.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Tournament.Models; using osuTK; namespace osu.Game.Tournament.Components { - public class DrawableTeamHeader : TournamentSpriteTextWithBackground + public partial class DrawableTeamHeader : TournamentSpriteTextWithBackground { public DrawableTeamHeader(TeamColour colour) { diff --git a/osu.Game.Tournament/Components/DrawableTeamTitle.cs b/osu.Game.Tournament/Components/DrawableTeamTitle.cs index 6732eb152f..68cc46be19 100644 --- a/osu.Game.Tournament/Components/DrawableTeamTitle.cs +++ b/osu.Game.Tournament/Components/DrawableTeamTitle.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -8,7 +10,7 @@ using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Components { - public class DrawableTeamTitle : TournamentSpriteTextWithBackground + public partial class DrawableTeamTitle : TournamentSpriteTextWithBackground { private readonly TournamentTeam team; @@ -25,7 +27,7 @@ namespace osu.Game.Tournament.Components { if (team == null) return; - (acronym = team.Acronym.GetBoundCopy()).BindValueChanged(acronym => Text.Text = team?.FullName.Value ?? string.Empty, true); + (acronym = team.Acronym.GetBoundCopy()).BindValueChanged(_ => Text.Text = team?.FullName.Value ?? string.Empty, true); } } } diff --git a/osu.Game.Tournament/Components/DrawableTeamTitleWithHeader.cs b/osu.Game.Tournament/Components/DrawableTeamTitleWithHeader.cs index ceffe3d315..27113b0d21 100644 --- a/osu.Game.Tournament/Components/DrawableTeamTitleWithHeader.cs +++ b/osu.Game.Tournament/Components/DrawableTeamTitleWithHeader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Tournament.Models; @@ -8,7 +10,7 @@ using osuTK; namespace osu.Game.Tournament.Components { - public class DrawableTeamTitleWithHeader : CompositeDrawable + public partial class DrawableTeamTitleWithHeader : CompositeDrawable { public DrawableTeamTitleWithHeader(TournamentTeam team, TeamColour colour) { diff --git a/osu.Game.Tournament/Components/DrawableTeamWithPlayers.cs b/osu.Game.Tournament/Components/DrawableTeamWithPlayers.cs index 4d9afc23ac..9606670ad8 100644 --- a/osu.Game.Tournament/Components/DrawableTeamWithPlayers.cs +++ b/osu.Game.Tournament/Components/DrawableTeamWithPlayers.cs @@ -1,18 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Tournament.Models; using osuTK; using osuTK.Graphics; namespace osu.Game.Tournament.Components { - public class DrawableTeamWithPlayers : CompositeDrawable + public partial class DrawableTeamWithPlayers : CompositeDrawable { public DrawableTeamWithPlayers(TournamentTeam team, TeamColour colour) { @@ -54,7 +55,7 @@ namespace osu.Game.Tournament.Components }, }; - TournamentSpriteText createPlayerText(APIUser p) => + TournamentSpriteText createPlayerText(TournamentUser p) => new TournamentSpriteText { Text = p.Username, diff --git a/osu.Game.Tournament/Components/DrawableTournamentHeaderLogo.cs b/osu.Game.Tournament/Components/DrawableTournamentHeaderLogo.cs index 3f5ab42fd7..c83fceb01d 100644 --- a/osu.Game.Tournament/Components/DrawableTournamentHeaderLogo.cs +++ b/osu.Game.Tournament/Components/DrawableTournamentHeaderLogo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -9,7 +11,7 @@ using osu.Framework.Graphics.Textures; namespace osu.Game.Tournament.Components { - public class DrawableTournamentHeaderLogo : CompositeDrawable + public partial class DrawableTournamentHeaderLogo : CompositeDrawable { public DrawableTournamentHeaderLogo() { @@ -19,7 +21,7 @@ namespace osu.Game.Tournament.Components RelativeSizeAxes = Axes.X; } - private class LogoSprite : Sprite + private partial class LogoSprite : Sprite { [BackgroundDependencyLoader] private void load(TextureStore textures) diff --git a/osu.Game.Tournament/Components/DrawableTournamentHeaderText.cs b/osu.Game.Tournament/Components/DrawableTournamentHeaderText.cs index 99d914fed4..7a1f448cb4 100644 --- a/osu.Game.Tournament/Components/DrawableTournamentHeaderText.cs +++ b/osu.Game.Tournament/Components/DrawableTournamentHeaderText.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -9,7 +11,7 @@ using osu.Framework.Graphics.Textures; namespace osu.Game.Tournament.Components { - public class DrawableTournamentHeaderText : CompositeDrawable + public partial class DrawableTournamentHeaderText : CompositeDrawable { public DrawableTournamentHeaderText(bool center = true) { @@ -23,7 +25,7 @@ namespace osu.Game.Tournament.Components RelativeSizeAxes = Axes.X; } - private class TextSprite : Sprite + private partial class TextSprite : Sprite { [BackgroundDependencyLoader] private void load(TextureStore textures) diff --git a/osu.Game.Tournament/Components/DrawableTournamentTeam.cs b/osu.Game.Tournament/Components/DrawableTournamentTeam.cs index 367e447947..0036f5f115 100644 --- a/osu.Game.Tournament/Components/DrawableTournamentTeam.cs +++ b/osu.Game.Tournament/Components/DrawableTournamentTeam.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -10,7 +12,7 @@ using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Components { - public abstract class DrawableTournamentTeam : CompositeDrawable + public abstract partial class DrawableTournamentTeam : CompositeDrawable { public readonly TournamentTeam Team; @@ -36,7 +38,7 @@ namespace osu.Game.Tournament.Components { if (Team == null) return; - (acronym = Team.Acronym.GetBoundCopy()).BindValueChanged(acronym => AcronymText.Text = Team?.Acronym.Value?.ToUpperInvariant() ?? string.Empty, true); + (acronym = Team.Acronym.GetBoundCopy()).BindValueChanged(_ => AcronymText.Text = Team?.Acronym.Value?.ToUpperInvariant() ?? string.Empty, true); } } } diff --git a/osu.Game.Tournament/Components/IPCErrorDialog.cs b/osu.Game.Tournament/Components/IPCErrorDialog.cs index dc039cd3bc..995bbffffc 100644 --- a/osu.Game.Tournament/Components/IPCErrorDialog.cs +++ b/osu.Game.Tournament/Components/IPCErrorDialog.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Sprites; using osu.Game.Overlays.Dialog; namespace osu.Game.Tournament.Components { - public class IPCErrorDialog : PopupDialog + public partial class IPCErrorDialog : PopupDialog { public IPCErrorDialog(string headerText, string bodyText) { diff --git a/osu.Game.Tournament/Components/RoundDisplay.cs b/osu.Game.Tournament/Components/RoundDisplay.cs index c0002e6804..6018cc6ffb 100644 --- a/osu.Game.Tournament/Components/RoundDisplay.cs +++ b/osu.Game.Tournament/Components/RoundDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; @@ -8,7 +10,7 @@ using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Components { - public class RoundDisplay : CompositeDrawable + public partial class RoundDisplay : CompositeDrawable { public RoundDisplay(TournamentMatch match) { diff --git a/osu.Game.Tournament/Components/SongBar.cs b/osu.Game.Tournament/Components/SongBar.cs index a45582a7c0..aeceece160 100644 --- a/osu.Game.Tournament/Components/SongBar.cs +++ b/osu.Game.Tournament/Components/SongBar.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -12,24 +14,24 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.Legacy; using osu.Game.Extensions; using osu.Game.Graphics; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; using osu.Game.Screens.Menu; +using osu.Game.Tournament.Models; using osuTK; using osuTK.Graphics; namespace osu.Game.Tournament.Components { - public class SongBar : CompositeDrawable + public partial class SongBar : CompositeDrawable { - private APIBeatmap beatmap; + private TournamentBeatmap beatmap; public const float HEIGHT = 145 / 2f; [Resolved] private IBindable ruleset { get; set; } - public APIBeatmap Beatmap + public TournamentBeatmap Beatmap { set { @@ -240,7 +242,7 @@ namespace osu.Game.Tournament.Components }; } - public class DiffPiece : TextFlowContainer + public partial class DiffPiece : TextFlowContainer { public DiffPiece(params (string heading, string content)[] tuples) { diff --git a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs index b064d3dd79..1157b50377 100644 --- a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs +++ b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Specialized; using System.Linq; @@ -12,15 +14,14 @@ using osu.Framework.Graphics.Shapes; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Tournament.Models; using osuTK.Graphics; namespace osu.Game.Tournament.Components { - public class TournamentBeatmapPanel : CompositeDrawable + public partial class TournamentBeatmapPanel : CompositeDrawable { - public readonly APIBeatmap Beatmap; + public readonly TournamentBeatmap Beatmap; private readonly string mod; @@ -29,9 +30,9 @@ namespace osu.Game.Tournament.Components private readonly Bindable currentMatch = new Bindable(); private Box flash; - public TournamentBeatmapPanel(APIBeatmap beatmap, string mod = null) + public TournamentBeatmapPanel(TournamentBeatmap beatmap, string mod = null) { - if (beatmap == null) throw new ArgumentNullException(nameof(beatmap)); + ArgumentNullException.ThrowIfNull(beatmap); Beatmap = beatmap; this.mod = mod; @@ -59,7 +60,7 @@ namespace osu.Game.Tournament.Components { RelativeSizeAxes = Axes.Both, Colour = OsuColour.Gray(0.5f), - OnlineInfo = Beatmap.BeatmapSet, + OnlineInfo = Beatmap, }, new FillFlowContainer { diff --git a/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs b/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs index a5ead6c2f0..8a0dd6e336 100644 --- a/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs +++ b/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs @@ -1,10 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Game.Online.API; using osu.Game.Online.Chat; using osu.Game.Overlays.Chat; using osu.Game.Tournament.IPC; @@ -12,7 +15,7 @@ using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Components { - public class TournamentMatchChatDisplay : StandAloneChatDisplay + public partial class TournamentMatchChatDisplay : StandAloneChatDisplay { private readonly Bindable chatChannel = new Bindable(); @@ -29,7 +32,7 @@ namespace osu.Game.Tournament.Components } [BackgroundDependencyLoader(true)] - private void load(MatchIPCInfo ipc) + private void load(MatchIPCInfo ipc, IAPIProvider api) { if (ipc != null) { @@ -45,7 +48,7 @@ namespace osu.Game.Tournament.Components if (manager == null) { - AddInternal(manager = new ChannelManager { HighPollRate = { Value = true } }); + AddInternal(manager = new ChannelManager(api)); Channel.BindTo(manager.CurrentChannel); } @@ -72,7 +75,7 @@ namespace osu.Game.Tournament.Components protected override StandAloneDrawableChannel CreateDrawableChannel(Channel channel) => new MatchChannel(channel); - public class MatchChannel : StandAloneDrawableChannel + public partial class MatchChannel : StandAloneDrawableChannel { public MatchChannel(Channel channel) : base(channel) @@ -81,7 +84,7 @@ namespace osu.Game.Tournament.Components } } - protected class MatchMessage : StandAloneMessage + protected partial class MatchMessage : StandAloneMessage { public MatchMessage(Message message) : base(message) diff --git a/osu.Game.Tournament/Components/TournamentModIcon.cs b/osu.Game.Tournament/Components/TournamentModIcon.cs index 57a0390ac2..76b6151519 100644 --- a/osu.Game.Tournament/Components/TournamentModIcon.cs +++ b/osu.Game.Tournament/Components/TournamentModIcon.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -16,7 +18,7 @@ namespace osu.Game.Tournament.Components /// /// Mod icon displayed in tournament usages, allowing user overridden graphics. /// - public class TournamentModIcon : CompositeDrawable + public partial class TournamentModIcon : CompositeDrawable { private readonly string modAcronym; diff --git a/osu.Game.Tournament/Components/TournamentSpriteTextWithBackground.cs b/osu.Game.Tournament/Components/TournamentSpriteTextWithBackground.cs index d92b9eb605..3a16662463 100644 --- a/osu.Game.Tournament/Components/TournamentSpriteTextWithBackground.cs +++ b/osu.Game.Tournament/Components/TournamentSpriteTextWithBackground.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -8,9 +10,10 @@ using osu.Game.Graphics; namespace osu.Game.Tournament.Components { - public class TournamentSpriteTextWithBackground : CompositeDrawable + public partial class TournamentSpriteTextWithBackground : CompositeDrawable { - protected readonly TournamentSpriteText Text; + public readonly TournamentSpriteText Text; + protected readonly Box Background; public TournamentSpriteTextWithBackground(string text = "") diff --git a/osu.Game.Tournament/Components/TourneyVideo.cs b/osu.Game.Tournament/Components/TourneyVideo.cs index 2709580385..b9ce84b735 100644 --- a/osu.Game.Tournament/Components/TourneyVideo.cs +++ b/osu.Game.Tournament/Components/TourneyVideo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -13,13 +15,15 @@ using osu.Game.Tournament.IO; namespace osu.Game.Tournament.Components { - public class TourneyVideo : CompositeDrawable + public partial class TourneyVideo : CompositeDrawable { private readonly string filename; private readonly bool drawFallbackGradient; private Video video; private ManualClock manualClock; + public bool VideoAvailable => video != null; + public TourneyVideo(string filename, bool drawFallbackGradient = false) { this.filename = filename; diff --git a/osu.Game.Tournament/Configuration/TournamentStorageManager.cs b/osu.Game.Tournament/Configuration/TournamentConfigManager.cs similarity index 53% rename from osu.Game.Tournament/Configuration/TournamentStorageManager.cs rename to osu.Game.Tournament/Configuration/TournamentConfigManager.cs index e3d0a9e75c..8f256ba9c3 100644 --- a/osu.Game.Tournament/Configuration/TournamentStorageManager.cs +++ b/osu.Game.Tournament/Configuration/TournamentConfigManager.cs @@ -1,19 +1,30 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Configuration; using osu.Framework.Platform; namespace osu.Game.Tournament.Configuration { - public class TournamentStorageManager : IniConfigManager + public class TournamentConfigManager : IniConfigManager { protected override string Filename => "tournament.ini"; - public TournamentStorageManager(Storage storage) + private const string default_tournament = "default"; + + public TournamentConfigManager(Storage storage) : base(storage) { } + + protected override void InitialiseDefaults() + { + base.InitialiseDefaults(); + + SetDefault(StorageConfig.CurrentTournament, default_tournament); + } } public enum StorageConfig diff --git a/osu.Game.Tournament/CountryExtensions.cs b/osu.Game.Tournament/CountryExtensions.cs new file mode 100644 index 0000000000..f2a583c8a5 --- /dev/null +++ b/osu.Game.Tournament/CountryExtensions.cs @@ -0,0 +1,770 @@ +// Copyright (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.Users; + +namespace osu.Game.Tournament +{ + public static class CountryExtensions + { + public static string GetAcronym(this CountryCode country) + { + switch (country) + { + case CountryCode.BD: + return "BGD"; + + case CountryCode.BE: + return "BEL"; + + case CountryCode.BF: + return "BFA"; + + case CountryCode.BG: + return "BGR"; + + case CountryCode.BA: + return "BIH"; + + case CountryCode.BB: + return "BRB"; + + case CountryCode.WF: + return "WLF"; + + case CountryCode.BL: + return "BLM"; + + case CountryCode.BM: + return "BMU"; + + case CountryCode.BN: + return "BRN"; + + case CountryCode.BO: + return "BOL"; + + case CountryCode.BH: + return "BHR"; + + case CountryCode.BI: + return "BDI"; + + case CountryCode.BJ: + return "BEN"; + + case CountryCode.BT: + return "BTN"; + + case CountryCode.JM: + return "JAM"; + + case CountryCode.BV: + return "BVT"; + + case CountryCode.BW: + return "BWA"; + + case CountryCode.WS: + return "WSM"; + + case CountryCode.BQ: + return "BES"; + + case CountryCode.BR: + return "BRA"; + + case CountryCode.BS: + return "BHS"; + + case CountryCode.JE: + return "JEY"; + + case CountryCode.BY: + return "BLR"; + + case CountryCode.BZ: + return "BLZ"; + + case CountryCode.RU: + return "RUS"; + + case CountryCode.RW: + return "RWA"; + + case CountryCode.RS: + return "SRB"; + + case CountryCode.TL: + return "TLS"; + + case CountryCode.RE: + return "REU"; + + case CountryCode.TM: + return "TKM"; + + case CountryCode.TJ: + return "TJK"; + + case CountryCode.RO: + return "ROU"; + + case CountryCode.TK: + return "TKL"; + + case CountryCode.GW: + return "GNB"; + + case CountryCode.GU: + return "GUM"; + + case CountryCode.GT: + return "GTM"; + + case CountryCode.GS: + return "SGS"; + + case CountryCode.GR: + return "GRC"; + + case CountryCode.GQ: + return "GNQ"; + + case CountryCode.GP: + return "GLP"; + + case CountryCode.JP: + return "JPN"; + + case CountryCode.GY: + return "GUY"; + + case CountryCode.GG: + return "GGY"; + + case CountryCode.GF: + return "GUF"; + + case CountryCode.GE: + return "GEO"; + + case CountryCode.GD: + return "GRD"; + + case CountryCode.GB: + return "GBR"; + + case CountryCode.GA: + return "GAB"; + + case CountryCode.SV: + return "SLV"; + + case CountryCode.GN: + return "GIN"; + + case CountryCode.GM: + return "GMB"; + + case CountryCode.GL: + return "GRL"; + + case CountryCode.GI: + return "GIB"; + + case CountryCode.GH: + return "GHA"; + + case CountryCode.OM: + return "OMN"; + + case CountryCode.TN: + return "TUN"; + + case CountryCode.JO: + return "JOR"; + + case CountryCode.HR: + return "HRV"; + + case CountryCode.HT: + return "HTI"; + + case CountryCode.HU: + return "HUN"; + + case CountryCode.HK: + return "HKG"; + + case CountryCode.HN: + return "HND"; + + case CountryCode.HM: + return "HMD"; + + case CountryCode.VE: + return "VEN"; + + case CountryCode.PR: + return "PRI"; + + case CountryCode.PS: + return "PSE"; + + case CountryCode.PW: + return "PLW"; + + case CountryCode.PT: + return "PRT"; + + case CountryCode.SJ: + return "SJM"; + + case CountryCode.PY: + return "PRY"; + + case CountryCode.IQ: + return "IRQ"; + + case CountryCode.PA: + return "PAN"; + + case CountryCode.PF: + return "PYF"; + + case CountryCode.PG: + return "PNG"; + + case CountryCode.PE: + return "PER"; + + case CountryCode.PK: + return "PAK"; + + case CountryCode.PH: + return "PHL"; + + case CountryCode.PN: + return "PCN"; + + case CountryCode.PL: + return "POL"; + + case CountryCode.PM: + return "SPM"; + + case CountryCode.ZM: + return "ZMB"; + + case CountryCode.EH: + return "ESH"; + + case CountryCode.EE: + return "EST"; + + case CountryCode.EG: + return "EGY"; + + case CountryCode.ZA: + return "ZAF"; + + case CountryCode.EC: + return "ECU"; + + case CountryCode.IT: + return "ITA"; + + case CountryCode.VN: + return "VNM"; + + case CountryCode.SB: + return "SLB"; + + case CountryCode.ET: + return "ETH"; + + case CountryCode.SO: + return "SOM"; + + case CountryCode.ZW: + return "ZWE"; + + case CountryCode.SA: + return "SAU"; + + case CountryCode.ES: + return "ESP"; + + case CountryCode.ER: + return "ERI"; + + case CountryCode.ME: + return "MNE"; + + case CountryCode.MD: + return "MDA"; + + case CountryCode.MG: + return "MDG"; + + case CountryCode.MF: + return "MAF"; + + case CountryCode.MA: + return "MAR"; + + case CountryCode.MC: + return "MCO"; + + case CountryCode.UZ: + return "UZB"; + + case CountryCode.MM: + return "MMR"; + + case CountryCode.ML: + return "MLI"; + + case CountryCode.MO: + return "MAC"; + + case CountryCode.MN: + return "MNG"; + + case CountryCode.MH: + return "MHL"; + + case CountryCode.MK: + return "MKD"; + + case CountryCode.MU: + return "MUS"; + + case CountryCode.MT: + return "MLT"; + + case CountryCode.MW: + return "MWI"; + + case CountryCode.MV: + return "MDV"; + + case CountryCode.MQ: + return "MTQ"; + + case CountryCode.MP: + return "MNP"; + + case CountryCode.MS: + return "MSR"; + + case CountryCode.MR: + return "MRT"; + + case CountryCode.IM: + return "IMN"; + + case CountryCode.UG: + return "UGA"; + + case CountryCode.TZ: + return "TZA"; + + case CountryCode.MY: + return "MYS"; + + case CountryCode.MX: + return "MEX"; + + case CountryCode.IL: + return "ISR"; + + case CountryCode.FR: + return "FRA"; + + case CountryCode.IO: + return "IOT"; + + case CountryCode.SH: + return "SHN"; + + case CountryCode.FI: + return "FIN"; + + case CountryCode.FJ: + return "FJI"; + + case CountryCode.FK: + return "FLK"; + + case CountryCode.FM: + return "FSM"; + + case CountryCode.FO: + return "FRO"; + + case CountryCode.NI: + return "NIC"; + + case CountryCode.NL: + return "NLD"; + + case CountryCode.NO: + return "NOR"; + + case CountryCode.NA: + return "NAM"; + + case CountryCode.VU: + return "VUT"; + + case CountryCode.NC: + return "NCL"; + + case CountryCode.NE: + return "NER"; + + case CountryCode.NF: + return "NFK"; + + case CountryCode.NG: + return "NGA"; + + case CountryCode.NZ: + return "NZL"; + + case CountryCode.NP: + return "NPL"; + + case CountryCode.NR: + return "NRU"; + + case CountryCode.NU: + return "NIU"; + + case CountryCode.CK: + return "COK"; + + case CountryCode.XK: + return "XKX"; + + case CountryCode.CI: + return "CIV"; + + case CountryCode.CH: + return "CHE"; + + case CountryCode.CO: + return "COL"; + + case CountryCode.CN: + return "CHN"; + + case CountryCode.CM: + return "CMR"; + + case CountryCode.CL: + return "CHL"; + + case CountryCode.CC: + return "CCK"; + + case CountryCode.CA: + return "CAN"; + + case CountryCode.CG: + return "COG"; + + case CountryCode.CF: + return "CAF"; + + case CountryCode.CD: + return "COD"; + + case CountryCode.CZ: + return "CZE"; + + case CountryCode.CY: + return "CYP"; + + case CountryCode.CX: + return "CXR"; + + case CountryCode.CR: + return "CRI"; + + case CountryCode.CW: + return "CUW"; + + case CountryCode.CV: + return "CPV"; + + case CountryCode.CU: + return "CUB"; + + case CountryCode.SZ: + return "SWZ"; + + case CountryCode.SY: + return "SYR"; + + case CountryCode.SX: + return "SXM"; + + case CountryCode.KG: + return "KGZ"; + + case CountryCode.KE: + return "KEN"; + + case CountryCode.SS: + return "SSD"; + + case CountryCode.SR: + return "SUR"; + + case CountryCode.KI: + return "KIR"; + + case CountryCode.KH: + return "KHM"; + + case CountryCode.KN: + return "KNA"; + + case CountryCode.KM: + return "COM"; + + case CountryCode.ST: + return "STP"; + + case CountryCode.SK: + return "SVK"; + + case CountryCode.KR: + return "KOR"; + + case CountryCode.SI: + return "SVN"; + + case CountryCode.KP: + return "PRK"; + + case CountryCode.KW: + return "KWT"; + + case CountryCode.SN: + return "SEN"; + + case CountryCode.SM: + return "SMR"; + + case CountryCode.SL: + return "SLE"; + + case CountryCode.SC: + return "SYC"; + + case CountryCode.KZ: + return "KAZ"; + + case CountryCode.KY: + return "CYM"; + + case CountryCode.SG: + return "SGP"; + + case CountryCode.SE: + return "SWE"; + + case CountryCode.SD: + return "SDN"; + + case CountryCode.DO: + return "DOM"; + + case CountryCode.DM: + return "DMA"; + + case CountryCode.DJ: + return "DJI"; + + case CountryCode.DK: + return "DNK"; + + case CountryCode.VG: + return "VGB"; + + case CountryCode.DE: + return "DEU"; + + case CountryCode.YE: + return "YEM"; + + case CountryCode.DZ: + return "DZA"; + + case CountryCode.US: + return "USA"; + + case CountryCode.UY: + return "URY"; + + case CountryCode.YT: + return "MYT"; + + case CountryCode.UM: + return "UMI"; + + case CountryCode.LB: + return "LBN"; + + case CountryCode.LC: + return "LCA"; + + case CountryCode.LA: + return "LAO"; + + case CountryCode.TV: + return "TUV"; + + case CountryCode.TW: + return "TWN"; + + case CountryCode.TT: + return "TTO"; + + case CountryCode.TR: + return "TUR"; + + case CountryCode.LK: + return "LKA"; + + case CountryCode.LI: + return "LIE"; + + case CountryCode.LV: + return "LVA"; + + case CountryCode.TO: + return "TON"; + + case CountryCode.LT: + return "LTU"; + + case CountryCode.LU: + return "LUX"; + + case CountryCode.LR: + return "LBR"; + + case CountryCode.LS: + return "LSO"; + + case CountryCode.TH: + return "THA"; + + case CountryCode.TF: + return "ATF"; + + case CountryCode.TG: + return "TGO"; + + case CountryCode.TD: + return "TCD"; + + case CountryCode.TC: + return "TCA"; + + case CountryCode.LY: + return "LBY"; + + case CountryCode.VA: + return "VAT"; + + case CountryCode.VC: + return "VCT"; + + case CountryCode.AE: + return "ARE"; + + case CountryCode.AD: + return "AND"; + + case CountryCode.AG: + return "ATG"; + + case CountryCode.AF: + return "AFG"; + + case CountryCode.AI: + return "AIA"; + + case CountryCode.VI: + return "VIR"; + + case CountryCode.IS: + return "ISL"; + + case CountryCode.IR: + return "IRN"; + + case CountryCode.AM: + return "ARM"; + + case CountryCode.AL: + return "ALB"; + + case CountryCode.AO: + return "AGO"; + + case CountryCode.AQ: + return "ATA"; + + case CountryCode.AS: + return "ASM"; + + case CountryCode.AR: + return "ARG"; + + case CountryCode.AU: + return "AUS"; + + case CountryCode.AT: + return "AUT"; + + case CountryCode.AW: + return "ABW"; + + case CountryCode.IN: + return "IND"; + + case CountryCode.AX: + return "ALA"; + + case CountryCode.AZ: + return "AZE"; + + case CountryCode.IE: + return "IRL"; + + case CountryCode.ID: + return "IDN"; + + case CountryCode.UA: + return "UKR"; + + case CountryCode.QA: + return "QAT"; + + case CountryCode.MZ: + return "MOZ"; + + default: + throw new ArgumentOutOfRangeException(nameof(country)); + } + } + } +} diff --git a/osu.Game.Tournament/IO/TournamentStorage.cs b/osu.Game.Tournament/IO/TournamentStorage.cs index b4859d0c91..e59f90a45e 100644 --- a/osu.Game.Tournament/IO/TournamentStorage.cs +++ b/osu.Game.Tournament/IO/TournamentStorage.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using System.IO; using osu.Framework.Bindables; using osu.Framework.Logging; using osu.Framework.Platform; @@ -11,35 +10,28 @@ using osu.Game.Tournament.Configuration; namespace osu.Game.Tournament.IO { - public class TournamentStorage : MigratableStorage + public class TournamentStorage : WrappedStorage { - private const string default_tournament = "default"; - private readonly Storage storage; - /// /// The storage where all tournaments are located. /// public readonly Storage AllTournaments; - private readonly TournamentStorageManager storageConfig; public readonly Bindable CurrentTournament; + protected TournamentConfigManager TournamentConfigManager { get; } + public TournamentStorage(Storage storage) : base(storage.GetStorageForDirectory("tournaments"), string.Empty) { - this.storage = storage; AllTournaments = UnderlyingStorage; - storageConfig = new TournamentStorageManager(storage); + TournamentConfigManager = new TournamentConfigManager(storage); - if (storage.Exists("tournament.ini")) - { - ChangeTargetStorage(AllTournaments.GetStorageForDirectory(storageConfig.Get(StorageConfig.CurrentTournament))); - } - else - Migrate(AllTournaments.GetStorageForDirectory(default_tournament)); + CurrentTournament = TournamentConfigManager.GetBindable(StorageConfig.CurrentTournament); + + ChangeTargetStorage(AllTournaments.GetStorageForDirectory(CurrentTournament.Value)); - CurrentTournament = storageConfig.GetBindable(StorageConfig.CurrentTournament); Logger.Log("Using tournament storage: " + GetFullPath(string.Empty)); CurrentTournament.BindValueChanged(updateTournament); @@ -51,62 +43,6 @@ namespace osu.Game.Tournament.IO Logger.Log("Changing tournament storage: " + GetFullPath(string.Empty)); } - protected override void ChangeTargetStorage(Storage newStorage) - { - // due to an unfortunate oversight, on OSes that are sensitive to pathname casing - // the custom flags directory needed to be named `Flags` (uppercase), - // while custom mods and videos directories needed to be named `mods` and `videos` respectively (lowercase). - // to unify handling to uppercase, move any non-compliant directories automatically for the user to migrate. - // can be removed 20220528 - if (newStorage.ExistsDirectory("flags")) - AttemptOperation(() => Directory.Move(newStorage.GetFullPath("flags"), newStorage.GetFullPath("Flags"))); - if (newStorage.ExistsDirectory("mods")) - AttemptOperation(() => Directory.Move(newStorage.GetFullPath("mods"), newStorage.GetFullPath("Mods"))); - if (newStorage.ExistsDirectory("videos")) - AttemptOperation(() => Directory.Move(newStorage.GetFullPath("videos"), newStorage.GetFullPath("Videos"))); - - base.ChangeTargetStorage(newStorage); - } - public IEnumerable ListTournaments() => AllTournaments.GetDirectories(string.Empty); - - public override bool Migrate(Storage newStorage) - { - // this migration only happens once on moving to the per-tournament storage system. - // listed files are those known at that point in time. - // this can be removed at some point in the future (6 months obsoletion would mean 2021-04-19) - - var source = new DirectoryInfo(storage.GetFullPath("tournament")); - var destination = new DirectoryInfo(newStorage.GetFullPath(".")); - - if (source.Exists) - { - Logger.Log("Migrating tournament assets to default tournament storage."); - CopyRecursive(source, destination); - DeleteRecursive(source); - } - - moveFileIfExists(TournamentGameBase.BRACKET_FILENAME, destination); - moveFileIfExists("drawings.txt", destination); - moveFileIfExists("drawings_results.txt", destination); - moveFileIfExists("drawings.ini", destination); - - ChangeTargetStorage(newStorage); - storageConfig.SetValue(StorageConfig.CurrentTournament, default_tournament); - storageConfig.Save(); - - return true; - } - - private void moveFileIfExists(string file, DirectoryInfo destination) - { - if (!storage.Exists(file)) - return; - - Logger.Log($"Migrating {file} to default tournament storage."); - var fileInfo = new System.IO.FileInfo(storage.GetFullPath(file)); - AttemptOperation(() => fileInfo.CopyTo(Path.Combine(destination.FullName, fileInfo.Name), true)); - fileInfo.Delete(); - } } } diff --git a/osu.Game.Tournament/IO/TournamentVideoResourceStore.cs b/osu.Game.Tournament/IO/TournamentVideoResourceStore.cs index 964d03220d..ba584f1d3e 100644 --- a/osu.Game.Tournament/IO/TournamentVideoResourceStore.cs +++ b/osu.Game.Tournament/IO/TournamentVideoResourceStore.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.IO.Stores; using osu.Framework.Platform; diff --git a/osu.Game.Tournament/IPC/FileBasedIPC.cs b/osu.Game.Tournament/IPC/FileBasedIPC.cs index 5278d538d2..7babb3ea5a 100644 --- a/osu.Game.Tournament/IPC/FileBasedIPC.cs +++ b/osu.Game.Tournament/IPC/FileBasedIPC.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.IO; using System.Linq; @@ -20,7 +22,7 @@ using osu.Game.Tournament.Models; namespace osu.Game.Tournament.IPC { - public class FileBasedIPC : MatchIPCInfo + public partial class FileBasedIPC : MatchIPCInfo { public Storage IPCStorage { get; private set; } @@ -94,7 +96,7 @@ namespace osu.Game.Tournament.IPC else { beatmapLookupRequest = new GetBeatmapRequest(new APIBeatmap { OnlineID = beatmapId }); - beatmapLookupRequest.Success += b => Beatmap.Value = b; + beatmapLookupRequest.Success += b => Beatmap.Value = new TournamentBeatmap(b); API.Queue(beatmapLookupRequest); } } @@ -125,7 +127,7 @@ namespace osu.Game.Tournament.IPC using (var stream = IPCStorage.GetStream(file_ipc_state_filename)) using (var sr = new StreamReader(stream)) { - State.Value = (TourneyState)Enum.Parse(typeof(TourneyState), sr.ReadLine().AsNonNull()); + State.Value = Enum.Parse(sr.ReadLine().AsNonNull()); } } catch (Exception) @@ -243,8 +245,10 @@ namespace osu.Game.Tournament.IPC { string stableInstallPath; +#pragma warning disable CA1416 using (RegistryKey key = Registry.ClassesRoot.OpenSubKey("osu")) stableInstallPath = key?.OpenSubKey(@"shell\open\command")?.GetValue(string.Empty)?.ToString()?.Split('"')[1].Replace("osu!.exe", ""); +#pragma warning restore CA1416 if (ipcFileExistsInDirectory(stableInstallPath)) return stableInstallPath; diff --git a/osu.Game.Tournament/IPC/MatchIPCInfo.cs b/osu.Game.Tournament/IPC/MatchIPCInfo.cs index fa7079b824..3bf790d58e 100644 --- a/osu.Game.Tournament/IPC/MatchIPCInfo.cs +++ b/osu.Game.Tournament/IPC/MatchIPCInfo.cs @@ -1,16 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Beatmaps.Legacy; -using osu.Game.Online.API.Requests.Responses; +using osu.Game.Tournament.Models; namespace osu.Game.Tournament.IPC { - public class MatchIPCInfo : Component + public partial class MatchIPCInfo : Component { - public Bindable Beatmap { get; } = new Bindable(); + public Bindable Beatmap { get; } = new Bindable(); public Bindable Mods { get; } = new Bindable(); public Bindable State { get; } = new Bindable(); public Bindable ChatChannel { get; } = new Bindable(); diff --git a/osu.Game.Tournament/IPC/TourneyState.cs b/osu.Game.Tournament/IPC/TourneyState.cs index ef1c612a53..2c7253dc10 100644 --- a/osu.Game.Tournament/IPC/TourneyState.cs +++ b/osu.Game.Tournament/IPC/TourneyState.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Tournament.IPC { public enum TourneyState diff --git a/osu.Game.Tournament/JsonPointConverter.cs b/osu.Game.Tournament/JsonPointConverter.cs index 32bbe2dc18..d3b40a3526 100644 --- a/osu.Game.Tournament/JsonPointConverter.cs +++ b/osu.Game.Tournament/JsonPointConverter.cs @@ -1,9 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using System.Drawing; +using System.Globalization; using Newtonsoft.Json; namespace osu.Game.Tournament @@ -29,7 +32,9 @@ namespace osu.Game.Tournament Debug.Assert(str != null); - return new PointConverter().ConvertFromString(str) as Point? ?? new Point(); + // Null check suppression is required due to .NET standard expecting a non-null context. + // Seems to work fine at a runtime level (and the parameter is nullable in .NET 6+). + return new PointConverter().ConvertFromString(null!, CultureInfo.InvariantCulture, str) as Point? ?? new Point(); } var point = new Point(); diff --git a/osu.Game.Tournament/Models/BeatmapChoice.cs b/osu.Game.Tournament/Models/BeatmapChoice.cs index 384b349b24..ddd4597722 100644 --- a/osu.Game.Tournament/Models/BeatmapChoice.cs +++ b/osu.Game.Tournament/Models/BeatmapChoice.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using Newtonsoft.Json; using Newtonsoft.Json.Converters; diff --git a/osu.Game.Tournament/Models/LadderEditorInfo.cs b/osu.Game.Tournament/Models/LadderEditorInfo.cs index 70fd115e25..84ebeff3db 100644 --- a/osu.Game.Tournament/Models/LadderEditorInfo.cs +++ b/osu.Game.Tournament/Models/LadderEditorInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; namespace osu.Game.Tournament.Models diff --git a/osu.Game.Tournament/Models/LadderInfo.cs b/osu.Game.Tournament/Models/LadderInfo.cs index 7794019437..6b64a1156e 100644 --- a/osu.Game.Tournament/Models/LadderInfo.cs +++ b/osu.Game.Tournament/Models/LadderInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using Newtonsoft.Json; @@ -38,5 +40,7 @@ namespace osu.Game.Tournament.Models MinValue = 3, MaxValue = 4, }; + + public Bindable AutoProgressScreens = new BindableBool(true); } } diff --git a/osu.Game.Tournament/Models/RoundBeatmap.cs b/osu.Game.Tournament/Models/RoundBeatmap.cs index 2fd79546f1..65ef77e53d 100644 --- a/osu.Game.Tournament/Models/RoundBeatmap.cs +++ b/osu.Game.Tournament/Models/RoundBeatmap.cs @@ -1,8 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Newtonsoft.Json; -using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Tournament.Models { @@ -12,6 +13,6 @@ namespace osu.Game.Tournament.Models public string Mods; [JsonProperty("BeatmapInfo")] - public APIBeatmap Beatmap; + public TournamentBeatmap Beatmap; } } diff --git a/osu.Game.Tournament/Models/SeedingBeatmap.cs b/osu.Game.Tournament/Models/SeedingBeatmap.cs index 26f3016ac8..0ac312342c 100644 --- a/osu.Game.Tournament/Models/SeedingBeatmap.cs +++ b/osu.Game.Tournament/Models/SeedingBeatmap.cs @@ -3,7 +3,6 @@ using Newtonsoft.Json; using osu.Framework.Bindables; -using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Tournament.Models { @@ -12,14 +11,14 @@ namespace osu.Game.Tournament.Models public int ID; [JsonProperty("BeatmapInfo")] - public APIBeatmap Beatmap; + public TournamentBeatmap? Beatmap; public long Score; public Bindable Seed = new BindableInt { MinValue = 1, - MaxValue = 64 + MaxValue = 256 }; } } diff --git a/osu.Game.Tournament/Models/SeedingResult.cs b/osu.Game.Tournament/Models/SeedingResult.cs index d37c967762..2a404153e6 100644 --- a/osu.Game.Tournament/Models/SeedingResult.cs +++ b/osu.Game.Tournament/Models/SeedingResult.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Bindables; @@ -15,7 +17,7 @@ namespace osu.Game.Tournament.Models public Bindable Seed = new BindableInt { MinValue = 1, - MaxValue = 64 + MaxValue = 256 }; } } diff --git a/osu.Game.Tournament/Models/StableInfo.cs b/osu.Game.Tournament/Models/StableInfo.cs index c835b11a4d..1ae80d4596 100644 --- a/osu.Game.Tournament/Models/StableInfo.cs +++ b/osu.Game.Tournament/Models/StableInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.IO; using Newtonsoft.Json; diff --git a/osu.Game.Tournament/Models/TournamentBeatmap.cs b/osu.Game.Tournament/Models/TournamentBeatmap.cs new file mode 100644 index 0000000000..7f57b6a151 --- /dev/null +++ b/osu.Game.Tournament/Models/TournamentBeatmap.cs @@ -0,0 +1,98 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Beatmaps; +using osu.Game.Extensions; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets; + +namespace osu.Game.Tournament.Models +{ + public class TournamentBeatmap : IBeatmapInfo, IBeatmapSetOnlineInfo + { + public int OnlineID { get; set; } + + public string DifficultyName { get; set; } = string.Empty; + + public double BPM { get; set; } + + public double Length { get; set; } + + public double StarRating { get; set; } + + public IBeatmapMetadataInfo Metadata { get; set; } = new BeatmapMetadata(); + + public IBeatmapDifficultyInfo Difficulty { get; set; } = new BeatmapDifficulty(); + + public BeatmapSetOnlineCovers Covers { get; set; } + + public TournamentBeatmap() + { + } + + public TournamentBeatmap(APIBeatmap beatmap) + { + OnlineID = beatmap.OnlineID; + DifficultyName = beatmap.DifficultyName; + BPM = beatmap.BPM; + Length = beatmap.Length; + StarRating = beatmap.StarRating; + Metadata = beatmap.Metadata; + Difficulty = beatmap.Difficulty; + Covers = beatmap.BeatmapSet?.Covers ?? new BeatmapSetOnlineCovers(); + } + + public bool Equals(IBeatmapInfo? other) => other is TournamentBeatmap b && this.MatchesOnlineID(b); + + #region IBeatmapInfo/IBeatmapSetOnlineInfo explicit implementation + + IBeatmapSetInfo IBeatmapInfo.BeatmapSet => throw new NotImplementedException(); + + string IBeatmapSetOnlineInfo.Preview => throw new NotImplementedException(); + + double IBeatmapSetOnlineInfo.BPM => throw new NotImplementedException(); + + int IBeatmapSetOnlineInfo.PlayCount => throw new NotImplementedException(); + + int IBeatmapSetOnlineInfo.FavouriteCount => throw new NotImplementedException(); + + bool IBeatmapSetOnlineInfo.HasFavourited => throw new NotImplementedException(); + + BeatmapSetOnlineAvailability IBeatmapSetOnlineInfo.Availability => throw new NotImplementedException(); + + BeatmapSetOnlineGenre IBeatmapSetOnlineInfo.Genre => throw new NotImplementedException(); + + BeatmapSetOnlineLanguage IBeatmapSetOnlineInfo.Language => throw new NotImplementedException(); + + int? IBeatmapSetOnlineInfo.TrackId => throw new NotImplementedException(); + + int[] IBeatmapSetOnlineInfo.Ratings => throw new NotImplementedException(); + + BeatmapSetHypeStatus IBeatmapSetOnlineInfo.HypeStatus => throw new NotImplementedException(); + + BeatmapSetNominationStatus IBeatmapSetOnlineInfo.NominationStatus => throw new NotImplementedException(); + + string IBeatmapInfo.Hash => throw new NotImplementedException(); + + string IBeatmapInfo.MD5Hash => throw new NotImplementedException(); + + IRulesetInfo IBeatmapInfo.Ruleset => throw new NotImplementedException(); + + DateTimeOffset IBeatmapSetOnlineInfo.Submitted => throw new NotImplementedException(); + + DateTimeOffset? IBeatmapSetOnlineInfo.Ranked => throw new NotImplementedException(); + + DateTimeOffset? IBeatmapSetOnlineInfo.LastUpdated => throw new NotImplementedException(); + + BeatmapOnlineStatus IBeatmapSetOnlineInfo.Status => throw new NotImplementedException(); + + bool IBeatmapSetOnlineInfo.HasExplicitContent => throw new NotImplementedException(); + + bool IBeatmapSetOnlineInfo.HasVideo => throw new NotImplementedException(); + + bool IBeatmapSetOnlineInfo.HasStoryboard => throw new NotImplementedException(); + + #endregion + } +} diff --git a/osu.Game.Tournament/Models/TournamentMatch.cs b/osu.Game.Tournament/Models/TournamentMatch.cs index bdfb1728f3..97c2060f2c 100644 --- a/osu.Game.Tournament/Models/TournamentMatch.cs +++ b/osu.Game.Tournament/Models/TournamentMatch.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -104,13 +106,16 @@ namespace osu.Game.Tournament.Models } /// - /// Initialise this match with zeroed scores. Will be a noop if either team is not present. + /// Initialise this match with zeroed scores. Will be a noop if either team is not present or if either of the scores are non-zero. /// public void StartMatch() { if (Team1.Value == null || Team2.Value == null) return; + if (Team1Score.Value > 0 || Team2Score.Value > 0) + return; + Team1Score.Value = 0; Team2Score.Value = 0; } diff --git a/osu.Game.Tournament/Models/TournamentProgression.cs b/osu.Game.Tournament/Models/TournamentProgression.cs index 5575b4c29e..6c3ba1922a 100644 --- a/osu.Game.Tournament/Models/TournamentProgression.cs +++ b/osu.Game.Tournament/Models/TournamentProgression.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; namespace osu.Game.Tournament.Models diff --git a/osu.Game.Tournament/Models/TournamentRound.cs b/osu.Game.Tournament/Models/TournamentRound.cs index ab39605d07..480d6c37c3 100644 --- a/osu.Game.Tournament/Models/TournamentRound.cs +++ b/osu.Game.Tournament/Models/TournamentRound.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using Newtonsoft.Json; diff --git a/osu.Game.Tournament/Models/TournamentTeam.cs b/osu.Game.Tournament/Models/TournamentTeam.cs index 24dee3b372..1beea517d5 100644 --- a/osu.Game.Tournament/Models/TournamentTeam.cs +++ b/osu.Game.Tournament/Models/TournamentTeam.cs @@ -1,11 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using Newtonsoft.Json; using osu.Framework.Bindables; -using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Tournament.Models { @@ -36,7 +37,7 @@ namespace osu.Game.Tournament.Models { get { - int[] ranks = Players.Select(p => p.Statistics?.GlobalRank) + int[] ranks = Players.Select(p => p.Rank) .Where(i => i.HasValue) .Select(i => i.Value) .ToArray(); @@ -53,11 +54,11 @@ namespace osu.Game.Tournament.Models public Bindable LastYearPlacing = new BindableInt { MinValue = 1, - MaxValue = 64 + MaxValue = 256 }; [JsonProperty] - public BindableList Players { get; set; } = new BindableList(); + public BindableList Players { get; set; } = new BindableList(); public TournamentTeam() { @@ -65,14 +66,14 @@ namespace osu.Game.Tournament.Models { // use a sane default flag name based on acronym. if (val.OldValue.StartsWith(FlagName.Value, StringComparison.InvariantCultureIgnoreCase)) - FlagName.Value = val.NewValue.Length >= 2 ? val.NewValue?.Substring(0, 2).ToUpper() : string.Empty; + FlagName.Value = val.NewValue.Length >= 2 ? val.NewValue?.Substring(0, 2).ToUpperInvariant() : string.Empty; }; FullName.ValueChanged += val => { // use a sane acronym based on full name. if (val.OldValue.StartsWith(Acronym.Value, StringComparison.InvariantCultureIgnoreCase)) - Acronym.Value = val.NewValue.Length >= 3 ? val.NewValue?.Substring(0, 3).ToUpper() : string.Empty; + Acronym.Value = val.NewValue.Length >= 3 ? val.NewValue?.Substring(0, 3).ToUpperInvariant() : string.Empty; }; } diff --git a/osu.Game.Tournament/Models/TournamentUser.cs b/osu.Game.Tournament/Models/TournamentUser.cs new file mode 100644 index 0000000000..78ca014860 --- /dev/null +++ b/osu.Game.Tournament/Models/TournamentUser.cs @@ -0,0 +1,59 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using Newtonsoft.Json; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Users; + +namespace osu.Game.Tournament.Models +{ + /// + /// A tournament player user, containing simple information about the player. + /// + [Serializable] + public class TournamentUser : IUser + { + [JsonProperty(@"id")] + public int OnlineID { get; set; } + + public string Username { get; set; } = string.Empty; + + /// + /// The player's country. + /// + [JsonProperty("country_code")] + public CountryCode CountryCode { get; set; } + + /// + /// The player's global rank, or null if not available. + /// + public int? Rank { get; set; } + + /// + /// A URL to the player's profile cover. + /// + public string CoverUrl { get; set; } = string.Empty; + + public APIUser ToAPIUser() + { + var user = new APIUser + { + Id = OnlineID, + Username = Username, + CountryCode = CountryCode, + CoverUrl = CoverUrl, + }; + + user.Statistics = new UserStatistics + { + User = user, + GlobalRank = Rank + }; + + return user; + } + + bool IUser.IsBot => false; + } +} diff --git a/osu.Game.Tournament/Properties/AssemblyInfo.cs b/osu.Game.Tournament/Properties/AssemblyInfo.cs index 70e42bcafb..2eb8c3e1d6 100644 --- a/osu.Game.Tournament/Properties/AssemblyInfo.cs +++ b/osu.Game.Tournament/Properties/AssemblyInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Runtime.CompilerServices; // We publish our internal attributes to other sub-projects of the framework. diff --git a/osu.Game.Tournament/Resources/countries.json b/osu.Game.Tournament/Resources/countries.json deleted file mode 100644 index 7306a8bec5..0000000000 --- a/osu.Game.Tournament/Resources/countries.json +++ /dev/null @@ -1,1252 +0,0 @@ -[ - { - "FlagName": "BD", - "FullName": "Bangladesh", - "Acronym": "BGD" - }, - { - "FlagName": "BE", - "FullName": "Belgium", - "Acronym": "BEL" - }, - { - "FlagName": "BF", - "FullName": "Burkina Faso", - "Acronym": "BFA" - }, - { - "FlagName": "BG", - "FullName": "Bulgaria", - "Acronym": "BGR" - }, - { - "FlagName": "BA", - "FullName": "Bosnia and Herzegovina", - "Acronym": "BIH" - }, - { - "FlagName": "BB", - "FullName": "Barbados", - "Acronym": "BRB" - }, - { - "FlagName": "WF", - "FullName": "Wallis and Futuna", - "Acronym": "WLF" - }, - { - "FlagName": "BL", - "FullName": "Saint Barthelemy", - "Acronym": "BLM" - }, - { - "FlagName": "BM", - "FullName": "Bermuda", - "Acronym": "BMU" - }, - { - "FlagName": "BN", - "FullName": "Brunei", - "Acronym": "BRN" - }, - { - "FlagName": "BO", - "FullName": "Bolivia", - "Acronym": "BOL" - }, - { - "FlagName": "BH", - "FullName": "Bahrain", - "Acronym": "BHR" - }, - { - "FlagName": "BI", - "FullName": "Burundi", - "Acronym": "BDI" - }, - { - "FlagName": "BJ", - "FullName": "Benin", - "Acronym": "BEN" - }, - { - "FlagName": "BT", - "FullName": "Bhutan", - "Acronym": "BTN" - }, - { - "FlagName": "JM", - "FullName": "Jamaica", - "Acronym": "JAM" - }, - { - "FlagName": "BV", - "FullName": "Bouvet Island", - "Acronym": "BVT" - }, - { - "FlagName": "BW", - "FullName": "Botswana", - "Acronym": "BWA" - }, - { - "FlagName": "WS", - "FullName": "Samoa", - "Acronym": "WSM" - }, - { - "FlagName": "BQ", - "FullName": "Bonaire, Saint Eustatius and Saba", - "Acronym": "BES" - }, - { - "FlagName": "BR", - "FullName": "Brazil", - "Acronym": "BRA" - }, - { - "FlagName": "BS", - "FullName": "Bahamas", - "Acronym": "BHS" - }, - { - "FlagName": "JE", - "FullName": "Jersey", - "Acronym": "JEY" - }, - { - "FlagName": "BY", - "FullName": "Belarus", - "Acronym": "BLR" - }, - { - "FlagName": "BZ", - "FullName": "Belize", - "Acronym": "BLZ" - }, - { - "FlagName": "RU", - "FullName": "Russia", - "Acronym": "RUS" - }, - { - "FlagName": "RW", - "FullName": "Rwanda", - "Acronym": "RWA" - }, - { - "FlagName": "RS", - "FullName": "Serbia", - "Acronym": "SRB" - }, - { - "FlagName": "TL", - "FullName": "East Timor", - "Acronym": "TLS" - }, - { - "FlagName": "RE", - "FullName": "Reunion", - "Acronym": "REU" - }, - { - "FlagName": "TM", - "FullName": "Turkmenistan", - "Acronym": "TKM" - }, - { - "FlagName": "TJ", - "FullName": "Tajikistan", - "Acronym": "TJK" - }, - { - "FlagName": "RO", - "FullName": "Romania", - "Acronym": "ROU" - }, - { - "FlagName": "TK", - "FullName": "Tokelau", - "Acronym": "TKL" - }, - { - "FlagName": "GW", - "FullName": "Guinea-Bissau", - "Acronym": "GNB" - }, - { - "FlagName": "GU", - "FullName": "Guam", - "Acronym": "GUM" - }, - { - "FlagName": "GT", - "FullName": "Guatemala", - "Acronym": "GTM" - }, - { - "FlagName": "GS", - "FullName": "South Georgia and the South Sandwich Islands", - "Acronym": "SGS" - }, - { - "FlagName": "GR", - "FullName": "Greece", - "Acronym": "GRC" - }, - { - "FlagName": "GQ", - "FullName": "Equatorial Guinea", - "Acronym": "GNQ" - }, - { - "FlagName": "GP", - "FullName": "Guadeloupe", - "Acronym": "GLP" - }, - { - "FlagName": "JP", - "FullName": "Japan", - "Acronym": "JPN" - }, - { - "FlagName": "GY", - "FullName": "Guyana", - "Acronym": "GUY" - }, - { - "FlagName": "GG", - "FullName": "Guernsey", - "Acronym": "GGY" - }, - { - "FlagName": "GF", - "FullName": "French Guiana", - "Acronym": "GUF" - }, - { - "FlagName": "GE", - "FullName": "Georgia", - "Acronym": "GEO" - }, - { - "FlagName": "GD", - "FullName": "Grenada", - "Acronym": "GRD" - }, - { - "FlagName": "GB", - "FullName": "United Kingdom", - "Acronym": "GBR" - }, - { - "FlagName": "GA", - "FullName": "Gabon", - "Acronym": "GAB" - }, - { - "FlagName": "SV", - "FullName": "El Salvador", - "Acronym": "SLV" - }, - { - "FlagName": "GN", - "FullName": "Guinea", - "Acronym": "GIN" - }, - { - "FlagName": "GM", - "FullName": "Gambia", - "Acronym": "GMB" - }, - { - "FlagName": "GL", - "FullName": "Greenland", - "Acronym": "GRL" - }, - { - "FlagName": "GI", - "FullName": "Gibraltar", - "Acronym": "GIB" - }, - { - "FlagName": "GH", - "FullName": "Ghana", - "Acronym": "GHA" - }, - { - "FlagName": "OM", - "FullName": "Oman", - "Acronym": "OMN" - }, - { - "FlagName": "TN", - "FullName": "Tunisia", - "Acronym": "TUN" - }, - { - "FlagName": "JO", - "FullName": "Jordan", - "Acronym": "JOR" - }, - { - "FlagName": "HR", - "FullName": "Croatia", - "Acronym": "HRV" - }, - { - "FlagName": "HT", - "FullName": "Haiti", - "Acronym": "HTI" - }, - { - "FlagName": "HU", - "FullName": "Hungary", - "Acronym": "HUN" - }, - { - "FlagName": "HK", - "FullName": "Hong Kong", - "Acronym": "HKG" - }, - { - "FlagName": "HN", - "FullName": "Honduras", - "Acronym": "HND" - }, - { - "FlagName": "HM", - "FullName": "Heard Island and McDonald Islands", - "Acronym": "HMD" - }, - { - "FlagName": "VE", - "FullName": "Venezuela", - "Acronym": "VEN" - }, - { - "FlagName": "PR", - "FullName": "Puerto Rico", - "Acronym": "PRI" - }, - { - "FlagName": "PS", - "FullName": "Palestinian Territory", - "Acronym": "PSE" - }, - { - "FlagName": "PW", - "FullName": "Palau", - "Acronym": "PLW" - }, - { - "FlagName": "PT", - "FullName": "Portugal", - "Acronym": "PRT" - }, - { - "FlagName": "SJ", - "FullName": "Svalbard and Jan Mayen", - "Acronym": "SJM" - }, - { - "FlagName": "PY", - "FullName": "Paraguay", - "Acronym": "PRY" - }, - { - "FlagName": "IQ", - "FullName": "Iraq", - "Acronym": "IRQ" - }, - { - "FlagName": "PA", - "FullName": "Panama", - "Acronym": "PAN" - }, - { - "FlagName": "PF", - "FullName": "French Polynesia", - "Acronym": "PYF" - }, - { - "FlagName": "PG", - "FullName": "Papua New Guinea", - "Acronym": "PNG" - }, - { - "FlagName": "PE", - "FullName": "Peru", - "Acronym": "PER" - }, - { - "FlagName": "PK", - "FullName": "Pakistan", - "Acronym": "PAK" - }, - { - "FlagName": "PH", - "FullName": "Philippines", - "Acronym": "PHL" - }, - { - "FlagName": "PN", - "FullName": "Pitcairn", - "Acronym": "PCN" - }, - { - "FlagName": "PL", - "FullName": "Poland", - "Acronym": "POL" - }, - { - "FlagName": "PM", - "FullName": "Saint Pierre and Miquelon", - "Acronym": "SPM" - }, - { - "FlagName": "ZM", - "FullName": "Zambia", - "Acronym": "ZMB" - }, - { - "FlagName": "EH", - "FullName": "Western Sahara", - "Acronym": "ESH" - }, - { - "FlagName": "EE", - "FullName": "Estonia", - "Acronym": "EST" - }, - { - "FlagName": "EG", - "FullName": "Egypt", - "Acronym": "EGY" - }, - { - "FlagName": "ZA", - "FullName": "South Africa", - "Acronym": "ZAF" - }, - { - "FlagName": "EC", - "FullName": "Ecuador", - "Acronym": "ECU" - }, - { - "FlagName": "IT", - "FullName": "Italy", - "Acronym": "ITA" - }, - { - "FlagName": "VN", - "FullName": "Vietnam", - "Acronym": "VNM" - }, - { - "FlagName": "SB", - "FullName": "Solomon Islands", - "Acronym": "SLB" - }, - { - "FlagName": "ET", - "FullName": "Ethiopia", - "Acronym": "ETH" - }, - { - "FlagName": "SO", - "FullName": "Somalia", - "Acronym": "SOM" - }, - { - "FlagName": "ZW", - "FullName": "Zimbabwe", - "Acronym": "ZWE" - }, - { - "FlagName": "SA", - "FullName": "Saudi Arabia", - "Acronym": "SAU" - }, - { - "FlagName": "ES", - "FullName": "Spain", - "Acronym": "ESP" - }, - { - "FlagName": "ER", - "FullName": "Eritrea", - "Acronym": "ERI" - }, - { - "FlagName": "ME", - "FullName": "Montenegro", - "Acronym": "MNE" - }, - { - "FlagName": "MD", - "FullName": "Moldova", - "Acronym": "MDA" - }, - { - "FlagName": "MG", - "FullName": "Madagascar", - "Acronym": "MDG" - }, - { - "FlagName": "MF", - "FullName": "Saint Martin", - "Acronym": "MAF" - }, - { - "FlagName": "MA", - "FullName": "Morocco", - "Acronym": "MAR" - }, - { - "FlagName": "MC", - "FullName": "Monaco", - "Acronym": "MCO" - }, - { - "FlagName": "UZ", - "FullName": "Uzbekistan", - "Acronym": "UZB" - }, - { - "FlagName": "MM", - "FullName": "Myanmar", - "Acronym": "MMR" - }, - { - "FlagName": "ML", - "FullName": "Mali", - "Acronym": "MLI" - }, - { - "FlagName": "MO", - "FullName": "Macao", - "Acronym": "MAC" - }, - { - "FlagName": "MN", - "FullName": "Mongolia", - "Acronym": "MNG" - }, - { - "FlagName": "MH", - "FullName": "Marshall Islands", - "Acronym": "MHL" - }, - { - "FlagName": "MK", - "FullName": "North Macedonia", - "Acronym": "MKD" - }, - { - "FlagName": "MU", - "FullName": "Mauritius", - "Acronym": "MUS" - }, - { - "FlagName": "MT", - "FullName": "Malta", - "Acronym": "MLT" - }, - { - "FlagName": "MW", - "FullName": "Malawi", - "Acronym": "MWI" - }, - { - "FlagName": "MV", - "FullName": "Maldives", - "Acronym": "MDV" - }, - { - "FlagName": "MQ", - "FullName": "Martinique", - "Acronym": "MTQ" - }, - { - "FlagName": "MP", - "FullName": "Northern Mariana Islands", - "Acronym": "MNP" - }, - { - "FlagName": "MS", - "FullName": "Montserrat", - "Acronym": "MSR" - }, - { - "FlagName": "MR", - "FullName": "Mauritania", - "Acronym": "MRT" - }, - { - "FlagName": "IM", - "FullName": "Isle of Man", - "Acronym": "IMN" - }, - { - "FlagName": "UG", - "FullName": "Uganda", - "Acronym": "UGA" - }, - { - "FlagName": "TZ", - "FullName": "Tanzania", - "Acronym": "TZA" - }, - { - "FlagName": "MY", - "FullName": "Malaysia", - "Acronym": "MYS" - }, - { - "FlagName": "MX", - "FullName": "Mexico", - "Acronym": "MEX" - }, - { - "FlagName": "IL", - "FullName": "Israel", - "Acronym": "ISR" - }, - { - "FlagName": "FR", - "FullName": "France", - "Acronym": "FRA" - }, - { - "FlagName": "IO", - "FullName": "British Indian Ocean Territory", - "Acronym": "IOT" - }, - { - "FlagName": "SH", - "FullName": "Saint Helena", - "Acronym": "SHN" - }, - { - "FlagName": "FI", - "FullName": "Finland", - "Acronym": "FIN" - }, - { - "FlagName": "FJ", - "FullName": "Fiji", - "Acronym": "FJI" - }, - { - "FlagName": "FK", - "FullName": "Falkland Islands", - "Acronym": "FLK" - }, - { - "FlagName": "FM", - "FullName": "Micronesia", - "Acronym": "FSM" - }, - { - "FlagName": "FO", - "FullName": "Faroe Islands", - "Acronym": "FRO" - }, - { - "FlagName": "NI", - "FullName": "Nicaragua", - "Acronym": "NIC" - }, - { - "FlagName": "NL", - "FullName": "Netherlands", - "Acronym": "NLD" - }, - { - "FlagName": "NO", - "FullName": "Norway", - "Acronym": "NOR" - }, - { - "FlagName": "NA", - "FullName": "Namibia", - "Acronym": "NAM" - }, - { - "FlagName": "VU", - "FullName": "Vanuatu", - "Acronym": "VUT" - }, - { - "FlagName": "NC", - "FullName": "New Caledonia", - "Acronym": "NCL" - }, - { - "FlagName": "NE", - "FullName": "Niger", - "Acronym": "NER" - }, - { - "FlagName": "NF", - "FullName": "Norfolk Island", - "Acronym": "NFK" - }, - { - "FlagName": "NG", - "FullName": "Nigeria", - "Acronym": "NGA" - }, - { - "FlagName": "NZ", - "FullName": "New Zealand", - "Acronym": "NZL" - }, - { - "FlagName": "NP", - "FullName": "Nepal", - "Acronym": "NPL" - }, - { - "FlagName": "NR", - "FullName": "Nauru", - "Acronym": "NRU" - }, - { - "FlagName": "NU", - "FullName": "Niue", - "Acronym": "NIU" - }, - { - "FlagName": "CK", - "FullName": "Cook Islands", - "Acronym": "COK" - }, - { - "FlagName": "XK", - "FullName": "Kosovo", - "Acronym": "XKX" - }, - { - "FlagName": "CI", - "FullName": "Ivory Coast", - "Acronym": "CIV" - }, - { - "FlagName": "CH", - "FullName": "Switzerland", - "Acronym": "CHE" - }, - { - "FlagName": "CO", - "FullName": "Colombia", - "Acronym": "COL" - }, - { - "FlagName": "CN", - "FullName": "China", - "Acronym": "CHN" - }, - { - "FlagName": "CM", - "FullName": "Cameroon", - "Acronym": "CMR" - }, - { - "FlagName": "CL", - "FullName": "Chile", - "Acronym": "CHL" - }, - { - "FlagName": "CC", - "FullName": "Cocos Islands", - "Acronym": "CCK" - }, - { - "FlagName": "CA", - "FullName": "Canada", - "Acronym": "CAN" - }, - { - "FlagName": "CG", - "FullName": "Republic of the Congo", - "Acronym": "COG" - }, - { - "FlagName": "CF", - "FullName": "Central African Republic", - "Acronym": "CAF" - }, - { - "FlagName": "CD", - "FullName": "Democratic Republic of the Congo", - "Acronym": "COD" - }, - { - "FlagName": "CZ", - "FullName": "Czech Republic", - "Acronym": "CZE" - }, - { - "FlagName": "CY", - "FullName": "Cyprus", - "Acronym": "CYP" - }, - { - "FlagName": "CX", - "FullName": "Christmas Island", - "Acronym": "CXR" - }, - { - "FlagName": "CR", - "FullName": "Costa Rica", - "Acronym": "CRI" - }, - { - "FlagName": "CW", - "FullName": "Curacao", - "Acronym": "CUW" - }, - { - "FlagName": "CV", - "FullName": "Cabo Verde", - "Acronym": "CPV" - }, - { - "FlagName": "CU", - "FullName": "Cuba", - "Acronym": "CUB" - }, - { - "FlagName": "SZ", - "FullName": "Eswatini", - "Acronym": "SWZ" - }, - { - "FlagName": "SY", - "FullName": "Syria", - "Acronym": "SYR" - }, - { - "FlagName": "SX", - "FullName": "Sint Maarten", - "Acronym": "SXM" - }, - { - "FlagName": "KG", - "FullName": "Kyrgyzstan", - "Acronym": "KGZ" - }, - { - "FlagName": "KE", - "FullName": "Kenya", - "Acronym": "KEN" - }, - { - "FlagName": "SS", - "FullName": "South Sudan", - "Acronym": "SSD" - }, - { - "FlagName": "SR", - "FullName": "Suriname", - "Acronym": "SUR" - }, - { - "FlagName": "KI", - "FullName": "Kiribati", - "Acronym": "KIR" - }, - { - "FlagName": "KH", - "FullName": "Cambodia", - "Acronym": "KHM" - }, - { - "FlagName": "KN", - "FullName": "Saint Kitts and Nevis", - "Acronym": "KNA" - }, - { - "FlagName": "KM", - "FullName": "Comoros", - "Acronym": "COM" - }, - { - "FlagName": "ST", - "FullName": "Sao Tome and Principe", - "Acronym": "STP" - }, - { - "FlagName": "SK", - "FullName": "Slovakia", - "Acronym": "SVK" - }, - { - "FlagName": "KR", - "FullName": "South Korea", - "Acronym": "KOR" - }, - { - "FlagName": "SI", - "FullName": "Slovenia", - "Acronym": "SVN" - }, - { - "FlagName": "KP", - "FullName": "North Korea", - "Acronym": "PRK" - }, - { - "FlagName": "KW", - "FullName": "Kuwait", - "Acronym": "KWT" - }, - { - "FlagName": "SN", - "FullName": "Senegal", - "Acronym": "SEN" - }, - { - "FlagName": "SM", - "FullName": "San Marino", - "Acronym": "SMR" - }, - { - "FlagName": "SL", - "FullName": "Sierra Leone", - "Acronym": "SLE" - }, - { - "FlagName": "SC", - "FullName": "Seychelles", - "Acronym": "SYC" - }, - { - "FlagName": "KZ", - "FullName": "Kazakhstan", - "Acronym": "KAZ" - }, - { - "FlagName": "KY", - "FullName": "Cayman Islands", - "Acronym": "CYM" - }, - { - "FlagName": "SG", - "FullName": "Singapore", - "Acronym": "SGP" - }, - { - "FlagName": "SE", - "FullName": "Sweden", - "Acronym": "SWE" - }, - { - "FlagName": "SD", - "FullName": "Sudan", - "Acronym": "SDN" - }, - { - "FlagName": "DO", - "FullName": "Dominican Republic", - "Acronym": "DOM" - }, - { - "FlagName": "DM", - "FullName": "Dominica", - "Acronym": "DMA" - }, - { - "FlagName": "DJ", - "FullName": "Djibouti", - "Acronym": "DJI" - }, - { - "FlagName": "DK", - "FullName": "Denmark", - "Acronym": "DNK" - }, - { - "FlagName": "VG", - "FullName": "British Virgin Islands", - "Acronym": "VGB" - }, - { - "FlagName": "DE", - "FullName": "Germany", - "Acronym": "DEU" - }, - { - "FlagName": "YE", - "FullName": "Yemen", - "Acronym": "YEM" - }, - { - "FlagName": "DZ", - "FullName": "Algeria", - "Acronym": "DZA" - }, - { - "FlagName": "US", - "FullName": "United States", - "Acronym": "USA" - }, - { - "FlagName": "UY", - "FullName": "Uruguay", - "Acronym": "URY" - }, - { - "FlagName": "YT", - "FullName": "Mayotte", - "Acronym": "MYT" - }, - { - "FlagName": "UM", - "FullName": "United States Minor Outlying Islands", - "Acronym": "UMI" - }, - { - "FlagName": "LB", - "FullName": "Lebanon", - "Acronym": "LBN" - }, - { - "FlagName": "LC", - "FullName": "Saint Lucia", - "Acronym": "LCA" - }, - { - "FlagName": "LA", - "FullName": "Laos", - "Acronym": "LAO" - }, - { - "FlagName": "TV", - "FullName": "Tuvalu", - "Acronym": "TUV" - }, - { - "FlagName": "TW", - "FullName": "Taiwan", - "Acronym": "TWN" - }, - { - "FlagName": "TT", - "FullName": "Trinidad and Tobago", - "Acronym": "TTO" - }, - { - "FlagName": "TR", - "FullName": "Turkey", - "Acronym": "TUR" - }, - { - "FlagName": "LK", - "FullName": "Sri Lanka", - "Acronym": "LKA" - }, - { - "FlagName": "LI", - "FullName": "Liechtenstein", - "Acronym": "LIE" - }, - { - "FlagName": "LV", - "FullName": "Latvia", - "Acronym": "LVA" - }, - { - "FlagName": "TO", - "FullName": "Tonga", - "Acronym": "TON" - }, - { - "FlagName": "LT", - "FullName": "Lithuania", - "Acronym": "LTU" - }, - { - "FlagName": "LU", - "FullName": "Luxembourg", - "Acronym": "LUX" - }, - { - "FlagName": "LR", - "FullName": "Liberia", - "Acronym": "LBR" - }, - { - "FlagName": "LS", - "FullName": "Lesotho", - "Acronym": "LSO" - }, - { - "FlagName": "TH", - "FullName": "Thailand", - "Acronym": "THA" - }, - { - "FlagName": "TF", - "FullName": "French Southern Territories", - "Acronym": "ATF" - }, - { - "FlagName": "TG", - "FullName": "Togo", - "Acronym": "TGO" - }, - { - "FlagName": "TD", - "FullName": "Chad", - "Acronym": "TCD" - }, - { - "FlagName": "TC", - "FullName": "Turks and Caicos Islands", - "Acronym": "TCA" - }, - { - "FlagName": "LY", - "FullName": "Libya", - "Acronym": "LBY" - }, - { - "FlagName": "VA", - "FullName": "Vatican", - "Acronym": "VAT" - }, - { - "FlagName": "VC", - "FullName": "Saint Vincent and the Grenadines", - "Acronym": "VCT" - }, - { - "FlagName": "AE", - "FullName": "United Arab Emirates", - "Acronym": "ARE" - }, - { - "FlagName": "AD", - "FullName": "Andorra", - "Acronym": "AND" - }, - { - "FlagName": "AG", - "FullName": "Antigua and Barbuda", - "Acronym": "ATG" - }, - { - "FlagName": "AF", - "FullName": "Afghanistan", - "Acronym": "AFG" - }, - { - "FlagName": "AI", - "FullName": "Anguilla", - "Acronym": "AIA" - }, - { - "FlagName": "VI", - "FullName": "U.S. Virgin Islands", - "Acronym": "VIR" - }, - { - "FlagName": "IS", - "FullName": "Iceland", - "Acronym": "ISL" - }, - { - "FlagName": "IR", - "FullName": "Iran", - "Acronym": "IRN" - }, - { - "FlagName": "AM", - "FullName": "Armenia", - "Acronym": "ARM" - }, - { - "FlagName": "AL", - "FullName": "Albania", - "Acronym": "ALB" - }, - { - "FlagName": "AO", - "FullName": "Angola", - "Acronym": "AGO" - }, - { - "FlagName": "AQ", - "FullName": "Antarctica", - "Acronym": "ATA" - }, - { - "FlagName": "AS", - "FullName": "American Samoa", - "Acronym": "ASM" - }, - { - "FlagName": "AR", - "FullName": "Argentina", - "Acronym": "ARG" - }, - { - "FlagName": "AU", - "FullName": "Australia", - "Acronym": "AUS" - }, - { - "FlagName": "AT", - "FullName": "Austria", - "Acronym": "AUT" - }, - { - "FlagName": "AW", - "FullName": "Aruba", - "Acronym": "ABW" - }, - { - "FlagName": "IN", - "FullName": "India", - "Acronym": "IND" - }, - { - "FlagName": "AX", - "FullName": "Aland Islands", - "Acronym": "ALA" - }, - { - "FlagName": "AZ", - "FullName": "Azerbaijan", - "Acronym": "AZE" - }, - { - "FlagName": "IE", - "FullName": "Ireland", - "Acronym": "IRL" - }, - { - "FlagName": "ID", - "FullName": "Indonesia", - "Acronym": "IDN" - }, - { - "FlagName": "UA", - "FullName": "Ukraine", - "Acronym": "UKR" - }, - { - "FlagName": "QA", - "FullName": "Qatar", - "Acronym": "QAT" - }, - { - "FlagName": "MZ", - "FullName": "Mozambique", - "Acronym": "MOZ" - } -] \ No newline at end of file diff --git a/osu.Game.Tournament/SaveChangesOverlay.cs b/osu.Game.Tournament/SaveChangesOverlay.cs new file mode 100644 index 0000000000..6db8605808 --- /dev/null +++ b/osu.Game.Tournament/SaveChangesOverlay.cs @@ -0,0 +1,101 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Online.Multiplayer; +using osuTK; + +namespace osu.Game.Tournament +{ + internal partial class SaveChangesOverlay : CompositeDrawable + { + [Resolved] + private TournamentGame tournamentGame { get; set; } = null!; + + private string? lastSerialisedLadder; + private readonly TourneyButton saveChangesButton; + + public SaveChangesOverlay() + { + RelativeSizeAxes = Axes.Both; + + InternalChild = new Container + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Position = new Vector2(5), + CornerRadius = 10, + Masking = true, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = OsuColour.Gray(0.2f), + RelativeSizeAxes = Axes.Both, + }, + saveChangesButton = new TourneyButton + { + Text = "Save Changes", + Width = 140, + Height = 50, + Padding = new MarginPadding + { + Top = 10, + Left = 10, + }, + Margin = new MarginPadding + { + Right = 10, + Bottom = 10, + }, + Action = saveChanges, + // Enabled = { Value = false }, + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + scheduleNextCheck(); + } + + private async Task checkForChanges() + { + string serialisedLadder = await Task.Run(() => tournamentGame.GetSerialisedLadder()).ConfigureAwait(true); + + // If a save hasn't been triggered by the user yet, populate the initial value + lastSerialisedLadder ??= serialisedLadder; + + if (lastSerialisedLadder != serialisedLadder && !saveChangesButton.Enabled.Value) + { + saveChangesButton.Enabled.Value = true; + saveChangesButton.Background + .FadeColour(saveChangesButton.BackgroundColour.Lighten(0.5f), 500, Easing.In).Then() + .FadeColour(saveChangesButton.BackgroundColour, 500, Easing.Out) + .Loop(); + } + + scheduleNextCheck(); + } + + private void scheduleNextCheck() => Scheduler.AddDelayed(() => checkForChanges().FireAndForget(), 1000); + + private void saveChanges() + { + tournamentGame.SaveChanges(); + lastSerialisedLadder = tournamentGame.GetSerialisedLadder(); + + saveChangesButton.Enabled.Value = false; + saveChangesButton.Background.FadeColour(saveChangesButton.BackgroundColour, 500); + } + } +} diff --git a/osu.Game.Tournament/Screens/BeatmapInfoScreen.cs b/osu.Game.Tournament/Screens/BeatmapInfoScreen.cs index 77b816d24c..6f7234b8c3 100644 --- a/osu.Game.Tournament/Screens/BeatmapInfoScreen.cs +++ b/osu.Game.Tournament/Screens/BeatmapInfoScreen.cs @@ -1,17 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Beatmaps.Legacy; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Tournament.Components; using osu.Game.Tournament.IPC; +using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Screens { - public abstract class BeatmapInfoScreen : TournamentMatchScreen + public abstract partial class BeatmapInfoScreen : TournamentMatchScreen { protected readonly SongBar SongBar; @@ -37,7 +39,7 @@ namespace osu.Game.Tournament.Screens SongBar.Mods = mods.NewValue; } - private void beatmapChanged(ValueChangedEvent beatmap) + private void beatmapChanged(ValueChangedEvent beatmap) { SongBar.FadeInFromZero(300, Easing.OutQuint); SongBar.Beatmap = beatmap.NewValue; diff --git a/osu.Game.Tournament/Screens/Drawings/Components/DrawingsConfigManager.cs b/osu.Game.Tournament/Screens/Drawings/Components/DrawingsConfigManager.cs index 1a2f5a1ff4..ac1d599851 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/DrawingsConfigManager.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/DrawingsConfigManager.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Configuration; using osu.Framework.Platform; diff --git a/osu.Game.Tournament/Screens/Drawings/Components/Group.cs b/osu.Game.Tournament/Screens/Drawings/Components/Group.cs index ece1c431e2..b397f807f0 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/Group.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/Group.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using System.Text; @@ -14,7 +16,7 @@ using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Drawings.Components { - public class Group : Container + public partial class Group : Container { public readonly string GroupName; @@ -91,7 +93,7 @@ namespace osu.Game.Tournament.Screens.Drawings.Components { allTeams.RemoveAll(gt => gt.Team == team); - if (teams.RemoveAll(gt => gt.Team == team) > 0) + if (teams.RemoveAll(gt => gt.Team == team, true) > 0) { TeamsCount--; return true; diff --git a/osu.Game.Tournament/Screens/Drawings/Components/GroupContainer.cs b/osu.Game.Tournament/Screens/Drawings/Components/GroupContainer.cs index b9a19090df..37e15b7e45 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/GroupContainer.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/GroupContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -12,7 +14,7 @@ using osuTK; namespace osu.Game.Tournament.Screens.Drawings.Components { - public class GroupContainer : Container + public partial class GroupContainer : Container { private readonly List groups = new List(); diff --git a/osu.Game.Tournament/Screens/Drawings/Components/GroupTeam.cs b/osu.Game.Tournament/Screens/Drawings/Components/GroupTeam.cs index cd252392ba..167a576424 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/GroupTeam.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/GroupTeam.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; @@ -10,7 +12,7 @@ using osuTK; namespace osu.Game.Tournament.Screens.Drawings.Components { - public class GroupTeam : DrawableTournamentTeam + public partial class GroupTeam : DrawableTournamentTeam { private readonly FillFlowContainer innerContainer; diff --git a/osu.Game.Tournament/Screens/Drawings/Components/ITeamList.cs b/osu.Game.Tournament/Screens/Drawings/Components/ITeamList.cs index 09208818a9..7e0ac89c83 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/ITeamList.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/ITeamList.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Game.Tournament.Models; diff --git a/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs b/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs index 6fec74f95b..c2b15dd3e9 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; @@ -18,7 +20,7 @@ using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Drawings.Components { - public class ScrollingTeamContainer : Container + public partial class ScrollingTeamContainer : Container { public event Action OnScrollStarted; public event Action OnSelected; @@ -168,7 +170,7 @@ namespace osu.Game.Tournament.Screens.Drawings.Components availableTeams.Add(team); - RemoveAll(c => c is ScrollingTeam); + RemoveAll(c => c is ScrollingTeam, true); setScrollState(ScrollState.Idle); } @@ -184,7 +186,7 @@ namespace osu.Game.Tournament.Screens.Drawings.Components public void ClearTeams() { availableTeams.Clear(); - RemoveAll(c => c is ScrollingTeam); + RemoveAll(c => c is ScrollingTeam, true); setScrollState(ScrollState.Idle); } @@ -307,10 +309,10 @@ namespace osu.Game.Tournament.Screens.Drawings.Components Scrolling } - public class ScrollingTeam : DrawableTournamentTeam + public partial class ScrollingTeam : DrawableTournamentTeam { public const float WIDTH = 58; - public const float HEIGHT = 41; + public const float HEIGHT = 44; private readonly Box outline; diff --git a/osu.Game.Tournament/Screens/Drawings/Components/StorageBackedTeamList.cs b/osu.Game.Tournament/Screens/Drawings/Components/StorageBackedTeamList.cs index 5d035a4028..74afb42c1a 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/StorageBackedTeamList.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/StorageBackedTeamList.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.IO; @@ -43,7 +45,7 @@ namespace osu.Game.Tournament.Screens.Drawings.Components continue; // ReSharper disable once PossibleNullReferenceException - string[] split = line.Split(':'); + string[] split = line.Split(':', StringSplitOptions.TrimEntries); if (split.Length < 2) { @@ -53,9 +55,9 @@ namespace osu.Game.Tournament.Screens.Drawings.Components teams.Add(new TournamentTeam { - FullName = { Value = split[1].Trim(), }, - Acronym = { Value = split.Length >= 3 ? split[2].Trim() : null, }, - FlagName = { Value = split[0].Trim() } + FullName = { Value = split[1], }, + Acronym = { Value = split.Length >= 3 ? split[2] : null, }, + FlagName = { Value = split[0] } }); } } diff --git a/osu.Game.Tournament/Screens/Drawings/Components/VisualiserContainer.cs b/osu.Game.Tournament/Screens/Drawings/Components/VisualiserContainer.cs index f21f5c9460..676eec14cd 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/VisualiserContainer.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/VisualiserContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -12,7 +14,7 @@ using osu.Framework.Utils; namespace osu.Game.Tournament.Screens.Drawings.Components { - public class VisualiserContainer : Container + public partial class VisualiserContainer : Container { /// /// Number of lines in the visualiser. @@ -55,11 +57,11 @@ namespace osu.Game.Tournament.Screens.Drawings.Components if (allLines.Count == 0) return; - Remove(allLines.First()); + Remove(allLines.First(), true); allLines.Remove(allLines.First()); } - private class VisualiserLine : Container + private partial class VisualiserLine : Container { /// /// Time offset. diff --git a/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs b/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs index 53ac93afea..23d0edf26e 100644 --- a/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs +++ b/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.IO; @@ -10,8 +12,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Graphics; @@ -24,7 +24,7 @@ using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Drawings { - public class DrawingsScreen : TournamentScreen + public partial class DrawingsScreen : TournamentScreen { private const string results_filename = "drawings_results.txt"; @@ -43,7 +43,7 @@ namespace osu.Game.Tournament.Screens.Drawings public ITeamList TeamList; [BackgroundDependencyLoader] - private void load(TextureStore textures, Storage storage) + private void load(Storage storage) { RelativeSizeAxes = Axes.Both; @@ -89,11 +89,10 @@ namespace osu.Game.Tournament.Screens.Drawings RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - new Sprite + new TourneyVideo("drawings") { + Loop = true, RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fill, - Texture = textures.Get(@"Backgrounds/Drawings/background.png") }, // Visualiser new VisualiserContainer @@ -217,7 +216,7 @@ namespace osu.Game.Tournament.Screens.Drawings } } - writeOp = writeOp?.ContinueWith(t => { writeAction(); }) ?? Task.Run(writeAction); + writeOp = writeOp?.ContinueWith(_ => { writeAction(); }) ?? Task.Run(writeAction); } private void reloadTeams() diff --git a/osu.Game.Tournament/Screens/Editors/IModelBacked.cs b/osu.Game.Tournament/Screens/Editors/IModelBacked.cs index ed94bd7f45..ca59afa2cb 100644 --- a/osu.Game.Tournament/Screens/Editors/IModelBacked.cs +++ b/osu.Game.Tournament/Screens/Editors/IModelBacked.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Tournament.Screens.Editors { /// diff --git a/osu.Game.Tournament/Screens/Editors/LadderEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/LadderEditorScreen.cs index ca46c3b050..4ee3108034 100644 --- a/osu.Game.Tournament/Screens/Editors/LadderEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/LadderEditorScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Drawing; using System.Linq; @@ -21,7 +23,7 @@ using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Editors { [Cached] - public class LadderEditorScreen : LadderScreen, IHasContextMenu + public partial class LadderEditorScreen : LadderScreen, IHasContextMenu { [Cached] private LadderEditorInfo editorInfo = new LadderEditorInfo(); @@ -42,7 +44,7 @@ namespace osu.Game.Tournament.Screens.Editors AddInternal(rightClickMessage = new WarningBox("Right click to place and link matches")); - LadderInfo.Matches.CollectionChanged += (_, __) => updateMessage(); + LadderInfo.Matches.CollectionChanged += (_, _) => updateMessage(); updateMessage(); } @@ -84,7 +86,7 @@ namespace osu.Game.Tournament.Screens.Editors MatchesContainer.FirstOrDefault(p => p.Match == match)?.Remove(); } - private class JoinVisualiser : CompositeDrawable + private partial class JoinVisualiser : CompositeDrawable { private readonly Container matchesContainer; public readonly TournamentMatch Source; diff --git a/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs index 5c12d83d1c..75131c282d 100644 --- a/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -18,11 +20,11 @@ using osuTK; namespace osu.Game.Tournament.Screens.Editors { - public class RoundEditorScreen : TournamentEditorScreen + public partial class RoundEditorScreen : TournamentEditorScreen { protected override BindableList Storage => LadderInfo.Rounds; - public class RoundRow : CompositeDrawable, IModelBacked + public partial class RoundRow : CompositeDrawable, IModelBacked { public TournamentRound Model { get; } @@ -111,7 +113,7 @@ namespace osu.Game.Tournament.Screens.Editors AutoSizeAxes = Axes.Y; } - public class RoundBeatmapEditor : CompositeDrawable + public partial class RoundBeatmapEditor : CompositeDrawable { private readonly TournamentRound round; private readonly FillFlowContainer flow; @@ -139,7 +141,7 @@ namespace osu.Game.Tournament.Screens.Editors flow.Add(new RoundBeatmapRow(round, user)); } - public class RoundBeatmapRow : CompositeDrawable + public partial class RoundBeatmapRow : CompositeDrawable { public RoundBeatmap Model { get; } @@ -237,7 +239,7 @@ namespace osu.Game.Tournament.Screens.Editors req.Success += res => { - Model.Beatmap = res; + Model.Beatmap = new TournamentBeatmap(res); updatePanel(); }; @@ -254,7 +256,7 @@ namespace osu.Game.Tournament.Screens.Editors mods.BindValueChanged(modString => Model.Mods = modString.NewValue); } - private void updatePanel() + private void updatePanel() => Schedule(() => { drawableContainer.Clear(); @@ -267,7 +269,7 @@ namespace osu.Game.Tournament.Screens.Editors Width = 300 }; } - } + }); } } } diff --git a/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs index 5cdfe7dc08..a4358b4396 100644 --- a/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -18,7 +20,7 @@ using osuTK; namespace osu.Game.Tournament.Screens.Editors { - public class SeedingEditorScreen : TournamentEditorScreen + public partial class SeedingEditorScreen : TournamentEditorScreen { private readonly TournamentTeam team; @@ -30,7 +32,7 @@ namespace osu.Game.Tournament.Screens.Editors this.team = team; } - public class SeedingResultRow : CompositeDrawable, IModelBacked + public partial class SeedingResultRow : CompositeDrawable, IModelBacked { public SeedingResult Model { get; } @@ -104,7 +106,7 @@ namespace osu.Game.Tournament.Screens.Editors AutoSizeAxes = Axes.Y; } - public class SeedingBeatmapEditor : CompositeDrawable + public partial class SeedingBeatmapEditor : CompositeDrawable { private readonly SeedingResult round; private readonly FillFlowContainer flow; @@ -132,7 +134,7 @@ namespace osu.Game.Tournament.Screens.Editors flow.Add(new SeedingBeatmapRow(round, user)); } - public class SeedingBeatmapRow : CompositeDrawable + public partial class SeedingBeatmapRow : CompositeDrawable { private readonly SeedingResult result; public SeedingBeatmap Model { get; } @@ -237,17 +239,17 @@ namespace osu.Game.Tournament.Screens.Editors var req = new GetBeatmapRequest(new APIBeatmap { OnlineID = Model.ID }); - req.Success += res => + req.Success += res => Schedule(() => { - Model.Beatmap = res; + Model.Beatmap = new TournamentBeatmap(res); updatePanel(); - }; + }); - req.Failure += _ => + req.Failure += _ => Schedule(() => { Model.Beatmap = null; updatePanel(); - }; + }); API.Queue(req); }, true); diff --git a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs index 6adddb5204..c9d897ca11 100644 --- a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs @@ -1,19 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System; using System.Collections.Generic; -using System.Diagnostics; -using System.IO; using System.Linq; -using Newtonsoft.Json; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Settings; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; @@ -22,11 +22,8 @@ using osuTK; namespace osu.Game.Tournament.Screens.Editors { - public class TeamEditorScreen : TournamentEditorScreen + public partial class TeamEditorScreen : TournamentEditorScreen { - [Resolved] - private TournamentGameBase game { get; set; } - protected override BindableList Storage => LadderInfo.Teams; [BackgroundDependencyLoader] @@ -44,19 +41,23 @@ namespace osu.Game.Tournament.Screens.Editors private void addAllCountries() { - List countries; + var countries = new List(); - using (Stream stream = game.Resources.GetStream("Resources/countries.json")) - using (var sr = new StreamReader(stream)) - countries = JsonConvert.DeserializeObject>(sr.ReadToEnd()); - - Debug.Assert(countries != null); + foreach (var country in Enum.GetValues().Skip(1)) + { + countries.Add(new TournamentTeam + { + FlagName = { Value = country.ToString() }, + FullName = { Value = country.GetDescription() }, + Acronym = { Value = country.GetAcronym() }, + }); + } foreach (var c in countries) Storage.Add(c); } - public class TeamRow : CompositeDrawable, IModelBacked + public partial class TeamRow : CompositeDrawable, IModelBacked { public TournamentTeam Model { get; } @@ -177,7 +178,7 @@ namespace osu.Game.Tournament.Screens.Editors drawableContainer.Child = new DrawableTeamFlag(Model); } - public class PlayerEditor : CompositeDrawable + public partial class PlayerEditor : CompositeDrawable { private readonly TournamentTeam team; private readonly FillFlowContainer flow; @@ -200,14 +201,14 @@ namespace osu.Game.Tournament.Screens.Editors public void CreateNew() { - var user = new APIUser(); - team.Players.Add(user); - flow.Add(new PlayerRow(team, user)); + var player = new TournamentUser(); + team.Players.Add(player); + flow.Add(new PlayerRow(team, player)); } - public class PlayerRow : CompositeDrawable + public partial class PlayerRow : CompositeDrawable { - private readonly APIUser user; + private readonly TournamentUser user; [Resolved] protected IAPIProvider API { get; private set; } @@ -215,11 +216,11 @@ namespace osu.Game.Tournament.Screens.Editors [Resolved] private TournamentGameBase game { get; set; } - private readonly Bindable userId = new Bindable(); + private readonly Bindable playerId = new Bindable(); private readonly Container drawableContainer; - public PlayerRow(TournamentTeam team, APIUser user) + public PlayerRow(TournamentTeam team, TournamentUser user) { this.user = user; @@ -252,7 +253,7 @@ namespace osu.Game.Tournament.Screens.Editors LabelText = "User ID", RelativeSizeAxes = Axes.None, Width = 200, - Current = userId, + Current = playerId, }, drawableContainer = new Container { @@ -279,10 +280,10 @@ namespace osu.Game.Tournament.Screens.Editors [BackgroundDependencyLoader] private void load() { - userId.Value = user.Id; - userId.BindValueChanged(id => + playerId.Value = user.OnlineID; + playerId.BindValueChanged(id => { - user.Id = id.NewValue ?? 0; + user.OnlineID = id.NewValue ?? 0; if (id.NewValue != id.OldValue) user.Username = string.Empty; @@ -293,14 +294,14 @@ namespace osu.Game.Tournament.Screens.Editors return; } - game.PopulateUser(user, updatePanel, updatePanel); + game.PopulatePlayer(user, updatePanel, updatePanel); }, true); } - private void updatePanel() + private void updatePanel() => Scheduler.AddOnce(() => { - drawableContainer.Child = new UserGridPanel(user) { Width = 300 }; - } + drawableContainer.Child = new UserGridPanel(user.ToAPIUser()) { Width = 300 }; + }); } } } diff --git a/osu.Game.Tournament/Screens/Editors/TournamentEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/TournamentEditorScreen.cs index a5a2c5c15f..0fb6c1367b 100644 --- a/osu.Game.Tournament/Screens/Editors/TournamentEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/TournamentEditorScreen.cs @@ -1,7 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Specialized; +using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -18,7 +21,7 @@ using osuTK; namespace osu.Game.Tournament.Screens.Editors { - public abstract class TournamentEditorScreen : TournamentScreen, IProvideVideo + public abstract partial class TournamentEditorScreen : TournamentScreen where TDrawable : Drawable, IModelBacked where TModel : class, new() { @@ -100,11 +103,15 @@ namespace osu.Game.Tournament.Screens.Editors switch (args.Action) { case NotifyCollectionChangedAction.Add: + Debug.Assert(args.NewItems != null); + args.NewItems.Cast().ForEach(i => flow.Add(CreateDrawable(i))); break; case NotifyCollectionChangedAction.Remove: - args.OldItems.Cast().ForEach(i => flow.RemoveAll(d => d.Model == i)); + Debug.Assert(args.OldItems != null); + + args.OldItems.Cast().ForEach(i => flow.RemoveAll(d => d.Model == i, true)); break; } }; diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/MatchHeader.cs b/osu.Game.Tournament/Screens/Gameplay/Components/MatchHeader.cs index 8048425ce1..8f7484980d 100644 --- a/osu.Game.Tournament/Screens/Gameplay/Components/MatchHeader.cs +++ b/osu.Game.Tournament/Screens/Gameplay/Components/MatchHeader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,7 +12,7 @@ using osuTK; namespace osu.Game.Tournament.Screens.Gameplay.Components { - public class MatchHeader : Container + public partial class MatchHeader : Container { private TeamScoreDisplay teamDisplay1; private TeamScoreDisplay teamDisplay2; diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/MatchRoundDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/MatchRoundDisplay.cs index 2f0e4b5e87..d2b61220f0 100644 --- a/osu.Game.Tournament/Screens/Gameplay/Components/MatchRoundDisplay.cs +++ b/osu.Game.Tournament/Screens/Gameplay/Components/MatchRoundDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Tournament.Components; @@ -8,7 +10,7 @@ using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Screens.Gameplay.Components { - public class MatchRoundDisplay : TournamentSpriteTextWithBackground + public partial class MatchRoundDisplay : TournamentSpriteTextWithBackground { private readonly Bindable currentMatch = new Bindable(); diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/TeamDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/TeamDisplay.cs index 33658115cc..60d1678326 100644 --- a/osu.Game.Tournament/Screens/Gameplay/Components/TeamDisplay.cs +++ b/osu.Game.Tournament/Screens/Gameplay/Components/TeamDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,10 +12,14 @@ using osuTK; namespace osu.Game.Tournament.Screens.Gameplay.Components { - public class TeamDisplay : DrawableTournamentTeam + public partial class TeamDisplay : DrawableTournamentTeam { private readonly TeamScore score; + private readonly TournamentSpriteTextWithBackground teamText; + + private readonly Bindable teamName = new Bindable("???"); + private bool showScore; public bool ShowScore @@ -91,7 +97,7 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components } } }, - new TournamentSpriteTextWithBackground(team?.FullName.Value ?? "???") + teamText = new TournamentSpriteTextWithBackground { Scale = new Vector2(0.5f), Origin = anchor, @@ -111,6 +117,11 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components updateDisplay(); FinishTransforms(true); + + if (Team != null) + teamName.BindTo(Team.FullName); + + teamName.BindValueChanged(name => teamText.Text.Text = name.NewValue, true); } private void updateDisplay() diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/TeamScore.cs b/osu.Game.Tournament/Screens/Gameplay/Components/TeamScore.cs index 36c78c5ac1..8b3786fa1f 100644 --- a/osu.Game.Tournament/Screens/Gameplay/Components/TeamScore.cs +++ b/osu.Game.Tournament/Screens/Gameplay/Components/TeamScore.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -15,7 +17,7 @@ using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Gameplay.Components { - public class TeamScore : CompositeDrawable + public partial class TeamScore : CompositeDrawable { private readonly Bindable currentTeamScore = new Bindable(); private readonly StarCounter counter; @@ -39,7 +41,7 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components private void scoreChanged(ValueChangedEvent score) => counter.Current = score.NewValue ?? 0; - public class TeamScoreStarCounter : StarCounter + public partial class TeamScoreStarCounter : StarCounter { public TeamScoreStarCounter(int count) : base(count) @@ -48,7 +50,7 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components public override Star CreateStar() => new LightSquare(); - public class LightSquare : Star + public partial class LightSquare : Star { private readonly Box box; diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/TeamScoreDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/TeamScoreDisplay.cs index da55ba53ea..57fe1c7312 100644 --- a/osu.Game.Tournament/Screens/Gameplay/Components/TeamScoreDisplay.cs +++ b/osu.Game.Tournament/Screens/Gameplay/Components/TeamScoreDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osuTK.Input; namespace osu.Game.Tournament.Screens.Gameplay.Components { - public class TeamScoreDisplay : CompositeDrawable + public partial class TeamScoreDisplay : CompositeDrawable { private readonly TeamColour teamColour; @@ -23,6 +25,7 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components public bool ShowScore { + get => teamDisplay.ShowScore; set => teamDisplay.ShowScore = value; } @@ -40,6 +43,8 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components currentMatch.BindTo(ladder.CurrentMatch); currentMatch.BindValueChanged(matchChanged); + currentTeam.BindValueChanged(teamChanged); + updateMatch(); } @@ -65,7 +70,7 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components // team may change to same team, which means score is not in a good state. // thus we handle this manually. - teamChanged(currentTeam.Value); + currentTeam.TriggerChange(); } protected override bool OnMouseDown(MouseDownEvent e) @@ -86,12 +91,16 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components return base.OnMouseDown(e); } - private void teamChanged(TournamentTeam team) + private void teamChanged(ValueChangedEvent team) { + bool wasShowingScores = teamDisplay?.ShowScore ?? false; + InternalChildren = new Drawable[] { - teamDisplay = new TeamDisplay(team, teamColour, currentTeamScore, currentMatch.Value?.PointsToWin ?? 0), + teamDisplay = new TeamDisplay(team.NewValue, teamColour, currentTeamScore, currentMatch.Value?.PointsToWin ?? 0), }; + + teamDisplay.ShowScore = wasShowingScores; } } } diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs index db15a46fc8..bd1f3a2dd0 100644 --- a/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs +++ b/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -16,7 +18,7 @@ using osuTK; namespace osu.Game.Tournament.Screens.Gameplay.Components { // TODO: Update to derive from osu-side class? - public class TournamentMatchScoreDisplay : CompositeDrawable + public partial class TournamentMatchScoreDisplay : CompositeDrawable { private const float bar_height = 18; @@ -126,7 +128,7 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components score2Text.X = Math.Max(5 + score2Text.DrawWidth / 2, score2Bar.DrawWidth); } - private class MatchScoreCounter : CommaSeparatedScoreCounter + private partial class MatchScoreCounter : CommaSeparatedScoreCounter { private OsuSpriteText displayedSpriteText; diff --git a/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs b/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs index f900dd7eac..f2a2e97bcc 100644 --- a/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs +++ b/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -19,7 +21,7 @@ using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Gameplay { - public class GameplayScreen : BeatmapInfoScreen, IProvideVideo + public partial class GameplayScreen : BeatmapInfoScreen { private readonly BindableBool warmup = new BindableBool(); @@ -197,16 +199,19 @@ namespace osu.Game.Tournament.Screens.Gameplay case TourneyState.Idle: contract(); - const float delay_before_progression = 4000; - - // if we've returned to idle and the last screen was ranking - // we should automatically proceed after a short delay - if (lastState == TourneyState.Ranking && !warmup.Value) + if (LadderInfo.AutoProgressScreens.Value) { - if (CurrentMatch.Value?.Completed.Value == true) - scheduledOperation = Scheduler.AddDelayed(() => { sceneManager?.SetScreen(typeof(TeamWinScreen)); }, delay_before_progression); - else if (CurrentMatch.Value?.Completed.Value == false) - scheduledOperation = Scheduler.AddDelayed(() => { sceneManager?.SetScreen(typeof(MapPoolScreen)); }, delay_before_progression); + const float delay_before_progression = 4000; + + // if we've returned to idle and the last screen was ranking + // we should automatically proceed after a short delay + if (lastState == TourneyState.Ranking && !warmup.Value) + { + if (CurrentMatch.Value?.Completed.Value == true) + scheduledOperation = Scheduler.AddDelayed(() => { sceneManager?.SetScreen(typeof(TeamWinScreen)); }, delay_before_progression); + else if (CurrentMatch.Value?.Completed.Value == false) + scheduledOperation = Scheduler.AddDelayed(() => { sceneManager?.SetScreen(typeof(MapPoolScreen)); }, delay_before_progression); + } } break; @@ -227,7 +232,7 @@ namespace osu.Game.Tournament.Screens.Gameplay } } - private class ChromaArea : CompositeDrawable + private partial class ChromaArea : CompositeDrawable { [Resolved] private LadderInfo ladder { get; set; } diff --git a/osu.Game.Tournament/Screens/IProvideVideo.cs b/osu.Game.Tournament/Screens/IProvideVideo.cs deleted file mode 100644 index 61989d8448..0000000000 --- a/osu.Game.Tournament/Screens/IProvideVideo.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -namespace osu.Game.Tournament.Screens -{ - /// - /// Marker interface for a screen which provides its own local video background. - /// - public interface IProvideVideo - { - } -} diff --git a/osu.Game.Tournament/Screens/Ladder/Components/ConditionalTournamentMatch.cs b/osu.Game.Tournament/Screens/Ladder/Components/ConditionalTournamentMatch.cs index 16224a7fb4..04155fcb89 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/ConditionalTournamentMatch.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/ConditionalTournamentMatch.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Screens.Ladder.Components diff --git a/osu.Game.Tournament/Screens/Ladder/Components/DrawableMatchTeam.cs b/osu.Game.Tournament/Screens/Ladder/Components/DrawableMatchTeam.cs index ea453a53ca..2b66df1a31 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/DrawableMatchTeam.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/DrawableMatchTeam.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -22,7 +24,7 @@ using osuTK.Input; namespace osu.Game.Tournament.Screens.Ladder.Components { - public class DrawableMatchTeam : DrawableTournamentTeam, IHasContextMenu + public partial class DrawableMatchTeam : DrawableTournamentTeam, IHasContextMenu { private readonly TournamentMatch match; private readonly bool losers; diff --git a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs index 5729e779c4..33e383482f 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Drawing; @@ -17,7 +19,7 @@ using osuTK.Input; namespace osu.Game.Tournament.Screens.Ladder.Components { - public class DrawableTournamentMatch : CompositeDrawable + public partial class DrawableTournamentMatch : CompositeDrawable { public readonly TournamentMatch Match; private readonly bool editor; @@ -278,7 +280,7 @@ namespace osu.Game.Tournament.Screens.Ladder.Components protected override bool OnClick(ClickEvent e) { - if (editorInfo == null || Match is ConditionalTournamentMatch) + if (editorInfo == null || Match is ConditionalTournamentMatch || e.Button != MouseButton.Left) return false; Selected = true; diff --git a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentRound.cs b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentRound.cs index cad0b827c0..4b2a29247b 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentRound.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentRound.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Screens.Ladder.Components { - public class DrawableTournamentRound : CompositeDrawable + public partial class DrawableTournamentRound : CompositeDrawable { [UsedImplicitly] private readonly Bindable name; @@ -47,10 +49,10 @@ namespace osu.Game.Tournament.Screens.Ladder.Components }; name = round.Name.GetBoundCopy(); - name.BindValueChanged(n => textName.Text = ((losers ? "Losers " : "") + round.Name).ToUpper(), true); + name.BindValueChanged(_ => textName.Text = ((losers ? "Losers " : "") + round.Name).ToUpperInvariant(), true); description = round.Description.GetBoundCopy(); - description.BindValueChanged(n => textDescription.Text = round.Description.Value?.ToUpper(), true); + description.BindValueChanged(_ => textDescription.Text = round.Description.Value?.ToUpperInvariant(), true); } } } diff --git a/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs b/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs index cf4466a2e3..5aea551c00 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Collections.Specialized; +using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -16,10 +19,8 @@ using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Screens.Ladder.Components { - public class LadderEditorSettings : PlayerSettingsGroup + public partial class LadderEditorSettings : PlayerSettingsGroup { - private const int padding = 10; - private SettingsDropdown roundDropdown; private PlayerCheckbox losersCheckbox; private DateTextBox dateTimeBox; @@ -51,6 +52,9 @@ namespace osu.Game.Tournament.Screens.Ladder.Components editorInfo.Selected.ValueChanged += selection => { + // ensure any ongoing edits are committed out to the *current* selection before changing to a new one. + GetContainingInputManager().TriggerFocusContention(null); + roundDropdown.Current = selection.NewValue?.Round; losersCheckbox.Current = selection.NewValue?.Losers; dateTimeBox.Current = selection.NewValue?.Date; @@ -84,7 +88,7 @@ namespace osu.Game.Tournament.Screens.Ladder.Components { } - private class SettingsRoundDropdown : SettingsDropdown + private partial class SettingsRoundDropdown : SettingsDropdown { public SettingsRoundDropdown(BindableList rounds) { @@ -98,10 +102,14 @@ namespace osu.Game.Tournament.Screens.Ladder.Components switch (args.Action) { case NotifyCollectionChangedAction.Add: + Debug.Assert(args.NewItems != null); + args.NewItems.Cast().ForEach(add); break; case NotifyCollectionChangedAction.Remove: + Debug.Assert(args.OldItems != null); + args.OldItems.Cast().ForEach(i => Control.RemoveDropdownItem(i)); break; } diff --git a/osu.Game.Tournament/Screens/Ladder/Components/ProgressionPath.cs b/osu.Game.Tournament/Screens/Ladder/Components/ProgressionPath.cs index 960c4f41cc..c79dbc26be 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/ProgressionPath.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/ProgressionPath.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Lines; @@ -8,7 +10,7 @@ using osuTK; namespace osu.Game.Tournament.Screens.Ladder.Components { - public class ProgressionPath : Path + public partial class ProgressionPath : Path { public DrawableTournamentMatch Source { get; } public DrawableTournamentMatch Destination { get; } diff --git a/osu.Game.Tournament/Screens/Ladder/Components/SettingsTeamDropdown.cs b/osu.Game.Tournament/Screens/Ladder/Components/SettingsTeamDropdown.cs index 6604e3a313..f7a42e4f50 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/SettingsTeamDropdown.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/SettingsTeamDropdown.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Collections.Specialized; +using System.Diagnostics; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; @@ -11,7 +14,7 @@ using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Screens.Ladder.Components { - public class SettingsTeamDropdown : SettingsDropdown + public partial class SettingsTeamDropdown : SettingsDropdown { public SettingsTeamDropdown(BindableList teams) { @@ -23,10 +26,14 @@ namespace osu.Game.Tournament.Screens.Ladder.Components switch (args.Action) { case NotifyCollectionChangedAction.Add: + Debug.Assert(args.NewItems != null); + args.NewItems.Cast().ForEach(add); break; case NotifyCollectionChangedAction.Remove: + Debug.Assert(args.OldItems != null); + args.OldItems.Cast().ForEach(i => Control.RemoveDropdownItem(i)); break; } diff --git a/osu.Game.Tournament/Screens/Ladder/LadderDragContainer.cs b/osu.Game.Tournament/Screens/Ladder/LadderDragContainer.cs index f98bfd087d..10d58612f4 100644 --- a/osu.Game.Tournament/Screens/Ladder/LadderDragContainer.cs +++ b/osu.Game.Tournament/Screens/Ladder/LadderDragContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,7 +12,7 @@ using osuTK; namespace osu.Game.Tournament.Screens.Ladder { - public class LadderDragContainer : Container + public partial class LadderDragContainer : Container { protected override bool OnDragStart(DragStartEvent e) => true; diff --git a/osu.Game.Tournament/Screens/Ladder/LadderScreen.cs b/osu.Game.Tournament/Screens/Ladder/LadderScreen.cs index ad6e304c80..176c06c0e5 100644 --- a/osu.Game.Tournament/Screens/Ladder/LadderScreen.cs +++ b/osu.Game.Tournament/Screens/Ladder/LadderScreen.cs @@ -1,7 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Specialized; +using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Caching; @@ -17,7 +20,7 @@ using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Ladder { - public class LadderScreen : TournamentScreen, IProvideVideo + public partial class LadderScreen : TournamentScreen { protected Container MatchesContainer; private Container paths; @@ -73,17 +76,21 @@ namespace osu.Game.Tournament.Screens.Ladder foreach (var match in LadderInfo.Matches) addMatch(match); - LadderInfo.Rounds.CollectionChanged += (_, __) => layout.Invalidate(); + LadderInfo.Rounds.CollectionChanged += (_, _) => layout.Invalidate(); LadderInfo.Matches.CollectionChanged += (_, args) => { switch (args.Action) { case NotifyCollectionChangedAction.Add: + Debug.Assert(args.NewItems != null); + foreach (var p in args.NewItems.Cast()) addMatch(p); break; case NotifyCollectionChangedAction.Remove: + Debug.Assert(args.OldItems != null); + foreach (var p in args.OldItems.Cast()) { foreach (var d in MatchesContainer.Where(d => d.Match == p)) @@ -151,7 +158,7 @@ namespace osu.Game.Tournament.Screens.Ladder foreach (var round in LadderInfo.Rounds) { - var topMatch = MatchesContainer.Where(p => !p.Match.Losers.Value && p.Match.Round.Value == round).OrderBy(p => p.Y).FirstOrDefault(); + var topMatch = MatchesContainer.Where(p => !p.Match.Losers.Value && p.Match.Round.Value == round).MinBy(p => p.Y); if (topMatch == null) continue; @@ -165,7 +172,7 @@ namespace osu.Game.Tournament.Screens.Ladder foreach (var round in LadderInfo.Rounds) { - var topMatch = MatchesContainer.Where(p => p.Match.Losers.Value && p.Match.Round.Value == round).OrderBy(p => p.Y).FirstOrDefault(); + var topMatch = MatchesContainer.Where(p => p.Match.Losers.Value && p.Match.Round.Value == round).MinBy(p => p.Y); if (topMatch == null) continue; diff --git a/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs index 3ae007f955..f0e34d78c3 100644 --- a/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs +++ b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -9,7 +11,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; using osu.Framework.Threading; using osu.Game.Graphics.UserInterface; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Tournament.Components; using osu.Game.Tournament.IPC; using osu.Game.Tournament.Models; @@ -21,7 +22,7 @@ using osuTK.Input; namespace osu.Game.Tournament.Screens.MapPool { - public class MapPoolScreen : TournamentMatchScreen + public partial class MapPoolScreen : TournamentMatchScreen { private readonly FillFlowContainer> mapFlows; @@ -45,7 +46,10 @@ namespace osu.Game.Tournament.Screens.MapPool Loop = true, RelativeSizeAxes = Axes.Both, }, - new MatchHeader(), + new MatchHeader + { + ShowScores = true, + }, mapFlows = new FillFlowContainer> { Y = 160, @@ -105,7 +109,7 @@ namespace osu.Game.Tournament.Screens.MapPool ipc.Beatmap.BindValueChanged(beatmapChanged); } - private void beatmapChanged(ValueChangedEvent beatmap) + private void beatmapChanged(ValueChangedEvent beatmap) { if (CurrentMatch.Value == null || CurrentMatch.Value.PicksBans.Count(p => p.Type == ChoiceType.Ban) < 2) return; @@ -196,10 +200,13 @@ namespace osu.Game.Tournament.Screens.MapPool setNextMode(); - if (pickType == ChoiceType.Pick && CurrentMatch.Value.PicksBans.Any(i => i.Type == ChoiceType.Pick)) + if (LadderInfo.AutoProgressScreens.Value) { - scheduledChange?.Cancel(); - scheduledChange = Scheduler.AddDelayed(() => { sceneManager?.SetScreen(typeof(GameplayScreen)); }, 10000); + if (pickType == ChoiceType.Pick && CurrentMatch.Value.PicksBans.Any(i => i.Type == ChoiceType.Pick)) + { + scheduledChange?.Cancel(); + scheduledChange = Scheduler.AddDelayed(() => { sceneManager?.SetScreen(typeof(GameplayScreen)); }, 10000); + } } } diff --git a/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs b/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs index 84f38170ea..8d5547c749 100644 --- a/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs +++ b/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using osu.Framework.Allocation; @@ -17,7 +19,7 @@ using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Schedule { - public class ScheduleScreen : TournamentScreen // IProvidesVideo + public partial class ScheduleScreen : TournamentScreen { private readonly Bindable currentMatch = new Bindable(); private Container mainContainer; @@ -207,7 +209,7 @@ namespace osu.Game.Tournament.Screens.Schedule } } - public class ScheduleMatch : DrawableTournamentMatch + public partial class ScheduleMatch : DrawableTournamentMatch { public ScheduleMatch(TournamentMatch match, bool showTimestamp = true) : base(match) @@ -247,7 +249,7 @@ namespace osu.Game.Tournament.Screens.Schedule } } - public class ScheduleMatchDate : DrawableDate + public partial class ScheduleMatchDate : DrawableDate { public ScheduleMatchDate(DateTimeOffset date, float textSize = OsuFont.DEFAULT_FONT_SIZE, bool italic = true) : base(date, textSize, italic) @@ -259,7 +261,7 @@ namespace osu.Game.Tournament.Screens.Schedule : $"Starting {base.Format()}"; } - public class ScheduleContainer : Container + public partial class ScheduleContainer : Container { protected override Container Content => content; diff --git a/osu.Game.Tournament/Screens/Setup/ActionableInfo.cs b/osu.Game.Tournament/Screens/Setup/ActionableInfo.cs index cfdf9c99ae..e3fe2170ba 100644 --- a/osu.Game.Tournament/Screens/Setup/ActionableInfo.cs +++ b/osu.Game.Tournament/Screens/Setup/ActionableInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -11,7 +13,7 @@ using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Setup { - internal class ActionableInfo : LabelledDrawable + internal partial class ActionableInfo : LabelledDrawable { protected OsuButton Button; @@ -59,7 +61,7 @@ namespace osu.Game.Tournament.Screens.Setup Spacing = new Vector2(10, 0), Children = new Drawable[] { - Button = new TriangleButton + Button = new RoundedButton { Size = new Vector2(100, 40), Action = () => Action?.Invoke() diff --git a/osu.Game.Tournament/Screens/Setup/ResolutionSelector.cs b/osu.Game.Tournament/Screens/Setup/ResolutionSelector.cs index e53110651b..e6ab6f143a 100644 --- a/osu.Game.Tournament/Screens/Setup/ResolutionSelector.cs +++ b/osu.Game.Tournament/Screens/Setup/ResolutionSelector.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics; using osu.Game.Graphics.UserInterface; namespace osu.Game.Tournament.Screens.Setup { - internal class ResolutionSelector : ActionableInfo + internal partial class ResolutionSelector : ActionableInfo { private const int minimum_window_height = 480; private const int maximum_window_height = 2160; diff --git a/osu.Game.Tournament/Screens/Setup/SetupScreen.cs b/osu.Game.Tournament/Screens/Setup/SetupScreen.cs index f6d28c15e0..ceddd4d1a1 100644 --- a/osu.Game.Tournament/Screens/Setup/SetupScreen.cs +++ b/osu.Game.Tournament/Screens/Setup/SetupScreen.cs @@ -1,12 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Drawing; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Online.API; using osu.Game.Overlays; @@ -17,7 +21,7 @@ using osuTK; namespace osu.Game.Tournament.Screens.Setup { - public class SetupScreen : TournamentScreen, IProvideVideo + public partial class SetupScreen : TournamentScreen { private FillFlowContainer fillFlow; @@ -46,13 +50,21 @@ namespace osu.Game.Tournament.Screens.Setup { windowSize = frameworkConfig.GetBindable(FrameworkSetting.WindowedSize); - InternalChild = fillFlow = new FillFlowContainer + InternalChildren = new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Padding = new MarginPadding(10), - Spacing = new Vector2(10), + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.Gray(0.2f), + }, + fillFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding(10), + Spacing = new Vector2(10), + } }; api.LocalUser.BindValueChanged(_ => Schedule(reload)); @@ -72,7 +84,8 @@ namespace osu.Game.Tournament.Screens.Setup Action = () => sceneManager?.SetScreen(new StablePathSelectScreen()), Value = fileBasedIpc?.IPCStorage?.GetFullPath(string.Empty) ?? "Not found", Failing = fileBasedIpc?.IPCStorage == null, - Description = "The osu!stable installation which is currently being used as a data source. If a source is not found, make sure you have created an empty ipc.txt in your stable cutting-edge installation." + Description = + "The osu!stable installation which is currently being used as a data source. If a source is not found, make sure you have created an empty ipc.txt in your stable cutting-edge installation." }, new ActionableInfo { @@ -100,7 +113,7 @@ namespace osu.Game.Tournament.Screens.Setup new LabelledDropdown { Label = "Ruleset", - Description = "Decides what stats are displayed and which ranks are retrieved for players.", + Description = "Decides what stats are displayed and which ranks are retrieved for players. This requires a restart to reload data for an existing bracket.", Items = rulesets.AvailableRulesets, Current = LadderInfo.Ruleset, }, @@ -118,6 +131,12 @@ namespace osu.Game.Tournament.Screens.Setup windowSize.Value = new Size((int)(height * aspect_ratio / TournamentSceneManager.STREAM_AREA_WIDTH * TournamentSceneManager.REQUIRED_WIDTH), height); } }, + new LabelledSwitchButton + { + Label = "Auto advance screens", + Description = "Screens will progress automatically from gameplay -> results -> map pool", + Current = LadderInfo.AutoProgressScreens, + }, }; } diff --git a/osu.Game.Tournament/Screens/Setup/StablePathSelectScreen.cs b/osu.Game.Tournament/Screens/Setup/StablePathSelectScreen.cs index fb9ca46c2d..463b012b77 100644 --- a/osu.Game.Tournament/Screens/Setup/StablePathSelectScreen.cs +++ b/osu.Game.Tournament/Screens/Setup/StablePathSelectScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.IO; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -19,7 +21,7 @@ using osuTK; namespace osu.Game.Tournament.Screens.Setup { - public class StablePathSelectScreen : TournamentScreen + public partial class StablePathSelectScreen : TournamentScreen { [Resolved(canBeNull: true)] private TournamentSceneManager sceneManager { get; set; } @@ -91,7 +93,7 @@ namespace osu.Game.Tournament.Screens.Setup Spacing = new Vector2(20), Children = new Drawable[] { - new TriangleButton + new RoundedButton { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -99,7 +101,7 @@ namespace osu.Game.Tournament.Screens.Setup Text = "Select stable path", Action = ChangePath }, - new TriangleButton + new RoundedButton { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tournament/Screens/Setup/TournamentSwitcher.cs b/osu.Game.Tournament/Screens/Setup/TournamentSwitcher.cs index f0aa857769..7a8b03a7aa 100644 --- a/osu.Game.Tournament/Screens/Setup/TournamentSwitcher.cs +++ b/osu.Game.Tournament/Screens/Setup/TournamentSwitcher.cs @@ -1,14 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Tournament.IO; namespace osu.Game.Tournament.Screens.Setup { - internal class TournamentSwitcher : ActionableInfo + internal partial class TournamentSwitcher : ActionableInfo { private OsuDropdown dropdown; private OsuButton folderButton; @@ -25,7 +28,7 @@ namespace osu.Game.Tournament.Screens.Setup dropdown.Items = storage.ListTournaments(); dropdown.Current.BindValueChanged(v => Button.Enabled.Value = v.NewValue != startupTournament, true); - Action = () => game.GracefullyExit(); + Action = () => game.AttemptExit(); folderButton.Action = () => storage.PresentExternally(); ButtonText = "Close osu!"; @@ -35,7 +38,7 @@ namespace osu.Game.Tournament.Screens.Setup { var drawable = base.CreateComponent(); - FlowContainer.Insert(-1, folderButton = new TriangleButton + FlowContainer.Insert(-1, folderButton = new RoundedButton { Text = "Open folder", Width = 100 diff --git a/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs b/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs index 32d458e191..35d63f4fcf 100644 --- a/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs +++ b/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Showcase { - public class ShowcaseScreen : BeatmapInfoScreen // IProvideVideo + public partial class ShowcaseScreen : BeatmapInfoScreen { [BackgroundDependencyLoader] private void load() diff --git a/osu.Game.Tournament/Screens/Showcase/TournamentLogo.cs b/osu.Game.Tournament/Screens/Showcase/TournamentLogo.cs index bd5aa2f5d9..d04059118f 100644 --- a/osu.Game.Tournament/Screens/Showcase/TournamentLogo.cs +++ b/osu.Game.Tournament/Screens/Showcase/TournamentLogo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -9,7 +11,7 @@ using osu.Framework.Graphics.Textures; namespace osu.Game.Tournament.Screens.Showcase { - public class TournamentLogo : CompositeDrawable + public partial class TournamentLogo : CompositeDrawable { public TournamentLogo() { diff --git a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs index 0003e213e7..b07a0a65dd 100644 --- a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs +++ b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs @@ -1,6 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -17,12 +20,15 @@ using osuTK; namespace osu.Game.Tournament.Screens.TeamIntro { - public class SeedingScreen : TournamentMatchScreen, IProvideVideo + public partial class SeedingScreen : TournamentMatchScreen { private Container mainContainer; private readonly Bindable currentTeam = new Bindable(); + private TourneyButton showFirstTeamButton; + private TourneyButton showSecondTeamButton; + [BackgroundDependencyLoader] private void load() { @@ -43,13 +49,13 @@ namespace osu.Game.Tournament.Screens.TeamIntro { Children = new Drawable[] { - new TourneyButton + showFirstTeamButton = new TourneyButton { RelativeSizeAxes = Axes.X, Text = "Show first team", Action = () => currentTeam.Value = CurrentMatch.Value.Team1.Value, }, - new TourneyButton + showSecondTeamButton = new TourneyButton { RelativeSizeAxes = Axes.X, Text = "Show second team", @@ -67,15 +73,15 @@ namespace osu.Game.Tournament.Screens.TeamIntro currentTeam.BindValueChanged(teamChanged, true); } - private void teamChanged(ValueChangedEvent team) - { - if (team.NewValue == null) - { - mainContainer.Clear(); - return; - } + private void teamChanged(ValueChangedEvent team) => updateTeamDisplay(); - showTeam(team.NewValue); + public override void Show() + { + base.Show(); + + // Changes could have been made on editor screen. + // Rather than trying to track all the possibilities (teams / players / scores) just force a full refresh. + updateTeamDisplay(); } protected override void CurrentMatchChanged(ValueChangedEvent match) @@ -83,21 +89,34 @@ namespace osu.Game.Tournament.Screens.TeamIntro base.CurrentMatchChanged(match); if (match.NewValue == null) + { + showFirstTeamButton.Enabled.Value = false; + showSecondTeamButton.Enabled.Value = false; return; + } + + showFirstTeamButton.Enabled.Value = true; + showSecondTeamButton.Enabled.Value = true; currentTeam.Value = match.NewValue.Team1.Value; } - private void showTeam(TournamentTeam team) + private void updateTeamDisplay() => Scheduler.AddOnce(() => { + if (currentTeam.Value == null) + { + mainContainer.Clear(); + return; + } + mainContainer.Children = new Drawable[] { - new LeftInfo(team) { Position = new Vector2(55, 150), }, - new RightInfo(team) { Position = new Vector2(500, 150), }, + new LeftInfo(currentTeam.Value) { Position = new Vector2(55, 150), }, + new RightInfo(currentTeam.Value) { Position = new Vector2(500, 150), }, }; - } + }); - private class RightInfo : CompositeDrawable + private partial class RightInfo : CompositeDrawable { public RightInfo(TournamentTeam team) { @@ -118,15 +137,23 @@ namespace osu.Game.Tournament.Screens.TeamIntro foreach (var seeding in team.SeedingResults) { fill.Add(new ModRow(seeding.Mod.Value, seeding.Seed.Value)); + foreach (var beatmap in seeding.Beatmaps) + { + if (beatmap.Beatmap == null) + continue; + fill.Add(new BeatmapScoreRow(beatmap)); + } } } - private class BeatmapScoreRow : CompositeDrawable + private partial class BeatmapScoreRow : CompositeDrawable { public BeatmapScoreRow(SeedingBeatmap beatmap) { + Debug.Assert(beatmap.Beatmap != null); + RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -155,14 +182,15 @@ namespace osu.Game.Tournament.Screens.TeamIntro Children = new Drawable[] { new TournamentSpriteText { Text = beatmap.Score.ToString("#,0"), Colour = TournamentGame.TEXT_COLOUR, Width = 80 }, - new TournamentSpriteText { Text = "#" + beatmap.Seed.Value.ToString("#,0"), Colour = TournamentGame.TEXT_COLOUR, Font = OsuFont.Torus.With(weight: FontWeight.Regular) }, + new TournamentSpriteText + { Text = "#" + beatmap.Seed.Value.ToString("#,0"), Colour = TournamentGame.TEXT_COLOUR, Font = OsuFont.Torus.With(weight: FontWeight.Regular) }, } }, }; } } - private class ModRow : CompositeDrawable + private partial class ModRow : CompositeDrawable { private readonly string mods; private readonly int seeding; @@ -196,7 +224,7 @@ namespace osu.Game.Tournament.Screens.TeamIntro { row.Add(new Sprite { - Texture = textures.Get($"Mods/{mods.ToLower()}"), + Texture = textures.Get($"Mods/{mods.ToLowerInvariant()}"), Scale = new Vector2(0.5f) }); } @@ -226,7 +254,7 @@ namespace osu.Game.Tournament.Screens.TeamIntro } } - private class LeftInfo : CompositeDrawable + private partial class LeftInfo : CompositeDrawable { public LeftInfo(TournamentTeam team) { @@ -255,10 +283,10 @@ namespace osu.Game.Tournament.Screens.TeamIntro }; foreach (var p in team.Players) - fill.Add(new RowDisplay(p.Username, p.Statistics?.GlobalRank?.ToString("\\##,0") ?? "-")); + fill.Add(new RowDisplay(p.Username, p.Rank?.ToString("\\##,0") ?? "-")); } - internal class RowDisplay : CompositeDrawable + internal partial class RowDisplay : CompositeDrawable { public RowDisplay(string left, string right) { @@ -285,7 +313,7 @@ namespace osu.Game.Tournament.Screens.TeamIntro } } - private class TeamDisplay : DrawableTournamentTeam + private partial class TeamDisplay : DrawableTournamentTeam { public TeamDisplay(TournamentTeam team) : base(team) diff --git a/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs b/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs index ef6f0b32ff..950a63808c 100644 --- a/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs +++ b/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osuTK; namespace osu.Game.Tournament.Screens.TeamIntro { - public class TeamIntroScreen : TournamentMatchScreen, IProvideVideo + public partial class TeamIntroScreen : TournamentMatchScreen { private Container mainContainer; diff --git a/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs b/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs index 11db7bfad9..9206de1dc2 100644 --- a/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs +++ b/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osuTK; namespace osu.Game.Tournament.Screens.TeamWin { - public class TeamWinScreen : TournamentMatchScreen, IProvideVideo + public partial class TeamWinScreen : TournamentMatchScreen { private Container mainContainer; @@ -64,7 +66,7 @@ namespace osu.Game.Tournament.Screens.TeamWin private bool firstDisplay = true; - private void update() => Schedule(() => + private void update() => Scheduler.AddOnce(() => { var match = CurrentMatch.Value; diff --git a/osu.Game.Tournament/Screens/TournamentMatchScreen.cs b/osu.Game.Tournament/Screens/TournamentMatchScreen.cs index 5f00036653..58444d0c1b 100644 --- a/osu.Game.Tournament/Screens/TournamentMatchScreen.cs +++ b/osu.Game.Tournament/Screens/TournamentMatchScreen.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Screens { - public abstract class TournamentMatchScreen : TournamentScreen + public abstract partial class TournamentMatchScreen : TournamentScreen { protected readonly Bindable CurrentMatch = new Bindable(); private WarningBox noMatchWarning; diff --git a/osu.Game.Tournament/Screens/TournamentScreen.cs b/osu.Game.Tournament/Screens/TournamentScreen.cs index 5da7c7a5d2..02903a637c 100644 --- a/osu.Game.Tournament/Screens/TournamentScreen.cs +++ b/osu.Game.Tournament/Screens/TournamentScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -8,7 +10,7 @@ using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Screens { - public abstract class TournamentScreen : CompositeDrawable + public abstract partial class TournamentScreen : CompositeDrawable { public const double FADE_DELAY = 200; diff --git a/osu.Game.Tournament/TournamentGame.cs b/osu.Game.Tournament/TournamentGame.cs index 7967f54b49..beef1e197d 100644 --- a/osu.Game.Tournament/TournamentGame.cs +++ b/osu.Game.Tournament/TournamentGame.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Drawing; using System.Linq; using osu.Framework.Allocation; @@ -9,8 +11,6 @@ using osu.Framework.Configuration; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Handlers.Mouse; using osu.Framework.Logging; using osu.Framework.Platform; @@ -18,12 +18,12 @@ using osu.Game.Graphics; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Tournament.Models; -using osuTK; using osuTK.Graphics; namespace osu.Game.Tournament { - public class TournamentGame : TournamentGameBase + [Cached] + public partial class TournamentGame : TournamentGameBase { public static ColourInfo GetTeamColour(TeamColour teamColour) => teamColour == TeamColour.Red ? COLOUR_RED : COLOUR_BLUE; @@ -76,40 +76,9 @@ namespace osu.Game.Tournament LoadComponentsAsync(new[] { - new Container + new SaveChangesOverlay { - CornerRadius = 10, Depth = float.MinValue, - Position = new Vector2(5), - Masking = true, - AutoSizeAxes = Axes.Both, - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Children = new Drawable[] - { - new Box - { - Colour = OsuColour.Gray(0.2f), - RelativeSizeAxes = Axes.Both, - }, - new TourneyButton - { - Text = "Save Changes", - Width = 140, - Height = 50, - Padding = new MarginPadding - { - Top = 10, - Left = 10, - }, - Margin = new MarginPadding - { - Right = 10, - Bottom = 10, - }, - Action = SaveChanges, - }, - } }, heightWarning = new WarningBox("Please make the window wider") { @@ -135,7 +104,7 @@ namespace osu.Game.Tournament heightWarning.Alpha = size.NewValue.Width < minWidth ? 1 : 0; }), true); - windowMode.BindValueChanged(mode => ScheduleAfterChildren(() => + windowMode.BindValueChanged(_ => ScheduleAfterChildren(() => { windowMode.Value = WindowMode.Windowed; }), true); diff --git a/osu.Game.Tournament/TournamentGameBase.cs b/osu.Game.Tournament/TournamentGameBase.cs index 6ae0312cce..634cc87a9f 100644 --- a/osu.Game.Tournament/TournamentGameBase.cs +++ b/osu.Game.Tournament/TournamentGameBase.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.IO; using System.Linq; @@ -14,18 +16,19 @@ using osu.Framework.IO.Stores; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Graphics; +using osu.Game.Online; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Tournament.IO; using osu.Game.Tournament.IPC; using osu.Game.Tournament.Models; +using osu.Game.Users; using osuTK.Input; -using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Tournament { [Cached(typeof(TournamentGameBase))] - public class TournamentGameBase : OsuGameBase + public partial class TournamentGameBase : OsuGameBase { public const string BRACKET_FILENAME = @"bracket.json"; private LadderInfo ladder; @@ -42,12 +45,20 @@ namespace osu.Game.Tournament return dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); } + public override EndpointConfiguration CreateEndpoints() + { + if (UseDevelopmentServer) + return base.CreateEndpoints(); + + return new ProductionEndpointConfiguration(); + } + private TournamentSpriteText initialisationText; [BackgroundDependencyLoader] private void load(Storage baseStorage) { - AddInternal(initialisationText = new TournamentSpriteText + Add(initialisationText = new TournamentSpriteText { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -61,17 +72,17 @@ namespace osu.Game.Tournament dependencies.Cache(new TournamentVideoResourceStore(storage)); - Textures.AddStore(new TextureLoaderStore(new StorageBackedResourceStore(storage))); + Textures.AddTextureSource(new TextureLoaderStore(new StorageBackedResourceStore(storage))); dependencies.CacheAs(new StableInfo(storage)); } protected override void LoadComplete() { - MenuCursorContainer.Cursor.AlwaysPresent = true; // required for tooltip display + GlobalCursorDisplay.MenuCursor.AlwaysPresent = true; // required for tooltip display // we don't want to show the menu cursor as it would appear on stream output. - MenuCursorContainer.Cursor.Alpha = 0; + GlobalCursorDisplay.MenuCursor.Alpha = 0; base.LoadComplete(); @@ -154,9 +165,21 @@ namespace osu.Game.Tournament addedInfo |= addSeedingBeatmaps(); if (addedInfo) - SaveChanges(); + saveChanges(); ladder.CurrentMatch.Value = ladder.Matches.FirstOrDefault(p => p.Current.Value); + + ladder.Ruleset.BindValueChanged(r => + { + // Refetch player rank data on next startup as the ruleset has changed. + foreach (var team in ladder.Teams) + { + foreach (var player in team.Players) + player.Rank = null; + } + + SaveChanges(); + }); } catch (Exception e) { @@ -186,8 +209,8 @@ namespace osu.Game.Tournament var playersRequiringPopulation = ladder.Teams .SelectMany(t => t.Players) .Where(p => string.IsNullOrEmpty(p.Username) - || p.Statistics?.GlobalRank == null - || p.Statistics?.CountryRank == null).ToList(); + || p.CountryCode == CountryCode.Unknown + || p.Rank == null).ToList(); if (playersRequiringPopulation.Count == 0) return false; @@ -195,7 +218,7 @@ namespace osu.Game.Tournament for (int i = 0; i < playersRequiringPopulation.Count; i++) { var p = playersRequiringPopulation[i]; - PopulateUser(p, immediate: true); + PopulatePlayer(p, immediate: true); updateLoadProgressMessage($"Populating user stats ({i} / {playersRequiringPopulation.Count})"); } @@ -209,7 +232,7 @@ namespace osu.Game.Tournament { var beatmapsRequiringPopulation = ladder.Rounds .SelectMany(r => r.Beatmaps) - .Where(b => string.IsNullOrEmpty(b.Beatmap?.BeatmapSet?.Title) && b.ID > 0).ToList(); + .Where(b => b.Beatmap?.OnlineID == 0 && b.ID > 0).ToList(); if (beatmapsRequiringPopulation.Count == 0) return false; @@ -220,7 +243,7 @@ namespace osu.Game.Tournament var req = new GetBeatmapRequest(new APIBeatmap { OnlineID = b.ID }); API.Perform(req); - b.Beatmap = req.Response ?? new APIBeatmap(); + b.Beatmap = new TournamentBeatmap(req.Response ?? new APIBeatmap()); updateLoadProgressMessage($"Populating round beatmaps ({i} / {beatmapsRequiringPopulation.Count})"); } @@ -236,7 +259,7 @@ namespace osu.Game.Tournament var beatmapsRequiringPopulation = ladder.Teams .SelectMany(r => r.SeedingResults) .SelectMany(r => r.Beatmaps) - .Where(b => string.IsNullOrEmpty(b.Beatmap?.BeatmapSet?.Title) && b.ID > 0).ToList(); + .Where(b => (b.Beatmap == null || b.Beatmap.OnlineID == 0) && b.ID > 0).ToList(); if (beatmapsRequiringPopulation.Count == 0) return false; @@ -247,7 +270,7 @@ namespace osu.Game.Tournament var req = new GetBeatmapRequest(new APIBeatmap { OnlineID = b.ID }); API.Perform(req); - b.Beatmap = req.Response ?? new APIBeatmap(); + b.Beatmap = new TournamentBeatmap(req.Response ?? new APIBeatmap()); updateLoadProgressMessage($"Populating seeding beatmaps ({i} / {beatmapsRequiringPopulation.Count})"); } @@ -257,9 +280,9 @@ namespace osu.Game.Tournament private void updateLoadProgressMessage(string s) => Schedule(() => initialisationText.Text = s); - public void PopulateUser(APIUser user, Action success = null, Action failure = null, bool immediate = false) + public void PopulatePlayer(TournamentUser user, Action success = null, Action failure = null, bool immediate = false) { - var req = new GetUserRequest(user.Id, ladder.Ruleset.Value); + var req = new GetUserRequest(user.OnlineID, ladder.Ruleset.Value); if (immediate) { @@ -268,10 +291,10 @@ namespace osu.Game.Tournament } else { - req.Success += res => { populate(); }; + req.Success += _ => { populate(); }; req.Failure += _ => { - user.Id = 1; + user.OnlineID = 1; failure?.Invoke(); }; @@ -285,18 +308,18 @@ namespace osu.Game.Tournament if (res == null) return; - user.Id = res.Id; + user.OnlineID = res.Id; user.Username = res.Username; - user.Statistics = res.Statistics; - user.Country = res.Country; - user.Cover = res.Cover; + user.CoverUrl = res.CoverUrl; + user.CountryCode = res.CountryCode; + user.Rank = res.Statistics?.GlobalRank; success?.Invoke(); } } - protected virtual void SaveChanges() + public void SaveChanges() { if (!bracketLoadTaskCompletionSource.Task.IsCompletedSuccessfully) { @@ -304,6 +327,21 @@ namespace osu.Game.Tournament return; } + saveChanges(); + } + + private void saveChanges() + { + // Serialise before opening stream for writing, so if there's a failure it will leave the file in the previous state. + string serialisedLadder = GetSerialisedLadder(); + + using (var stream = storage.CreateFileSafely(BRACKET_FILENAME)) + using (var sw = new StreamWriter(stream)) + sw.Write(serialisedLadder); + } + + public string GetSerialisedLadder() + { foreach (var r in ladder.Rounds) r.Matches = ladder.Matches.Where(p => p.Round.Value == r).Select(p => p.ID).ToList(); @@ -311,8 +349,7 @@ namespace osu.Game.Tournament 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 = JsonConvert.SerializeObject(ladder, + return JsonConvert.SerializeObject(ladder, new JsonSerializerSettings { Formatting = Formatting.Indented, @@ -320,15 +357,11 @@ namespace osu.Game.Tournament DefaultValueHandling = DefaultValueHandling.Ignore, Converters = new JsonConverter[] { new JsonPointConverter() } }); - - using (var stream = storage.CreateFileSafely(BRACKET_FILENAME)) - using (var sw = new StreamWriter(stream)) - sw.Write(serialisedLadder); } protected override UserInputManager CreateUserInputManager() => new TournamentInputManager(); - private class TournamentInputManager : UserInputManager + private partial class TournamentInputManager : UserInputManager { protected override MouseButtonEventManager CreateButtonEventManagerFor(MouseButton button) { diff --git a/osu.Game.Tournament/TournamentSceneManager.cs b/osu.Game.Tournament/TournamentSceneManager.cs index 98338244e4..abfe69b97b 100644 --- a/osu.Game.Tournament/TournamentSceneManager.cs +++ b/osu.Game.Tournament/TournamentSceneManager.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using osu.Framework.Allocation; @@ -8,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.Testing; using osu.Framework.Threading; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -30,7 +33,7 @@ using osuTK.Input; namespace osu.Game.Tournament { [Cached] - public class TournamentSceneManager : CompositeDrawable + public partial class TournamentSceneManager : CompositeDrawable { private Container screens; private TourneyVideo video; @@ -184,7 +187,7 @@ namespace osu.Game.Tournament var lastScreen = currentScreen; currentScreen = target; - if (currentScreen is IProvideVideo) + if (currentScreen.ChildrenOfType().FirstOrDefault()?.VideoAvailable == true) { video.FadeOut(200); @@ -202,12 +205,12 @@ namespace osu.Game.Tournament switch (currentScreen) { - case MapPoolScreen _: + case MapPoolScreen: chatContainer.FadeIn(TournamentScreen.FADE_DELAY); chatContainer.ResizeWidthTo(1, 500, Easing.OutQuint); break; - case GameplayScreen _: + case GameplayScreen: chatContainer.FadeIn(TournamentScreen.FADE_DELAY); chatContainer.ResizeWidthTo(0.5f, 500, Easing.OutQuint); break; @@ -221,7 +224,7 @@ namespace osu.Game.Tournament s.IsSelected = screenType == s.Type; } - private class Separator : CompositeDrawable + private partial class Separator : CompositeDrawable { public Separator() { @@ -230,7 +233,7 @@ namespace osu.Game.Tournament } } - private class ScreenButton : TourneyButton + private partial class ScreenButton : TourneyButton { public readonly Type Type; diff --git a/osu.Game.Tournament/TournamentSpriteText.cs b/osu.Game.Tournament/TournamentSpriteText.cs index e550dfbfae..7ecb31ff15 100644 --- a/osu.Game.Tournament/TournamentSpriteText.cs +++ b/osu.Game.Tournament/TournamentSpriteText.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Graphics; using osu.Game.Graphics.Sprites; namespace osu.Game.Tournament { - public class TournamentSpriteText : OsuSpriteText + public partial class TournamentSpriteText : OsuSpriteText { public TournamentSpriteText() { diff --git a/osu.Game.Tournament/TourneyButton.cs b/osu.Game.Tournament/TourneyButton.cs index 12872d3197..558bd476c3 100644 --- a/osu.Game.Tournament/TourneyButton.cs +++ b/osu.Game.Tournament/TourneyButton.cs @@ -1,12 +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.Graphics.Shapes; using osu.Game.Graphics.UserInterface; namespace osu.Game.Tournament { - public class TourneyButton : OsuButton + public partial class TourneyButton : OsuButton { + public new Box Background => base.Background; + public TourneyButton() : base(null) { diff --git a/osu.Game.Tournament/WarningBox.cs b/osu.Game.Tournament/WarningBox.cs index 814482aea4..4a196446f6 100644 --- a/osu.Game.Tournament/WarningBox.cs +++ b/osu.Game.Tournament/WarningBox.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -9,7 +11,7 @@ using osuTK.Graphics; namespace osu.Game.Tournament { - internal class WarningBox : Container + internal partial class WarningBox : Container { public WarningBox(string text) { diff --git a/osu.Game.Tournament/osu.Game.Tournament.csproj b/osu.Game.Tournament/osu.Game.Tournament.csproj index b049542bb0..ab67e490cd 100644 --- a/osu.Game.Tournament/osu.Game.Tournament.csproj +++ b/osu.Game.Tournament/osu.Game.Tournament.csproj @@ -1,6 +1,6 @@  - netstandard2.1 + net6.0 Library true tools for tournaments. @@ -11,4 +11,4 @@ - \ No newline at end of file + diff --git a/osu.Game/Audio/Effects/AudioFilter.cs b/osu.Game/Audio/Effects/AudioFilter.cs index 9446967173..682ca4ca7b 100644 --- a/osu.Game/Audio/Effects/AudioFilter.cs +++ b/osu.Game/Audio/Effects/AudioFilter.cs @@ -8,7 +8,7 @@ using osu.Framework.Graphics; namespace osu.Game.Audio.Effects { - public class AudioFilter : Component, ITransformableFilter + public partial class AudioFilter : Component, ITransformableFilter { /// /// The maximum cutoff frequency that can be used with a low-pass filter. diff --git a/osu.Game/Audio/HitSampleInfo.cs b/osu.Game/Audio/HitSampleInfo.cs index 3d90dd0189..efa5562cb8 100644 --- a/osu.Game/Audio/HitSampleInfo.cs +++ b/osu.Game/Audio/HitSampleInfo.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 enable - using System; using System.Collections.Generic; using Newtonsoft.Json; @@ -16,15 +14,15 @@ namespace osu.Game.Audio [Serializable] public class HitSampleInfo : ISampleInfo, IEquatable { + public const string HIT_NORMAL = @"hitnormal"; public const string HIT_WHISTLE = @"hitwhistle"; public const string HIT_FINISH = @"hitfinish"; - public const string HIT_NORMAL = @"hitnormal"; public const string HIT_CLAP = @"hitclap"; /// /// All valid sample addition constants. /// - public static IEnumerable AllAdditions => new[] { HIT_WHISTLE, HIT_CLAP, HIT_FINISH }; + public static IEnumerable AllAdditions => new[] { HIT_WHISTLE, HIT_FINISH, HIT_CLAP }; /// /// The name of the sample to load. diff --git a/osu.Game/Audio/PreviewTrack.cs b/osu.Game/Audio/PreviewTrack.cs index 5df656e1e0..d625566ee7 100644 --- a/osu.Game/Audio/PreviewTrack.cs +++ b/osu.Game/Audio/PreviewTrack.cs @@ -10,21 +10,21 @@ using osu.Framework.Threading; namespace osu.Game.Audio { [LongRunningLoad] - public abstract class PreviewTrack : Component + public abstract partial class PreviewTrack : Component { /// /// Invoked when this has stopped playing. /// Not invoked in a thread-safe context. /// - public event Action Stopped; + public event Action? Stopped; /// /// Invoked when this has started playing. /// Not invoked in a thread-safe context. /// - public event Action Started; + public event Action? Started; - protected Track Track { get; private set; } + protected Track? Track { get; private set; } private bool hasStarted; @@ -56,7 +56,7 @@ namespace osu.Game.Audio /// public bool IsRunning => Track?.IsRunning ?? false; - private ScheduledDelegate startDelegate; + private ScheduledDelegate? startDelegate; /// /// Starts playing this . @@ -98,12 +98,23 @@ namespace osu.Game.Audio Track.Stop(); + // Ensure the track is reset immediately on stopping, so the next time it is started it has a correct time value. + Track.Seek(0); + Stopped?.Invoke(); } /// /// Retrieves the audio track. /// - protected abstract Track GetTrack(); + protected abstract Track? GetTrack(); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + Stop(); + Track?.Dispose(); + } } } diff --git a/osu.Game/Audio/PreviewTrackManager.cs b/osu.Game/Audio/PreviewTrackManager.cs index 6d56d152f1..1d710e6395 100644 --- a/osu.Game/Audio/PreviewTrackManager.cs +++ b/osu.Game/Audio/PreviewTrackManager.cs @@ -12,15 +12,15 @@ using osu.Game.Beatmaps; namespace osu.Game.Audio { - public class PreviewTrackManager : Component + public partial class PreviewTrackManager : Component { private readonly IAdjustableAudioComponent mainTrackAdjustments; private readonly BindableDouble muteBindable = new BindableDouble(); - private ITrackStore trackStore; + private ITrackStore trackStore = null!; - protected TrackManagerPreviewTrack CurrentTrack; + protected TrackManagerPreviewTrack? CurrentTrack; public PreviewTrackManager(IAdjustableAudioComponent mainTrackAdjustments) { @@ -85,10 +85,10 @@ namespace osu.Game.Audio protected virtual TrackManagerPreviewTrack CreatePreviewTrack(IBeatmapSetInfo beatmapSetInfo, ITrackStore trackStore) => new TrackManagerPreviewTrack(beatmapSetInfo, trackStore); - public class TrackManagerPreviewTrack : PreviewTrack + public partial class TrackManagerPreviewTrack : PreviewTrack { - [Resolved(canBeNull: true)] - public IPreviewTrackOwner Owner { get; private set; } + [Resolved] + public IPreviewTrackOwner? Owner { get; private set; } private readonly IBeatmapSetInfo beatmapSetInfo; private readonly ITrackStore trackManager; diff --git a/osu.Game/Audio/SampleInfo.cs b/osu.Game/Audio/SampleInfo.cs index 5d8240204e..0191f96825 100644 --- a/osu.Game/Audio/SampleInfo.cs +++ b/osu.Game/Audio/SampleInfo.cs @@ -32,10 +32,10 @@ namespace osu.Game.Audio Volume); } - public bool Equals(SampleInfo other) + public bool Equals(SampleInfo? other) => other != null && sampleNames.SequenceEqual(other.sampleNames); - public override bool Equals(object obj) + public override bool Equals(object? obj) => obj is SampleInfo other && Equals(other); } } diff --git a/osu.Game/BackgroundBeatmapProcessor.cs b/osu.Game/BackgroundBeatmapProcessor.cs new file mode 100644 index 0000000000..b8c89d8822 --- /dev/null +++ b/osu.Game/BackgroundBeatmapProcessor.cs @@ -0,0 +1,197 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Rulesets; +using osu.Game.Scoring; +using osu.Game.Screens.Play; + +namespace osu.Game +{ + public partial class BackgroundBeatmapProcessor : Component + { + [Resolved] + private RulesetStore rulesetStore { get; set; } = null!; + + [Resolved] + private ScoreManager scoreManager { get; set; } = null!; + + [Resolved] + private RealmAccess realmAccess { get; set; } = null!; + + [Resolved] + private BeatmapUpdater beatmapUpdater { get; set; } = null!; + + [Resolved] + private IBindable gameBeatmap { get; set; } = null!; + + [Resolved] + private ILocalUserPlayInfo? localUserPlayInfo { get; set; } + + protected virtual int TimeToSleepDuringGameplay => 30000; + + protected override void LoadComplete() + { + base.LoadComplete(); + + Task.Run(() => + { + Logger.Log("Beginning background beatmap processing.."); + checkForOutdatedStarRatings(); + processBeatmapSetsWithMissingMetrics(); + processScoresWithMissingStatistics(); + }).ContinueWith(t => + { + if (t.Exception?.InnerException is ObjectDisposedException) + { + Logger.Log("Finished background aborted during shutdown"); + return; + } + + Logger.Log("Finished background beatmap processing!"); + }); + } + + /// + /// Check whether the databased difficulty calculation version matches the latest ruleset provided version. + /// If it doesn't, clear out any existing difficulties so they can be incrementally recalculated. + /// + private void checkForOutdatedStarRatings() + { + foreach (var ruleset in rulesetStore.AvailableRulesets) + { + // beatmap being passed in is arbitrary here. just needs to be non-null. + int currentVersion = ruleset.CreateInstance().CreateDifficultyCalculator(gameBeatmap.Value).Version; + + if (ruleset.LastAppliedDifficultyVersion < currentVersion) + { + Logger.Log($"Resetting star ratings for {ruleset.Name} (difficulty calculation version updated from {ruleset.LastAppliedDifficultyVersion} to {currentVersion})"); + + int countReset = 0; + + realmAccess.Write(r => + { + foreach (var b in r.All()) + { + if (b.Ruleset.ShortName == ruleset.ShortName) + { + b.StarRating = -1; + countReset++; + } + } + + r.Find(ruleset.ShortName).LastAppliedDifficultyVersion = currentVersion; + }); + + Logger.Log($"Finished resetting {countReset} beatmap sets for {ruleset.Name}"); + } + } + } + + private void processBeatmapSetsWithMissingMetrics() + { + HashSet beatmapSetIds = new HashSet(); + + Logger.Log("Querying for beatmap sets to reprocess..."); + + realmAccess.Run(r => + { + foreach (var b in r.All().Where(b => b.StarRating < 0 || (b.OnlineID > 0 && b.LastOnlineUpdate == null))) + { + Debug.Assert(b.BeatmapSet != null); + beatmapSetIds.Add(b.BeatmapSet.ID); + } + }); + + Logger.Log($"Found {beatmapSetIds.Count} beatmap sets which require reprocessing."); + + int i = 0; + + foreach (var id in beatmapSetIds) + { + while (localUserPlayInfo?.IsPlaying.Value == true) + { + Logger.Log("Background processing sleeping due to active gameplay..."); + Thread.Sleep(TimeToSleepDuringGameplay); + } + + realmAccess.Run(r => + { + var set = r.Find(id); + + if (set != null) + { + try + { + Logger.Log($"Background processing {set} ({++i} / {beatmapSetIds.Count})"); + beatmapUpdater.Process(set); + } + catch (Exception e) + { + Logger.Log($"Background processing failed on {set}: {e}"); + } + } + }); + } + } + + private void processScoresWithMissingStatistics() + { + HashSet scoreIds = new HashSet(); + + Logger.Log("Querying for scores to reprocess..."); + + realmAccess.Run(r => + { + foreach (var score in r.All()) + { + if (score.Statistics.Sum(kvp => kvp.Value) > 0 && score.MaximumStatistics.Sum(kvp => kvp.Value) == 0) + scoreIds.Add(score.ID); + } + }); + + Logger.Log($"Found {scoreIds.Count} scores which require reprocessing."); + + foreach (var id in scoreIds) + { + while (localUserPlayInfo?.IsPlaying.Value == true) + { + Logger.Log("Background processing sleeping due to active gameplay..."); + Thread.Sleep(TimeToSleepDuringGameplay); + } + + try + { + var score = scoreManager.Query(s => s.ID == id); + + scoreManager.PopulateMaximumStatistics(score); + + // Can't use async overload because we're not on the update thread. + // ReSharper disable once MethodHasAsyncOverload + realmAccess.Write(r => + { + r.Find(id).MaximumStatisticsJson = JsonConvert.SerializeObject(score.MaximumStatistics); + }); + + Logger.Log($"Populated maximum statistics for score {id}"); + } + catch (Exception e) + { + Logger.Log(@$"Failed to populate maximum statistics for {id}: {e}"); + } + } + } + } +} diff --git a/osu.Game/Beatmaps/APIFailTimes.cs b/osu.Game/Beatmaps/APIFailTimes.cs index 7218906b38..441d30d06b 100644 --- a/osu.Game/Beatmaps/APIFailTimes.cs +++ b/osu.Game/Beatmaps/APIFailTimes.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using Newtonsoft.Json; diff --git a/osu.Game/Beatmaps/BeatSyncProviderExtensions.cs b/osu.Game/Beatmaps/BeatSyncProviderExtensions.cs new file mode 100644 index 0000000000..767aa5df73 --- /dev/null +++ b/osu.Game/Beatmaps/BeatSyncProviderExtensions.cs @@ -0,0 +1,18 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Beatmaps +{ + public static class BeatSyncProviderExtensions + { + /// + /// Check whether beat sync is currently available. + /// + public static bool CheckBeatSyncAvailable(this IBeatSyncProvider provider) => provider.Clock != null; + + /// + /// Whether the beat sync provider is currently in a kiai section. Should make everything more epic. + /// + public static bool CheckIsKiaiTime(this IBeatSyncProvider provider) => provider.Clock != null && provider.ControlPoints?.EffectPointAt(provider.Clock.CurrentTime).KiaiMode == true; + } +} diff --git a/osu.Game/Beatmaps/Beatmap.cs b/osu.Game/Beatmaps/Beatmap.cs index 2fa5a56042..416d655cc3 100644 --- a/osu.Game/Beatmaps/Beatmap.cs +++ b/osu.Game/Beatmaps/Beatmap.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Game.Beatmaps.Timing; using osu.Game.Rulesets.Objects; @@ -12,9 +14,6 @@ using osu.Game.IO.Serialization.Converters; namespace osu.Game.Beatmaps { - /// - /// A Beatmap containing converted HitObjects. - /// public class Beatmap : IBeatmap where T : HitObject { @@ -82,9 +81,14 @@ namespace osu.Game.Beatmaps public double GetMostCommonBeatLength() { + double lastTime; + // The last playable time in the beatmap - the last timing point extends to this time. // Note: This is more accurate and may present different results because osu-stable didn't have the ability to calculate slider durations in this context. - double lastTime = HitObjects.LastOrDefault()?.GetEndTime() ?? ControlPointInfo.TimingPoints.LastOrDefault()?.Time ?? 0; + if (!HitObjects.Any()) + lastTime = ControlPointInfo.TimingPoints.LastOrDefault()?.Time ?? 0; + else + lastTime = this.GetLastObjectTime(); var mostCommon = // Construct a set of (beatLength, duration) tuples for each individual timing point. diff --git a/osu.Game/Beatmaps/BeatmapConverter.cs b/osu.Game/Beatmaps/BeatmapConverter.cs index 627e54c803..c7c244bf0e 100644 --- a/osu.Game/Beatmaps/BeatmapConverter.cs +++ b/osu.Game/Beatmaps/BeatmapConverter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -45,6 +47,7 @@ namespace osu.Game.Beatmaps // Shallow clone isn't enough to ensure we don't mutate beatmap info unexpectedly. // Can potentially be removed after `Beatmap.Difficulty` doesn't save back to `Beatmap.BeatmapInfo`. original.BeatmapInfo = original.BeatmapInfo.Clone(); + original.ControlPointInfo = original.ControlPointInfo.DeepClone(); return ConvertBeatmap(original, cancellationToken); } diff --git a/osu.Game/Beatmaps/BeatmapDifficulty.cs b/osu.Game/Beatmaps/BeatmapDifficulty.cs index 7e0462f1e8..f4bc5e7b77 100644 --- a/osu.Game/Beatmaps/BeatmapDifficulty.cs +++ b/osu.Game/Beatmaps/BeatmapDifficulty.cs @@ -4,8 +4,6 @@ using osu.Framework.Testing; using Realms; -#nullable enable - namespace osu.Game.Beatmaps { [ExcludeFromDynamicCompile] diff --git a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs index f102daeef5..871faf5906 100644 --- a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs +++ b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs @@ -7,14 +7,12 @@ using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Lists; using osu.Framework.Logging; using osu.Framework.Threading; -using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Rulesets; @@ -28,7 +26,7 @@ namespace osu.Game.Beatmaps /// A component which performs and acts as a central cache for difficulty calculations of beatmap/ruleset/mod combinations. /// Currently not persisted between game sessions. /// - public class BeatmapDifficultyCache : MemoryCachingComponent + public partial class BeatmapDifficultyCache : MemoryCachingComponent { // Too many simultaneous updates can lead to stutters. One thread seems to work fine for song select display purposes. private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler(1, nameof(BeatmapDifficultyCache)); @@ -48,31 +46,31 @@ namespace osu.Game.Beatmaps /// private readonly object bindableUpdateLock = new object(); - private CancellationTokenSource trackedUpdateCancellationSource; + private CancellationTokenSource trackedUpdateCancellationSource = new CancellationTokenSource(); [Resolved] - private BeatmapManager beatmapManager { get; set; } + private BeatmapManager beatmapManager { get; set; } = null!; [Resolved] - private Bindable currentRuleset { get; set; } + private Bindable currentRuleset { get; set; } = null!; [Resolved] - private Bindable> currentMods { get; set; } + private Bindable> currentMods { get; set; } = null!; - private ModSettingChangeTracker modSettingChangeTracker; - private ScheduledDelegate debouncedModSettingsChange; + private ModSettingChangeTracker? modSettingChangeTracker; + private ScheduledDelegate? debouncedModSettingsChange; protected override void LoadComplete() { base.LoadComplete(); - currentRuleset.BindValueChanged(_ => updateTrackedBindables()); + currentRuleset.BindValueChanged(_ => Scheduler.AddOnce(updateTrackedBindables)); currentMods.BindValueChanged(mods => { modSettingChangeTracker?.Dispose(); - updateTrackedBindables(); + Scheduler.AddOnce(updateTrackedBindables); modSettingChangeTracker = new ModSettingChangeTracker(mods.NewValue); modSettingChangeTracker.SettingChanged += _ => @@ -83,15 +81,22 @@ namespace osu.Game.Beatmaps }, true); } + public void Invalidate(IBeatmapInfo beatmap) + { + base.Invalidate(lookup => lookup.BeatmapInfo.Equals(beatmap)); + } + /// /// Retrieves a bindable containing the star difficulty of a that follows the currently-selected ruleset and mods. /// /// The to get the difficulty of. /// An optional which stops updating the star difficulty for the given . /// A bindable that is updated to contain the star difficulty when it becomes available. Will be null while in an initial calculating state (but not during updates to ruleset and mods if a stale value is already propagated). - public IBindable GetBindableDifficulty([NotNull] IBeatmapInfo beatmapInfo, CancellationToken cancellationToken = default) + public IBindable GetBindableDifficulty(IBeatmapInfo beatmapInfo, CancellationToken cancellationToken = default) { - var bindable = createBindable(beatmapInfo, currentRuleset.Value, currentMods.Value, cancellationToken); + var bindable = new BindableStarDifficulty(beatmapInfo, cancellationToken); + + updateBindable(bindable, currentRuleset.Value, currentMods.Value, cancellationToken); lock (bindableUpdateLock) trackedBindables.Add(bindable); @@ -99,21 +104,6 @@ namespace osu.Game.Beatmaps return bindable; } - /// - /// Retrieves a bindable containing the star difficulty of a with a given and combination. - /// - /// - /// The bindable will not update to follow the currently-selected ruleset and mods or its settings. - /// - /// The to get the difficulty of. - /// The to get the difficulty with. If null, the 's ruleset is used. - /// The s to get the difficulty with. If null, no mods will be assumed. - /// An optional which stops updating the star difficulty for the given . - /// A bindable that is updated to contain the star difficulty when it becomes available. Will be null while in an initial calculating state. - public IBindable GetBindableDifficulty([NotNull] IBeatmapInfo beatmapInfo, [CanBeNull] IRulesetInfo rulesetInfo, [CanBeNull] IEnumerable mods, - CancellationToken cancellationToken = default) - => createBindable(beatmapInfo, rulesetInfo, mods, cancellationToken); - /// /// Retrieves the difficulty of a . /// @@ -126,8 +116,8 @@ namespace osu.Game.Beatmaps /// A return value indicates that the difficulty process failed or was interrupted early, /// and as such there is no usable star difficulty value to be returned. /// - public virtual Task GetDifficultyAsync([NotNull] IBeatmapInfo beatmapInfo, [CanBeNull] IRulesetInfo rulesetInfo = null, - [CanBeNull] IEnumerable mods = null, CancellationToken cancellationToken = default) + public virtual Task GetDifficultyAsync(IBeatmapInfo beatmapInfo, IRulesetInfo? rulesetInfo = null, + IEnumerable? mods = null, CancellationToken cancellationToken = default) { // In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset. rulesetInfo ??= beatmapInfo.Ruleset; @@ -166,34 +156,6 @@ namespace osu.Game.Beatmaps updateScheduler); } - /// - /// Retrieves the that describes a star rating. - /// - /// - /// For more information, see: https://osu.ppy.sh/help/wiki/Difficulties - /// - /// The star rating. - /// The that best describes . - public static DifficultyRating GetDifficultyRating(double starRating) - { - if (Precision.AlmostBigger(starRating, 6.5, 0.005)) - return DifficultyRating.ExpertPlus; - - if (Precision.AlmostBigger(starRating, 5.3, 0.005)) - return DifficultyRating.Expert; - - if (Precision.AlmostBigger(starRating, 4.0, 0.005)) - return DifficultyRating.Insane; - - if (Precision.AlmostBigger(starRating, 2.7, 0.005)) - return DifficultyRating.Hard; - - if (Precision.AlmostBigger(starRating, 2.0, 0.005)) - return DifficultyRating.Normal; - - return DifficultyRating.Easy; - } - /// /// Updates all tracked using the current ruleset and mods. /// @@ -202,7 +164,6 @@ namespace osu.Game.Beatmaps lock (bindableUpdateLock) { cancelTrackedBindableUpdate(); - trackedUpdateCancellationSource = new CancellationTokenSource(); foreach (var b in trackedBindables) { @@ -221,35 +182,16 @@ namespace osu.Game.Beatmaps { lock (bindableUpdateLock) { - trackedUpdateCancellationSource?.Cancel(); - trackedUpdateCancellationSource = null; + trackedUpdateCancellationSource.Cancel(); + trackedUpdateCancellationSource = new CancellationTokenSource(); - if (linkedCancellationSources != null) - { - foreach (var c in linkedCancellationSources) - c.Dispose(); + foreach (var c in linkedCancellationSources) + c.Dispose(); - linkedCancellationSources.Clear(); - } + linkedCancellationSources.Clear(); } } - /// - /// Creates a new and triggers an initial value update. - /// - /// The that star difficulty should correspond to. - /// The initial to get the difficulty with. - /// The initial s to get the difficulty with. - /// An optional which stops updating the star difficulty for the given . - /// The . - private BindableStarDifficulty createBindable([NotNull] IBeatmapInfo beatmapInfo, [CanBeNull] IRulesetInfo initialRulesetInfo, [CanBeNull] IEnumerable initialMods, - CancellationToken cancellationToken) - { - var bindable = new BindableStarDifficulty(beatmapInfo, cancellationToken); - updateBindable(bindable, initialRulesetInfo, initialMods, cancellationToken); - return bindable; - } - /// /// Updates the value of a with a given ruleset + mods. /// @@ -257,7 +199,7 @@ namespace osu.Game.Beatmaps /// The to update with. /// The s to update with. /// A token that may be used to cancel this update. - private void updateBindable([NotNull] BindableStarDifficulty bindable, [CanBeNull] IRulesetInfo rulesetInfo, [CanBeNull] IEnumerable mods, CancellationToken cancellationToken = default) + private void updateBindable(BindableStarDifficulty bindable, IRulesetInfo? rulesetInfo, IEnumerable? mods, CancellationToken cancellationToken = default) { // GetDifficultyAsync will fall back to existing data from IBeatmapInfo if not locally available // (contrary to GetAsync) @@ -270,7 +212,7 @@ namespace osu.Game.Beatmaps if (cancellationToken.IsCancellationRequested) return; - var starDifficulty = task.GetResultSafely(); + StarDifficulty? starDifficulty = task.GetResultSafely(); if (starDifficulty != null) bindable.Value = starDifficulty.Value; @@ -327,7 +269,7 @@ namespace osu.Game.Beatmaps modSettingChangeTracker?.Dispose(); cancelTrackedBindableUpdate(); - updateScheduler?.Dispose(); + updateScheduler.Dispose(); } public readonly struct DifficultyCacheLookup : IEquatable @@ -337,7 +279,7 @@ namespace osu.Game.Beatmaps public readonly Mod[] OrderedMods; - public DifficultyCacheLookup([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo ruleset, IEnumerable mods) + public DifficultyCacheLookup(BeatmapInfo beatmapInfo, RulesetInfo? ruleset, IEnumerable? mods) { BeatmapInfo = beatmapInfo; // In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset. diff --git a/osu.Game/Stores/BeatmapImporter.cs b/osu.Game/Beatmaps/BeatmapImporter.cs similarity index 61% rename from osu.Game/Stores/BeatmapImporter.cs rename to osu.Game/Beatmaps/BeatmapImporter.cs index f04a0210ef..4731a70753 100644 --- a/osu.Game/Stores/BeatmapImporter.cs +++ b/osu.Game/Beatmaps/BeatmapImporter.cs @@ -3,51 +3,148 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; -using osu.Framework.Audio.Track; +using System.Threading.Tasks; using osu.Framework.Extensions; using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Graphics.Textures; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Testing; -using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; +using osu.Game.Collections; using osu.Game.Database; using osu.Game.Extensions; using osu.Game.IO; using osu.Game.IO.Archives; -using osu.Game.Models; +using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; -using osu.Game.Rulesets.Objects; -using osu.Game.Skinning; using Realms; -#nullable enable - -namespace osu.Game.Stores +namespace osu.Game.Beatmaps { /// /// Handles the storage and retrieval of Beatmaps/WorkingBeatmaps. /// [ExcludeFromDynamicCompile] - public abstract class BeatmapImporter : RealmArchiveModelManager, IDisposable + public class BeatmapImporter : RealmArchiveModelImporter { public override IEnumerable HandledExtensions => new[] { ".osz" }; protected override string[] HashableFileTypes => new[] { ".osu" }; - // protected override bool CheckLocalAvailability(RealmBeatmapSet model, System.Linq.IQueryable items) - // => base.CheckLocalAvailability(model, items) || (model.OnlineID > -1)); + public ProcessBeatmapDelegate? ProcessBeatmap { private get; set; } - private readonly BeatmapOnlineLookupQueue? onlineLookupQueue; - - protected BeatmapImporter(RealmAccess realm, Storage storage, BeatmapOnlineLookupQueue? onlineLookupQueue = null) + public BeatmapImporter(Storage storage, RealmAccess realm) : base(storage, realm) { - this.onlineLookupQueue = onlineLookupQueue; + } + + public override async Task?> ImportAsUpdate(ProgressNotification notification, ImportTask importTask, BeatmapSetInfo original) + { + var imported = await Import(notification, new[] { importTask }).ConfigureAwait(true); + + if (!imported.Any()) + return null; + + Debug.Assert(imported.Count() == 1); + + var first = imported.First(); + + // If there were no changes, ensure we don't accidentally nuke ourselves. + if (first.ID == original.ID) + { + first.PerformRead(s => + { + // Re-run processing even in this case. We might have outdated metadata. + ProcessBeatmap?.Invoke(s, MetadataLookupScope.OnlineFirst); + }); + return first; + } + + first.PerformWrite(updated => + { + var realm = updated.Realm; + + Logger.Log($"Beatmap \"{updated}\" update completed successfully", LoggingTarget.Database); + + original = realm.Find(original.ID); + + // Generally the import process will do this for us if the OnlineIDs match, + // but that isn't a guarantee (ie. if the .osu file doesn't have OnlineIDs populated). + original.DeletePending = true; + + // Transfer local values which should be persisted across a beatmap update. + updated.DateAdded = original.DateAdded; + + transferCollectionReferences(realm, original, updated); + + foreach (var beatmap in original.Beatmaps.ToArray()) + { + var updatedBeatmap = updated.Beatmaps.FirstOrDefault(b => b.Hash == beatmap.Hash); + + if (updatedBeatmap != null) + { + // If the updated beatmap matches an existing one, transfer any user data across.. + if (beatmap.Scores.Any()) + { + Logger.Log($"Transferring {beatmap.Scores.Count()} scores for unchanged difficulty \"{beatmap}\"", LoggingTarget.Database); + + foreach (var score in beatmap.Scores) + score.BeatmapInfo = updatedBeatmap; + } + + // ..then nuke the old beatmap completely. + // this is done instead of a soft deletion to avoid a user potentially creating weird + // interactions, like restoring the outdated beatmap then updating a second time + // (causing user data to be wiped). + original.Beatmaps.Remove(beatmap); + + realm.Remove(beatmap.Metadata); + realm.Remove(beatmap); + } + else + { + // If the beatmap differs in the original, leave it in a soft-deleted state but reset online info. + // This caters to the case where a user has made modifications they potentially want to restore, + // but after restoring we want to ensure it can't be used to trigger an update of the beatmap. + beatmap.ResetOnlineInfo(); + } + } + + // If the original has no beatmaps left, delete the set as well. + if (!original.Beatmaps.Any()) + realm.Remove(original); + }); + + return first; + } + + private static void transferCollectionReferences(Realm realm, BeatmapSetInfo original, BeatmapSetInfo updated) + { + // First check if every beatmap in the original set is in any collections. + // In this case, we will assume they also want any newly added difficulties added to the collection. + foreach (var c in realm.All()) + { + if (original.Beatmaps.Select(b => b.MD5Hash).All(c.BeatmapMD5Hashes.Contains)) + { + foreach (var b in original.Beatmaps) + c.BeatmapMD5Hashes.Remove(b.MD5Hash); + + foreach (var b in updated.Beatmaps) + c.BeatmapMD5Hashes.Add(b.MD5Hash); + } + } + + // Handle collections using permissive difficulty name to track difficulties. + foreach (var originalBeatmap in original.Beatmaps) + { + updated.Beatmaps + .FirstOrDefault(b => b.DifficultyName == originalBeatmap.DifficultyName)? + .TransferCollectionReferences(realm, originalBeatmap.MD5Hash); + } } protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path).ToLowerInvariant() == ".osz"; @@ -55,7 +152,7 @@ namespace osu.Game.Stores protected override void Populate(BeatmapSetInfo beatmapSet, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default) { if (archive != null) - beatmapSet.Beatmaps.AddRange(createBeatmapDifficulties(beatmapSet.Files, realm)); + beatmapSet.Beatmaps.AddRange(createBeatmapDifficulties(beatmapSet, realm)); foreach (BeatmapInfo b in beatmapSet.Beatmaps) { @@ -71,8 +168,7 @@ namespace osu.Game.Stores bool hadOnlineIDs = beatmapSet.Beatmaps.Any(b => b.OnlineID > 0); - onlineLookupQueue?.Update(beatmapSet); - + // TODO: this may no longer be valid as we aren't doing an online population at this point. // ensure at least one beatmap was able to retrieve or keep an online ID, else drop the set ID. if (hadOnlineIDs && !beatmapSet.Beatmaps.Any(b => b.OnlineID > 0)) { @@ -93,21 +189,26 @@ namespace osu.Game.Stores if (beatmapSet.OnlineID > 0) { - var existingSetWithSameOnlineID = realm.All().SingleOrDefault(b => b.OnlineID == beatmapSet.OnlineID); - - if (existingSetWithSameOnlineID != null) + // OnlineID should really be unique, but to avoid catastrophic failure let's iterate just to be sure. + foreach (var existingSetWithSameOnlineID in realm.All().Where(b => b.OnlineID == beatmapSet.OnlineID)) { existingSetWithSameOnlineID.DeletePending = true; existingSetWithSameOnlineID.OnlineID = -1; foreach (var b in existingSetWithSameOnlineID.Beatmaps) - b.OnlineID = -1; + b.ResetOnlineInfo(); - LogForModel(beatmapSet, $"Found existing beatmap set with same OnlineID ({beatmapSet.OnlineID}). It will be deleted."); + LogForModel(beatmapSet, $"Found existing beatmap set with same OnlineID ({beatmapSet.OnlineID}). It will be disassociated and marked for deletion."); } } } + protected override void PostImport(BeatmapSetInfo model, Realm realm, ImportParameters parameters) + { + base.PostImport(model, realm, parameters); + ProcessBeatmap?.Invoke(model, parameters.Batch ? MetadataLookupScope.LocalCacheFirst : MetadataLookupScope.OnlineFirst); + } + private void validateOnlineIds(BeatmapSetInfo beatmapSet, Realm realm) { var beatmapIds = beatmapSet.Beatmaps.Where(b => b.OnlineID > 0).Select(b => b.OnlineID).ToList(); @@ -140,7 +241,7 @@ namespace osu.Game.Stores } } - void resetIds() => beatmapSet.Beatmaps.ForEach(b => b.OnlineID = -1); + void resetIds() => beatmapSet.Beatmaps.ForEach(b => b.ResetOnlineInfo()); } protected override bool CanSkipImport(BeatmapSetInfo existing, BeatmapSetInfo import) @@ -169,11 +270,6 @@ namespace osu.Game.Stores existing.DateAdded = DateTimeOffset.UtcNow; } - public override bool IsAvailableLocally(BeatmapSetInfo model) - { - return Realm.Run(realm => realm.All().Any(s => s.OnlineID == model.OnlineID)); - } - public override string HumanisedModelName => "beatmap"; protected override BeatmapSetInfo? CreateModel(ArchiveReader reader) @@ -188,8 +284,17 @@ namespace osu.Game.Stores } Beatmap beatmap; + using (var stream = new LineBufferedReader(reader.GetStream(mapName))) + { + if (stream.PeekLine() == null) + { + Logger.Log($"No content found in first .osu file of beatmap archive ({reader.Name} / {mapName})", LoggingTarget.Database); + return null; + } + beatmap = Decoder.GetDecoder(stream).Decode(stream); + } return new BeatmapSetInfo { @@ -202,23 +307,32 @@ namespace osu.Game.Stores /// /// Create all required s for the provided archive. /// - private List createBeatmapDifficulties(IList files, Realm realm) + private List createBeatmapDifficulties(BeatmapSetInfo beatmapSet, Realm realm) { var beatmaps = new List(); - foreach (var file in files.Where(f => f.Filename.EndsWith(".osu", StringComparison.OrdinalIgnoreCase))) + foreach (var file in beatmapSet.Files.Where(f => f.Filename.EndsWith(".osu", StringComparison.OrdinalIgnoreCase))) { using (var memoryStream = new MemoryStream(Files.Store.Get(file.File.GetStoragePath()))) // we need a memory stream so we can seek { IBeatmap decoded; + using (var lineReader = new LineBufferedReader(memoryStream, true)) + { + if (lineReader.PeekLine() == null) + { + LogForModel(beatmapSet, $"No content found in beatmap file {file.Filename}."); + continue; + } + decoded = Decoder.GetDecoder(lineReader).Decode(lineReader); + } string hash = memoryStream.ComputeSHA2Hash(); if (beatmaps.Any(b => b.Hash == hash)) { - Logger.Log($"Skipping import of {file.Filename} due to duplicate file content.", LoggingTarget.Database); + LogForModel(beatmapSet, $"Skipping import of {file.Filename} due to duplicate file content."); continue; } @@ -229,7 +343,7 @@ namespace osu.Game.Stores if (ruleset?.Available != true) { - Logger.Log($"Skipping import of {file.Filename} due to missing local ruleset {decodedInfo.Ruleset.OnlineID}.", LoggingTarget.Database); + LogForModel(beatmapSet, $"Skipping import of {file.Filename} due to missing local ruleset {decodedInfo.Ruleset.OnlineID}."); continue; } @@ -280,64 +394,11 @@ namespace osu.Game.Stores MD5Hash = memoryStream.ComputeMD5Hash(), }; - updateBeatmapStatistics(beatmap, decoded); - beatmaps.Add(beatmap); } } return beatmaps; } - - private void updateBeatmapStatistics(BeatmapInfo beatmap, IBeatmap decoded) - { - var rulesetInstance = ((IRulesetInfo)beatmap.Ruleset).CreateInstance(); - - decoded.BeatmapInfo.Ruleset = rulesetInstance.RulesetInfo; - - // TODO: this should be done in a better place once we actually need to dynamically update it. - beatmap.StarRating = rulesetInstance.CreateDifficultyCalculator(new DummyConversionBeatmap(decoded)).Calculate().StarRating; - beatmap.Length = calculateLength(decoded); - beatmap.BPM = 60000 / decoded.GetMostCommonBeatLength(); - } - - 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; - } - - public void Dispose() - { - onlineLookupQueue?.Dispose(); - } - - /// - /// A dummy WorkingBeatmap for the purpose of retrieving a beatmap for star difficulty calculation. - /// - private class DummyConversionBeatmap : WorkingBeatmap - { - private readonly IBeatmap beatmap; - - public DummyConversionBeatmap(IBeatmap beatmap) - : base(beatmap.BeatmapInfo, null) - { - this.beatmap = beatmap; - } - - protected override IBeatmap GetBeatmap() => beatmap; - protected override Texture? GetBackground() => null; - protected override Track? GetBeatmapTrack() => null; - protected internal override ISkin? GetSkin() => null; - public override Stream? GetStream(string storagePath) => null; - } } } diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index abc9020dc6..63e878b80d 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -1,14 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - using System; +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; using osu.Game.Models; using osu.Game.Online.API.Requests.Responses; @@ -21,8 +21,12 @@ using Realms; namespace osu.Game.Beatmaps { /// - /// A single beatmap difficulty. + /// A realm model containing metadata for a single beatmap difficulty. + /// This should generally include anything which is required to be filtered on at song select, or anything pertaining to storage of beatmaps in the client. /// + /// + /// 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")] @@ -88,13 +92,48 @@ namespace osu.Game.Beatmaps public string Hash { get; set; } = string.Empty; - public double StarRating { get; set; } + /// + /// Defaults to -1 (meaning not-yet-calculated). + /// Will likely be superseded with a better storage considering ruleset/mods. + /// + public double StarRating { get; set; } = -1; + [Indexed] public string MD5Hash { get; set; } = string.Empty; + public string OnlineMD5Hash { get; set; } = string.Empty; + + /// + /// The last time of a local modification (via the editor). + /// + public DateTimeOffset? LastLocalUpdate { get; set; } + + /// + /// The last time online metadata was applied to this beatmap. + /// + public DateTimeOffset? LastOnlineUpdate { get; set; } + + /// + /// Whether this beatmap matches the online version, based on fetched online metadata. + /// Will return true if no online metadata is available. + /// + public bool MatchesOnlineVersion => LastOnlineUpdate == null || MD5Hash == OnlineMD5Hash; + [JsonIgnore] public bool Hidden { get; set; } + /// + /// Reset any fetched online linking information (and history). + /// + public void ResetOnlineInfo() + { + OnlineID = -1; + LastOnlineUpdate = null; + OnlineMD5Hash = string.Empty; + if (Status != BeatmapOnlineStatus.LocallyModified) + Status = BeatmapOnlineStatus.None; + } + #region Properties we may not want persisted (but also maybe no harm?) public double AudioLeadIn { get; set; } @@ -111,6 +150,11 @@ namespace osu.Game.Beatmaps public bool SamplesMatchPlaybackRate { get; set; } = true; + /// + /// The time at which this beatmap was last played by the local user. + /// + public DateTimeOffset? LastPlayed { get; set; } + /// /// The ratio of distance travelled per time unit. /// Generally used to decouple the spacing between hit objects from the enforced "velocity" of the beatmap (see ). @@ -123,7 +167,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; } @@ -152,14 +196,40 @@ namespace osu.Game.Beatmaps public bool AudioEquals(BeatmapInfo? other) => other != null && BeatmapSet != null && other.BeatmapSet != null - && BeatmapSet.Hash == other.BeatmapSet.Hash - && Metadata.AudioFile == other.Metadata.AudioFile; + && compareFiles(this, other, m => m.AudioFile); public bool BackgroundEquals(BeatmapInfo? other) => other != null && BeatmapSet != null && other.BeatmapSet != null - && BeatmapSet.Hash == other.BeatmapSet.Hash - && Metadata.BackgroundFile == other.Metadata.BackgroundFile; + && compareFiles(this, other, m => m.BackgroundFile); + + private static bool compareFiles(BeatmapInfo x, BeatmapInfo y, Func getFilename) + { + Debug.Assert(x.BeatmapSet != null); + Debug.Assert(y.BeatmapSet != null); + + string? fileHashX = x.BeatmapSet.GetFile(getFilename(x.Metadata))?.File.Hash; + string? fileHashY = y.BeatmapSet.GetFile(getFilename(y.Metadata))?.File.Hash; + + return fileHashX == fileHashY; + } + + /// + /// When updating a beatmap, its hashes will change. Collections currently track beatmaps by hash, so they need to be updated. + /// This method will handle updating + /// + /// A realm instance in an active write transaction. + /// The previous MD5 hash of the beatmap before update. + public void TransferCollectionReferences(Realm realm, string previousMD5Hash) + { + var collections = realm.All().AsEnumerable().Where(c => c.BeatmapMD5Hashes.Contains(previousMD5Hash)); + + foreach (var c in collections) + { + c.BeatmapMD5Hashes.Remove(previousMD5Hash); + c.BeatmapMD5Hashes.Add(MD5Hash); + } + } IBeatmapMetadataInfo IBeatmapInfo.Metadata => Metadata; IBeatmapSetInfo? IBeatmapInfo.BeatmapSet => BeatmapSet; @@ -168,14 +238,6 @@ namespace osu.Game.Beatmaps #region Compatibility properties - [Ignored] - [Obsolete("Use BeatmapInfo.Difficulty instead.")] // can be removed 20220719 - public BeatmapDifficulty BaseDifficulty - { - get => Difficulty; - set => Difficulty = value; - } - [Ignored] public string? Path => File?.Filename; diff --git a/osu.Game/Beatmaps/BeatmapInfoExtensions.cs b/osu.Game/Beatmaps/BeatmapInfoExtensions.cs index eab66b9857..3aab9a24e1 100644 --- a/osu.Game/Beatmaps/BeatmapInfoExtensions.cs +++ b/osu.Game/Beatmaps/BeatmapInfoExtensions.cs @@ -1,7 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Linq; +using System.Collections.Generic; using osu.Framework.Localisation; namespace osu.Game.Beatmaps @@ -29,10 +29,21 @@ namespace osu.Game.Beatmaps return new RomanisableString($"{metadata.GetPreferred(true)}".Trim(), $"{metadata.GetPreferred(false)}".Trim()); } - public static string[] GetSearchableTerms(this IBeatmapInfo beatmapInfo) => new[] + public static List GetSearchableTerms(this IBeatmapInfo beatmapInfo) { - beatmapInfo.DifficultyName - }.Concat(beatmapInfo.Metadata.GetSearchableTerms()).Where(s => !string.IsNullOrEmpty(s)).ToArray(); + var termsList = new List(BeatmapMetadataInfoExtensions.MAX_SEARCHABLE_TERM_COUNT + 1); + + addIfNotNull(beatmapInfo.DifficultyName); + + BeatmapMetadataInfoExtensions.CollectSearchableTerms(beatmapInfo.Metadata, termsList); + return termsList; + + void addIfNotNull(string? s) + { + if (!string.IsNullOrEmpty(s)) + termsList.Add(s); + } + } private static string getVersionString(IBeatmapInfo beatmapInfo) => string.IsNullOrEmpty(beatmapInfo.DifficultyName) ? string.Empty : $"[{beatmapInfo.DifficultyName}]"; } diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index dba457c81c..cab49b7d69 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -3,17 +3,22 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Linq.Expressions; +using System.Text; using System.Threading; using System.Threading.Tasks; using osu.Framework.Audio; 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; using osu.Game.IO.Archives; using osu.Game.Models; using osu.Game.Online.API; @@ -21,57 +26,65 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Skinning; -using osu.Game.Stores; using osu.Game.Utils; -#nullable enable - namespace osu.Game.Beatmaps { /// /// Handles general operations related to global beatmap management. /// [ExcludeFromDynamicCompile] - public class BeatmapManager : IModelManager, IModelFileManager, IModelImporter, IWorkingBeatmapCache, IDisposable + public class BeatmapManager : ModelManager, IModelImporter, IWorkingBeatmapCache { public ITrackStore BeatmapTrackStore { get; } - private readonly BeatmapModelManager beatmapModelManager; + private readonly BeatmapImporter beatmapImporter; private readonly WorkingBeatmapCache workingBeatmapCache; - private readonly BeatmapOnlineLookupQueue? onlineBeatmapLookupQueue; - private readonly RealmAccess realm; + public ProcessBeatmapDelegate? ProcessBeatmap { private get; set; } - public BeatmapManager(Storage storage, RealmAccess realm, RulesetStore rulesets, IAPIProvider? api, AudioManager audioManager, IResourceStore gameResources, GameHost? host = null, WorkingBeatmap? defaultBeatmap = null, bool performOnlineLookups = false) + public override bool PauseImports { - this.realm = realm; + get => base.PauseImports; + set + { + base.PauseImports = value; + beatmapImporter.PauseImports = value; + } + } + public BeatmapManager(Storage storage, RealmAccess realm, IAPIProvider? api, AudioManager audioManager, IResourceStore gameResources, GameHost? host = null, + WorkingBeatmap? defaultBeatmap = null, BeatmapDifficultyCache? difficultyCache = null, bool performOnlineLookups = false) + : base(storage, realm) + { if (performOnlineLookups) { if (api == null) throw new ArgumentNullException(nameof(api), "API must be provided if online lookups are required."); - onlineBeatmapLookupQueue = new BeatmapOnlineLookupQueue(api, storage); + if (difficultyCache == null) + throw new ArgumentNullException(nameof(difficultyCache), "Difficulty cache must be provided if online lookups are required."); } var userResources = new RealmFileStore(realm, storage).Store; BeatmapTrackStore = audioManager.GetTrackStore(userResources); - beatmapModelManager = CreateBeatmapModelManager(storage, realm, rulesets, onlineBeatmapLookupQueue); - workingBeatmapCache = CreateWorkingBeatmapCache(audioManager, gameResources, userResources, defaultBeatmap, host); + beatmapImporter = CreateBeatmapImporter(storage, realm); + beatmapImporter.ProcessBeatmap = (beatmapSet, scope) => ProcessBeatmap?.Invoke(beatmapSet, scope); + beatmapImporter.PostNotification = obj => PostNotification?.Invoke(obj); - beatmapModelManager.WorkingBeatmapCache = workingBeatmapCache; + workingBeatmapCache = CreateWorkingBeatmapCache(audioManager, gameResources, userResources, defaultBeatmap, host); } - protected virtual WorkingBeatmapCache CreateWorkingBeatmapCache(AudioManager audioManager, IResourceStore resources, IResourceStore storage, WorkingBeatmap? defaultBeatmap, GameHost? host) + protected virtual WorkingBeatmapCache CreateWorkingBeatmapCache(AudioManager audioManager, IResourceStore resources, IResourceStore storage, WorkingBeatmap? defaultBeatmap, + GameHost? host) { return new WorkingBeatmapCache(BeatmapTrackStore, audioManager, resources, storage, defaultBeatmap, host); } - protected virtual BeatmapModelManager CreateBeatmapModelManager(Storage storage, RealmAccess realm, RulesetStore rulesets, BeatmapOnlineLookupQueue? onlineLookupQueue) => - new BeatmapModelManager(realm, storage, onlineLookupQueue); + protected virtual BeatmapImporter CreateBeatmapImporter(Storage storage, RealmAccess realm) => new BeatmapImporter(storage, realm); /// /// Create a new beatmap set, backed by a model, @@ -91,6 +104,7 @@ namespace osu.Game.Beatmaps var beatmapSet = new BeatmapSetInfo { + DateAdded = DateTimeOffset.UtcNow, Beatmaps = { new BeatmapInfo(ruleset, new BeatmapDifficulty(), metadata) @@ -100,7 +114,7 @@ namespace osu.Game.Beatmaps foreach (BeatmapInfo b in beatmapSet.Beatmaps) b.BeatmapSet = beatmapSet; - var imported = beatmapModelManager.Import(beatmapSet); + var imported = beatmapImporter.ImportModel(beatmapSet); if (imported == null) throw new InvalidOperationException("Failed to import new beatmap"); @@ -122,14 +136,12 @@ namespace osu.Game.Beatmaps /// The ruleset with which the new difficulty should be created. public virtual WorkingBeatmap CreateNewDifficulty(BeatmapSetInfo targetBeatmapSet, WorkingBeatmap referenceWorkingBeatmap, RulesetInfo rulesetInfo) { - var playableBeatmap = referenceWorkingBeatmap.GetPlayableBeatmap(rulesetInfo); - - var newBeatmapInfo = new BeatmapInfo(rulesetInfo, new BeatmapDifficulty(), playableBeatmap.Metadata.DeepClone()) + var newBeatmapInfo = new BeatmapInfo(rulesetInfo, new BeatmapDifficulty(), referenceWorkingBeatmap.Metadata.DeepClone()) { DifficultyName = NamingUtils.GetNextBestName(targetBeatmapSet.Beatmaps.Select(b => b.DifficultyName), "New Difficulty") }; var newBeatmap = new Beatmap { BeatmapInfo = newBeatmapInfo }; - foreach (var timingPoint in playableBeatmap.ControlPointInfo.TimingPoints) + foreach (var timingPoint in referenceWorkingBeatmap.Beatmap.ControlPointInfo.TimingPoints) newBeatmap.ControlPointInfo.Add(timingPoint.Time, timingPoint.DeepClone()); return addDifficultyToSet(targetBeatmapSet, newBeatmap, referenceWorkingBeatmap.Skin); @@ -161,8 +173,7 @@ namespace osu.Game.Beatmaps // clear the hash, as that's what is used to match .osu files with their corresponding realm beatmaps. newBeatmapInfo.Hash = string.Empty; // clear online properties. - newBeatmapInfo.OnlineID = -1; - newBeatmapInfo.Status = BeatmapOnlineStatus.None; + newBeatmapInfo.ResetOnlineInfo(); return addDifficultyToSet(targetBeatmapSet, newBeatmap, referenceWorkingBeatmap.Skin); } @@ -170,12 +181,12 @@ namespace osu.Game.Beatmaps private WorkingBeatmap addDifficultyToSet(BeatmapSetInfo targetBeatmapSet, IBeatmap newBeatmap, ISkin beatmapSkin) { // populate circular beatmap set info <-> beatmap info references manually. - // several places like `BeatmapModelManager.Save()` or `GetWorkingBeatmap()` + // several places like `Save()` or `GetWorkingBeatmap()` // rely on them being freely traversable in both directions for correct operation. targetBeatmapSet.Beatmaps.Add(newBeatmap.BeatmapInfo); newBeatmap.BeatmapInfo.BeatmapSet = targetBeatmapSet; - beatmapModelManager.Save(newBeatmap.BeatmapInfo, newBeatmap, beatmapSkin); + save(newBeatmap.BeatmapInfo, newBeatmap, beatmapSkin, transferCollections: false); workingBeatmapCache.Invalidate(targetBeatmapSet); return GetWorkingBeatmap(newBeatmap.BeatmapInfo); @@ -187,7 +198,7 @@ namespace osu.Game.Beatmaps /// The beatmap difficulty to hide. public void Hide(BeatmapInfo beatmapInfo) { - realm.Run(r => + Realm.Run(r => { using (var transaction = r.BeginWrite()) { @@ -206,7 +217,7 @@ namespace osu.Game.Beatmaps /// The beatmap difficulty to restore. public void Restore(BeatmapInfo beatmapInfo) { - realm.Run(r => + Realm.Run(r => { using (var transaction = r.BeginWrite()) { @@ -221,7 +232,7 @@ namespace osu.Game.Beatmaps public void RestoreAll() { - realm.Run(r => + Realm.Run(r => { using (var transaction = r.BeginWrite()) { @@ -239,7 +250,7 @@ namespace osu.Game.Beatmaps /// A list of available . public List GetAllUsableBeatmapSets() { - return realm.Run(r => + return Realm.Run(r => { r.Refresh(); return r.All().Where(b => !b.DeletePending).Detach(); @@ -253,26 +264,15 @@ namespace osu.Game.Beatmaps /// The first result for the provided query, or null if no results were found. public Live? QueryBeatmapSet(Expression> query) { - return realm.Run(r => r.All().FirstOrDefault(query)?.ToLive(realm)); + return Realm.Run(r => r.All().FirstOrDefault(query)?.ToLive(Realm)); } - #region Delegation to BeatmapModelManager (methods which previously existed locally). - /// /// Perform a lookup query on available s. /// /// The query. /// The first result for the provided query, or null if no results were found. - public BeatmapInfo? QueryBeatmap(Expression> query) => beatmapModelManager.QueryBeatmap(query)?.Detach(); - - /// - /// Saves an file against a given . - /// - /// The to save the content against. The file referenced by will be replaced. - /// The content to write. - /// The beatmap content to write, null if to be omitted. - public virtual void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin? beatmapSkin = null) => - beatmapModelManager.Save(info, beatmapContent, beatmapSkin); + public BeatmapInfo? QueryBeatmap(Expression> query) => Realm.Run(r => r.All().FirstOrDefault(query)?.Detach()); /// /// A default representation of a WorkingBeatmap to use when no beatmap is available. @@ -280,176 +280,275 @@ namespace osu.Game.Beatmaps public IWorkingBeatmap DefaultBeatmap => workingBeatmapCache.DefaultBeatmap; /// - /// Fired when a notification should be presented to the user. + /// Saves an existing file against a given . /// - public Action PostNotification + /// + /// This method will also update any user beatmap collection hash references to the new post-saved hash. + /// + /// The to save the content against. The file referenced by will be replaced. + /// The content to write. + /// The beatmap content to write, null if to be omitted. + public virtual void Save(BeatmapInfo beatmapInfo, IBeatmap beatmapContent, ISkin? beatmapSkin = null) => + save(beatmapInfo, beatmapContent, beatmapSkin, transferCollections: true); + + public void DeleteAllVideos() { - set => beatmapModelManager.PostNotification = value; - } - - #endregion - - #region Implementation of IModelManager - - public bool IsAvailableLocally(BeatmapSetInfo model) - { - return beatmapModelManager.IsAvailableLocally(model); - } - - public bool Delete(BeatmapSetInfo item) - { - return beatmapModelManager.Delete(item); - } - - public void Delete(List items, bool silent = false) - { - beatmapModelManager.Delete(items, silent); + Realm.Write(r => + { + var items = r.All().Where(s => !s.DeletePending && !s.Protected); + DeleteVideos(items.ToList()); + }); } public void Delete(Expression>? filter = null, bool silent = false) { - realm.Run(r => + Realm.Run(r => { var items = r.All().Where(s => !s.DeletePending && !s.Protected); if (filter != null) items = items.Where(filter); - beatmapModelManager.Delete(items.ToList(), silent); + Delete(items.ToList(), silent); }); } - public void DeleteAllVideos() + /// + /// Delete a beatmap difficulty immediately. + /// + /// + /// There's no undoing this operation, as we don't have a soft-deletion flag on . + /// This may be a future consideration if there's a user requirement for undeleting support. + /// + public void DeleteDifficultyImmediately(BeatmapInfo beatmapInfo) { - realm.Write(r => + Realm.Write(r => { - var items = r.All().Where(s => !s.DeletePending && !s.Protected); - beatmapModelManager.DeleteVideos(items.ToList()); + if (!beatmapInfo.IsManaged) + beatmapInfo = r.Find(beatmapInfo.ID); + + Debug.Assert(beatmapInfo.BeatmapSet != null); + Debug.Assert(beatmapInfo.File != null); + + var setInfo = beatmapInfo.BeatmapSet; + + DeleteFile(setInfo, beatmapInfo.File); + setInfo.Beatmaps.Remove(beatmapInfo); + + updateHashAndMarkDirty(setInfo); + workingBeatmapCache.Invalidate(setInfo); }); } + /// + /// Delete videos from a list of beatmaps. + /// This will post notifications tracking progress. + /// + public void DeleteVideos(List items, bool silent = false) + { + if (items.Count == 0) return; + + var notification = new ProgressNotification + { + Progress = 0, + Text = $"Preparing to delete all {HumanisedModelName} videos...", + CompletionText = "No videos found to delete!", + State = ProgressNotificationState.Active, + }; + + if (!silent) + PostNotification?.Invoke(notification); + + int i = 0; + int deleted = 0; + + foreach (var b in items) + { + if (notification.State == ProgressNotificationState.Cancelled) + // user requested abort + return; + + var video = b.Files.FirstOrDefault(f => OsuGameBase.VIDEO_EXTENSIONS.Any(ex => f.Filename.EndsWith(ex, StringComparison.Ordinal))); + + if (video != null) + { + DeleteFile(b, video); + deleted++; + notification.CompletionText = $"Deleted {deleted} {HumanisedModelName} video(s)!"; + } + + notification.Text = $"Deleting videos from {HumanisedModelName}s ({deleted} deleted)"; + + notification.Progress = (float)++i / items.Count; + } + + notification.State = ProgressNotificationState.Completed; + } + public void UndeleteAll() { - realm.Run(r => beatmapModelManager.Undelete(r.All().Where(s => s.DeletePending).ToList())); + Realm.Run(r => Undelete(r.All().Where(s => s.DeletePending).ToList())); } - public void Undelete(List items, bool silent = false) + public Task?> ImportAsUpdate(ProgressNotification notification, ImportTask importTask, BeatmapSetInfo original) => + beatmapImporter.ImportAsUpdate(notification, importTask, original); + + private void updateHashAndMarkDirty(BeatmapSetInfo setInfo) { - beatmapModelManager.Undelete(items, silent); + setInfo.Hash = beatmapImporter.ComputeHash(setInfo); + setInfo.Status = BeatmapOnlineStatus.LocallyModified; } - public void Undelete(BeatmapSetInfo item) + private void save(BeatmapInfo beatmapInfo, IBeatmap beatmapContent, ISkin? beatmapSkin, bool transferCollections) { - beatmapModelManager.Undelete(item); - } + var setInfo = beatmapInfo.BeatmapSet; + Debug.Assert(setInfo != null); - #endregion + // Difficulty settings must be copied first due to the clone in `Beatmap<>.BeatmapInfo_Set`. + // This should hopefully be temporary, assuming said clone is eventually removed. + + // Warning: The directionality here is important. Changes have to be copied *from* beatmapContent (which comes from editor and is being saved) + // *to* the beatmapInfo (which is a database model and needs to receive values without the taiko slider velocity multiplier for correct operation). + // CopyTo() will undo such adjustments, while CopyFrom() will not. + beatmapContent.Difficulty.CopyTo(beatmapInfo.Difficulty); + + // All changes to metadata are made in the provided beatmapInfo, so this should be copied to the `IBeatmap` before encoding. + beatmapContent.BeatmapInfo = beatmapInfo; + + using (var stream = new MemoryStream()) + { + using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) + new LegacyBeatmapEncoder(beatmapContent, beatmapSkin).Encode(sw); + + stream.Seek(0, SeekOrigin.Begin); + + // AddFile generally handles updating/replacing files, but this is a case where the filename may have also changed so let's delete for simplicity. + var existingFileInfo = beatmapInfo.Path != null ? setInfo.GetFile(beatmapInfo.Path) : null; + string targetFilename = createBeatmapFilenameFromMetadata(beatmapInfo); + + // ensure that two difficulties from the set don't point at the same beatmap file. + if (setInfo.Beatmaps.Any(b => b.ID != beatmapInfo.ID && string.Equals(b.Path, targetFilename, StringComparison.OrdinalIgnoreCase))) + throw new InvalidOperationException($"{setInfo.GetDisplayString()} already has a difficulty with the name of '{beatmapInfo.DifficultyName}'."); + + if (existingFileInfo != null) + DeleteFile(setInfo, existingFileInfo); + + string oldMd5Hash = beatmapInfo.MD5Hash; + + beatmapInfo.MD5Hash = stream.ComputeMD5Hash(); + beatmapInfo.Hash = stream.ComputeSHA2Hash(); + + beatmapInfo.LastLocalUpdate = DateTimeOffset.Now; + beatmapInfo.Status = BeatmapOnlineStatus.LocallyModified; + + AddFile(setInfo, stream, createBeatmapFilenameFromMetadata(beatmapInfo)); + + updateHashAndMarkDirty(setInfo); + + Realm.Write(r => + { + var liveBeatmapSet = r.Find(setInfo.ID); + + setInfo.CopyChangesToRealm(liveBeatmapSet); + + if (transferCollections) + beatmapInfo.TransferCollectionReferences(r, oldMd5Hash); + + // 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); + }); + } + + Debug.Assert(beatmapInfo.BeatmapSet != null); + + static string createBeatmapFilenameFromMetadata(BeatmapInfo beatmapInfo) + { + var metadata = beatmapInfo.Metadata; + return $"{metadata.Artist} - {metadata.Title} ({metadata.Author.Username}) [{beatmapInfo.DifficultyName}].osu".GetValidFilename(); + } + } #region Implementation of ICanAcceptFiles - public Task Import(params string[] paths) - { - return beatmapModelManager.Import(paths); - } + public Task Import(params string[] paths) => beatmapImporter.Import(paths); - public Task Import(params ImportTask[] tasks) - { - return beatmapModelManager.Import(tasks); - } + public Task Import(ImportTask[] tasks, ImportParameters parameters = default) => beatmapImporter.Import(tasks, parameters); - public Task>> Import(ProgressNotification notification, params ImportTask[] tasks) - { - return beatmapModelManager.Import(notification, tasks); - } + public Task>> Import(ProgressNotification notification, ImportTask[] tasks, ImportParameters parameters = default) => + beatmapImporter.Import(notification, tasks, parameters); - public Task?> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default) - { - return beatmapModelManager.Import(task, lowPriority, cancellationToken); - } + public Task?> Import(ImportTask task, ImportParameters parameters = default, CancellationToken cancellationToken = default) => + beatmapImporter.Import(task, parameters, cancellationToken); - public Task?> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default) - { - return beatmapModelManager.Import(archive, lowPriority, cancellationToken); - } + public Live? Import(BeatmapSetInfo item, ArchiveReader? archive = null, CancellationToken cancellationToken = default) => + beatmapImporter.ImportModel(item, archive, default, cancellationToken); - public Live? Import(BeatmapSetInfo item, ArchiveReader? archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) - { - return beatmapModelManager.Import(item, archive, lowPriority, cancellationToken); - } - - public IEnumerable HandledExtensions => beatmapModelManager.HandledExtensions; + public IEnumerable HandledExtensions => beatmapImporter.HandledExtensions; #endregion #region Implementation of IWorkingBeatmapCache - public WorkingBeatmap GetWorkingBeatmap(BeatmapInfo? importedBeatmap) + /// + /// Retrieve a instance for the provided + /// + /// The beatmap to lookup. + /// Whether to force a refetch from the database to ensure is up-to-date. + /// A instance correlating to the provided . + public WorkingBeatmap GetWorkingBeatmap(BeatmapInfo? beatmapInfo, bool refetch = false) { - // Detached sets don't come with files. - // If we seem to be missing files, now is a good time to re-fetch. - if (importedBeatmap?.BeatmapSet?.Files.Count == 0) + if (beatmapInfo != null) { - realm.Run(r => - { - var refetch = r.Find(importedBeatmap.ID)?.Detach(); + if (refetch) + workingBeatmapCache.Invalidate(beatmapInfo); - if (refetch != null) - importedBeatmap = refetch; - }); + // Detached beatmapsets don't come with files as an optimisation (see `RealmObjectExtensions.beatmap_set_mapper`). + // If we seem to be missing files, now is a good time to re-fetch. + bool missingFiles = beatmapInfo.BeatmapSet?.Files.Count == 0; + + if (refetch || beatmapInfo.IsManaged || missingFiles) + { + Guid id = beatmapInfo.ID; + beatmapInfo = Realm.Run(r => r.Find(id)?.Detach()) ?? beatmapInfo; + } + + Debug.Assert(beatmapInfo.IsManaged != true); } - return workingBeatmapCache.GetWorkingBeatmap(importedBeatmap); - } - - public WorkingBeatmap GetWorkingBeatmap(Live? importedBeatmap) - { - WorkingBeatmap working = workingBeatmapCache.GetWorkingBeatmap(null); - - importedBeatmap?.PerformRead(b => working = workingBeatmapCache.GetWorkingBeatmap(b)); - - return working; + return workingBeatmapCache.GetWorkingBeatmap(beatmapInfo); } + WorkingBeatmap IWorkingBeatmapCache.GetWorkingBeatmap(BeatmapInfo beatmapInfo) => GetWorkingBeatmap(beatmapInfo); void IWorkingBeatmapCache.Invalidate(BeatmapSetInfo beatmapSetInfo) => workingBeatmapCache.Invalidate(beatmapSetInfo); void IWorkingBeatmapCache.Invalidate(BeatmapInfo beatmapInfo) => workingBeatmapCache.Invalidate(beatmapInfo); - #endregion - - #region Implementation of IModelFileManager - - public void ReplaceFile(BeatmapSetInfo model, RealmNamedFileUsage file, Stream contents) + public event Action? OnInvalidated { - beatmapModelManager.ReplaceFile(model, file, contents); + add => workingBeatmapCache.OnInvalidated += value; + remove => workingBeatmapCache.OnInvalidated -= value; } - public void DeleteFile(BeatmapSetInfo model, RealmNamedFileUsage file) - { - beatmapModelManager.DeleteFile(model, file); - } - - public void AddFile(BeatmapSetInfo model, Stream contents, string filename) - { - beatmapModelManager.AddFile(model, contents, filename); - } - - #endregion - - #region Implementation of IDisposable - - public void Dispose() - { - onlineBeatmapLookupQueue?.Dispose(); - } + public override bool IsAvailableLocally(BeatmapSetInfo model) => Realm.Run(realm => realm.All().Any(s => s.OnlineID == model.OnlineID)); #endregion #region Implementation of IPostImports - public Action>>? PostImport + public Action>>? PresentImport { - set => beatmapModelManager.PostImport = value; + set => beatmapImporter.PresentImport = value; } #endregion + + 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 3a24c4808f..f645d914b1 100644 --- a/osu.Game/Beatmaps/BeatmapMetadata.cs +++ b/osu.Game/Beatmaps/BeatmapMetadata.cs @@ -10,10 +10,19 @@ using osu.Game.Users; using osu.Game.Utils; using Realms; -#nullable enable - namespace osu.Game.Beatmaps { + /// + /// A realm model containing metadata for a beatmap. + /// + /// + /// This is currently stored against each beatmap difficulty, even when it is duplicated. + /// It is also provided via for convenience and historical purposes. + /// A future effort could see this converted to an or potentially de-duped + /// and shared across multiple difficulties in the same set, if required. + /// + /// Note that difficulty name is not stored in this metadata but in . + /// [ExcludeFromDynamicCompile] [Serializable] [MapTo("BeatmapMetadata")] diff --git a/osu.Game/Beatmaps/BeatmapMetadataInfoExtensions.cs b/osu.Game/Beatmaps/BeatmapMetadataInfoExtensions.cs index 7e7d1babf0..fe4e815e62 100644 --- a/osu.Game/Beatmaps/BeatmapMetadataInfoExtensions.cs +++ b/osu.Game/Beatmaps/BeatmapMetadataInfoExtensions.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Linq; +#nullable disable + +using System.Collections.Generic; using osu.Framework.Localisation; namespace osu.Game.Beatmaps @@ -11,16 +13,31 @@ namespace osu.Game.Beatmaps /// /// An array of all searchable terms provided in contained metadata. /// - public static string[] GetSearchableTerms(this IBeatmapMetadataInfo metadataInfo) => new[] + public static string[] GetSearchableTerms(this IBeatmapMetadataInfo metadataInfo) { - metadataInfo.Author.Username, - metadataInfo.Artist, - metadataInfo.ArtistUnicode, - metadataInfo.Title, - metadataInfo.TitleUnicode, - metadataInfo.Source, - metadataInfo.Tags - }.Where(s => !string.IsNullOrEmpty(s)).ToArray(); + var termsList = new List(MAX_SEARCHABLE_TERM_COUNT); + CollectSearchableTerms(metadataInfo, termsList); + return termsList.ToArray(); + } + + internal const int MAX_SEARCHABLE_TERM_COUNT = 7; + + internal static void CollectSearchableTerms(IBeatmapMetadataInfo metadataInfo, IList termsList) + { + addIfNotNull(metadataInfo.Author.Username); + addIfNotNull(metadataInfo.Artist); + addIfNotNull(metadataInfo.ArtistUnicode); + addIfNotNull(metadataInfo.Title); + addIfNotNull(metadataInfo.TitleUnicode); + addIfNotNull(metadataInfo.Source); + addIfNotNull(metadataInfo.Tags); + + void addIfNotNull(string s) + { + if (!string.IsNullOrEmpty(s)) + termsList.Add(s); + } + } /// /// A user-presentable display title representing this metadata. diff --git a/osu.Game/Beatmaps/BeatmapModelDownloader.cs b/osu.Game/Beatmaps/BeatmapModelDownloader.cs index d31730ca15..4295def5c3 100644 --- a/osu.Game/Beatmaps/BeatmapModelDownloader.cs +++ b/osu.Game/Beatmaps/BeatmapModelDownloader.cs @@ -12,7 +12,7 @@ namespace osu.Game.Beatmaps protected override ArchiveDownloadRequest CreateDownloadRequest(IBeatmapSetInfo set, bool minimiseDownloadSize) => new DownloadBeatmapSetRequest(set, minimiseDownloadSize); - public override ArchiveDownloadRequest GetExistingDownload(IBeatmapSetInfo model) + public override ArchiveDownloadRequest? GetExistingDownload(IBeatmapSetInfo model) => CurrentDownloads.Find(r => r.Model.OnlineID == model.OnlineID); public BeatmapModelDownloader(IModelImporter beatmapImporter, IAPIProvider api) diff --git a/osu.Game/Beatmaps/BeatmapModelManager.cs b/osu.Game/Beatmaps/BeatmapModelManager.cs deleted file mode 100644 index 277047348e..0000000000 --- a/osu.Game/Beatmaps/BeatmapModelManager.cs +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Linq.Expressions; -using System.Text; -using osu.Framework.Extensions; -using osu.Framework.Platform; -using osu.Framework.Testing; -using osu.Game.Beatmaps.Formats; -using osu.Game.Database; -using osu.Game.Extensions; -using osu.Game.Skinning; -using osu.Game.Stores; -using osu.Game.Overlays.Notifications; - -#nullable enable - -namespace osu.Game.Beatmaps -{ - [ExcludeFromDynamicCompile] - public class BeatmapModelManager : BeatmapImporter - { - /// - /// The game working beatmap cache, used to invalidate entries on changes. - /// - public IWorkingBeatmapCache? WorkingBeatmapCache { private get; set; } - - public override IEnumerable HandledExtensions => new[] { ".osz" }; - - protected override string[] HashableFileTypes => new[] { ".osu" }; - - public static readonly string[] VIDEO_EXTENSIONS = { ".mp4", ".mov", ".avi", ".flv" }; - - public BeatmapModelManager(RealmAccess realm, Storage storage, BeatmapOnlineLookupQueue? onlineLookupQueue = null) - : base(realm, storage, onlineLookupQueue) - { - } - - protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path)?.ToLowerInvariant() == ".osz"; - - /// - /// Saves an file against a given . - /// - /// The to save the content against. The file referenced by will be replaced. - /// The content to write. - /// The beatmap content to write, null if to be omitted. - public void Save(BeatmapInfo beatmapInfo, IBeatmap beatmapContent, ISkin? beatmapSkin = null) - { - var setInfo = beatmapInfo.BeatmapSet; - Debug.Assert(setInfo != null); - - // Difficulty settings must be copied first due to the clone in `Beatmap<>.BeatmapInfo_Set`. - // This should hopefully be temporary, assuming said clone is eventually removed. - - // Warning: The directionality here is important. Changes have to be copied *from* beatmapContent (which comes from editor and is being saved) - // *to* the beatmapInfo (which is a database model and needs to receive values without the taiko slider velocity multiplier for correct operation). - // CopyTo() will undo such adjustments, while CopyFrom() will not. - beatmapContent.Difficulty.CopyTo(beatmapInfo.Difficulty); - - // All changes to metadata are made in the provided beatmapInfo, so this should be copied to the `IBeatmap` before encoding. - beatmapContent.BeatmapInfo = beatmapInfo; - - using (var stream = new MemoryStream()) - { - using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) - new LegacyBeatmapEncoder(beatmapContent, beatmapSkin).Encode(sw); - - stream.Seek(0, SeekOrigin.Begin); - - // AddFile generally handles updating/replacing files, but this is a case where the filename may have also changed so let's delete for simplicity. - var existingFileInfo = setInfo.Files.SingleOrDefault(f => string.Equals(f.Filename, beatmapInfo.Path, StringComparison.OrdinalIgnoreCase)); - string targetFilename = getFilename(beatmapInfo); - - // ensure that two difficulties from the set don't point at the same beatmap file. - if (setInfo.Beatmaps.Any(b => b.ID != beatmapInfo.ID && string.Equals(b.Path, targetFilename, StringComparison.OrdinalIgnoreCase))) - throw new InvalidOperationException($"{setInfo.GetDisplayString()} already has a difficulty with the name of '{beatmapInfo.DifficultyName}'."); - - if (existingFileInfo != null) - DeleteFile(setInfo, existingFileInfo); - - beatmapInfo.MD5Hash = stream.ComputeMD5Hash(); - beatmapInfo.Hash = stream.ComputeSHA2Hash(); - - AddFile(setInfo, stream, getFilename(beatmapInfo)); - Update(setInfo); - } - - WorkingBeatmapCache?.Invalidate(beatmapInfo); - } - - private static string getFilename(BeatmapInfo beatmapInfo) - { - var metadata = beatmapInfo.Metadata; - return $"{metadata.Artist} - {metadata.Title} ({metadata.Author.Username}) [{beatmapInfo.DifficultyName}].osu".GetValidArchiveContentFilename(); - } - - /// - /// Perform a lookup query on available s. - /// - /// The query. - /// The first result for the provided query, or null if no results were found. - public BeatmapInfo? QueryBeatmap(Expression> query) - { - return Realm.Run(realm => realm.All().FirstOrDefault(query)?.Detach()); - } - - public void Update(BeatmapSetInfo item) - { - Realm.Write(r => - { - var existing = r.Find(item.ID); - item.CopyChangesToRealm(existing); - }); - } - - /// - /// Delete videos from a list of beatmaps. - /// This will post notifications tracking progress. - /// - public void DeleteVideos(List items, bool silent = false) - { - if (items.Count == 0) return; - - var notification = new ProgressNotification - { - Progress = 0, - Text = $"Preparing to delete all {HumanisedModelName} videos...", - CompletionText = "No videos found to delete!", - State = ProgressNotificationState.Active, - }; - - if (!silent) - PostNotification?.Invoke(notification); - - int i = 0; - int deleted = 0; - - foreach (var b in items) - { - if (notification.State == ProgressNotificationState.Cancelled) - // user requested abort - return; - - var video = b.Files.FirstOrDefault(f => VIDEO_EXTENSIONS.Any(ex => f.Filename.EndsWith(ex, StringComparison.Ordinal))); - - if (video != null) - { - DeleteFile(b, video); - deleted++; - notification.CompletionText = $"Deleted {deleted} {HumanisedModelName} video(s)!"; - } - - notification.Text = $"Deleting videos from {HumanisedModelName}s ({deleted} deleted)"; - - notification.Progress = (float)++i / items.Count; - } - - notification.State = ProgressNotificationState.Completed; - } - } -} diff --git a/osu.Game/Beatmaps/BeatmapOnlineChangeIngest.cs b/osu.Game/Beatmaps/BeatmapOnlineChangeIngest.cs new file mode 100644 index 0000000000..b160043820 --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapOnlineChangeIngest.cs @@ -0,0 +1,50 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Graphics; +using osu.Game.Database; +using osu.Game.Online.Metadata; + +namespace osu.Game.Beatmaps +{ + /// + /// Ingests any changes that happen externally to the client, reprocessing as required. + /// + public partial class BeatmapOnlineChangeIngest : Component + { + private readonly BeatmapUpdater beatmapUpdater; + private readonly RealmAccess realm; + private readonly MetadataClient metadataClient; + + public BeatmapOnlineChangeIngest(BeatmapUpdater beatmapUpdater, RealmAccess realm, MetadataClient metadataClient) + { + this.beatmapUpdater = beatmapUpdater; + this.realm = realm; + this.metadataClient = metadataClient; + + metadataClient.ChangedBeatmapSetsArrived += changesDetected; + } + + private void changesDetected(int[] beatmapSetIds) + { + // May want to batch incoming updates further if the background realm operations ever becomes a concern. + realm.Run(r => + { + foreach (int id in beatmapSetIds) + { + var matchingSet = r.All().FirstOrDefault(s => s.OnlineID == id); + + if (matchingSet != null) + beatmapUpdater.Queue(matchingSet.ToLive(realm), MetadataLookupScope.OnlineFirst); + } + }); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + metadataClient.ChangedBeatmapSetsArrived -= changesDetected; + } + } +} diff --git a/osu.Game/Beatmaps/BeatmapOnlineStatus.cs b/osu.Game/Beatmaps/BeatmapOnlineStatus.cs index 7ac25b7b75..cdd99d4432 100644 --- a/osu.Game/Beatmaps/BeatmapOnlineStatus.cs +++ b/osu.Game/Beatmaps/BeatmapOnlineStatus.cs @@ -1,13 +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.ComponentModel; using osu.Framework.Localisation; +using osu.Game.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Beatmaps { public enum BeatmapOnlineStatus { + /// + /// This is a special status given when local changes are made via the editor. + /// Once in this state, online status changes should be ignored unless the beatmap is reverted or submitted. + /// + [Description("Local")] + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.LocallyModified))] + LocallyModified = -4, + None = -3, [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowStatusGraveyard))] diff --git a/osu.Game/Beatmaps/BeatmapProcessor.cs b/osu.Game/Beatmaps/BeatmapProcessor.cs index cdeaab06ed..8f3d0b7445 100644 --- a/osu.Game/Beatmaps/BeatmapProcessor.cs +++ b/osu.Game/Beatmaps/BeatmapProcessor.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Game.Rulesets.Objects.Types; diff --git a/osu.Game/Beatmaps/BeatmapSetFileInfo.cs b/osu.Game/Beatmaps/BeatmapSetFileInfo.cs deleted file mode 100644 index 3d41f59b3d..0000000000 --- a/osu.Game/Beatmaps/BeatmapSetFileInfo.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.ComponentModel.DataAnnotations; -using osu.Game.Database; -using osu.Game.IO; - -namespace osu.Game.Beatmaps -{ - public class BeatmapSetFileInfo : INamedFileInfo, IHasPrimaryKey, INamedFileUsage - { - public int ID { get; set; } - - public bool IsManaged => ID > 0; - - public int BeatmapSetInfoID { get; set; } - - public EFBeatmapSetInfo BeatmapSetInfo { get; set; } - - public int FileInfoID { get; set; } - - public FileInfo FileInfo { get; set; } - - [Required] - public string Filename { get; set; } - - IFileInfo INamedFileUsage.File => FileInfo; - } -} diff --git a/osu.Game/Beatmaps/BeatmapSetHypeStatus.cs b/osu.Game/Beatmaps/BeatmapSetHypeStatus.cs index 8a576e396a..b2d4cac210 100644 --- a/osu.Game/Beatmaps/BeatmapSetHypeStatus.cs +++ b/osu.Game/Beatmaps/BeatmapSetHypeStatus.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Newtonsoft.Json; namespace osu.Game.Beatmaps diff --git a/osu.Game/Beatmaps/BeatmapSetInfo.cs b/osu.Game/Beatmaps/BeatmapSetInfo.cs index 9a4207d5cf..b90dfdba05 100644 --- a/osu.Game/Beatmaps/BeatmapSetInfo.cs +++ b/osu.Game/Beatmaps/BeatmapSetInfo.cs @@ -12,10 +12,11 @@ using osu.Game.Extensions; using osu.Game.Models; using Realms; -#nullable enable - 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 @@ -28,6 +29,16 @@ namespace osu.Game.Beatmaps public DateTimeOffset DateAdded { get; set; } + /// + /// The date this beatmap set was first submitted. + /// + public DateTimeOffset? DateSubmitted { get; set; } + + /// + /// The date this beatmap set was ranked. + /// + public DateTimeOffset? DateRanked { get; set; } + [JsonIgnore] public IBeatmapMetadataInfo Metadata => Beatmaps.FirstOrDefault()?.Metadata ?? new BeatmapMetadata(); @@ -73,13 +84,6 @@ namespace osu.Game.Beatmaps { } - /// - /// Returns the storage path for the file in this beatmapset with the given filename, if any exists, otherwise null. - /// The path returned is relative to the user file storage. - /// - /// The name of the file to get the storage path of. - public string? GetPathForFile(string filename) => Files.SingleOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath(); - public bool Equals(BeatmapSetInfo? other) { if (ReferenceEquals(this, other)) return true; @@ -95,5 +99,7 @@ namespace osu.Game.Beatmaps IEnumerable IBeatmapSetInfo.Beatmaps => Beatmaps; IEnumerable IHasNamedFiles.Files => Files; + + public bool AllBeatmapsUpToDate => Beatmaps.All(b => b.MatchesOnlineVersion); } } diff --git a/osu.Game/Beatmaps/BeatmapSetInfoExtensions.cs b/osu.Game/Beatmaps/BeatmapSetInfoExtensions.cs new file mode 100644 index 0000000000..965544da40 --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapSetInfoExtensions.cs @@ -0,0 +1,33 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Game.Database; +using osu.Game.Extensions; +using osu.Game.Models; + +namespace osu.Game.Beatmaps +{ + public static class BeatmapSetInfoExtensions + { + /// + /// Returns the storage path for the file in this beatmapset with the given filename, if any exists, otherwise null. + /// The path returned is relative to the user file storage. + /// The lookup is case insensitive. + /// + /// The model to operate on. + /// The name of the file to get the storage path of. + public static string? GetPathForFile(this IHasRealmFiles model, string filename) => model.GetFile(filename)?.File.GetStoragePath(); + + /// + /// Returns the file usage for the file in this beatmapset with the given filename, if any exists, otherwise null. + /// The path returned is relative to the user file storage. + /// The lookup is case insensitive. + /// + /// The model to operate on. + /// The name of the file to get the storage path of. + public static RealmNamedFileUsage? GetFile(this IHasRealmFiles model, string filename) => + model.Files.SingleOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/osu.Game/Beatmaps/BeatmapSetNominationStatus.cs b/osu.Game/Beatmaps/BeatmapSetNominationStatus.cs index 6a19616a97..cea0063814 100644 --- a/osu.Game/Beatmaps/BeatmapSetNominationStatus.cs +++ b/osu.Game/Beatmaps/BeatmapSetNominationStatus.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Newtonsoft.Json; namespace osu.Game.Beatmaps diff --git a/osu.Game/Beatmaps/BeatmapSetOnlineAvailability.cs b/osu.Game/Beatmaps/BeatmapSetOnlineAvailability.cs index 14a63f3279..12424b797c 100644 --- a/osu.Game/Beatmaps/BeatmapSetOnlineAvailability.cs +++ b/osu.Game/Beatmaps/BeatmapSetOnlineAvailability.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Newtonsoft.Json; namespace osu.Game.Beatmaps diff --git a/osu.Game/Beatmaps/BeatmapSetOnlineGenre.cs b/osu.Game/Beatmaps/BeatmapSetOnlineGenre.cs index e727e2c37f..ad2e994d3e 100644 --- a/osu.Game/Beatmaps/BeatmapSetOnlineGenre.cs +++ b/osu.Game/Beatmaps/BeatmapSetOnlineGenre.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Beatmaps { public struct BeatmapSetOnlineGenre diff --git a/osu.Game/Beatmaps/BeatmapSetOnlineLanguage.cs b/osu.Game/Beatmaps/BeatmapSetOnlineLanguage.cs index 658e5a4005..c71c279086 100644 --- a/osu.Game/Beatmaps/BeatmapSetOnlineLanguage.cs +++ b/osu.Game/Beatmaps/BeatmapSetOnlineLanguage.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Beatmaps { public struct BeatmapSetOnlineLanguage diff --git a/osu.Game/Beatmaps/BeatmapSetOnlineNomination.cs b/osu.Game/Beatmaps/BeatmapSetOnlineNomination.cs new file mode 100644 index 0000000000..f6de414628 --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapSetOnlineNomination.cs @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; + +namespace osu.Game.Beatmaps +{ + public struct BeatmapSetOnlineNomination + { + [JsonProperty(@"beatmapset_id")] + public int BeatmapsetId { get; set; } + + [JsonProperty(@"reset")] + public bool Reset { get; set; } + + [JsonProperty(@"rulesets")] + public string[]? Rulesets { get; set; } + + [JsonProperty(@"user_id")] + public int UserId { get; set; } + } +} diff --git a/osu.Game/Beatmaps/BeatmapStatistic.cs b/osu.Game/Beatmaps/BeatmapStatistic.cs index 94ebb56a5c..13e0e4ad5e 100644 --- a/osu.Game/Beatmaps/BeatmapStatistic.cs +++ b/osu.Game/Beatmaps/BeatmapStatistic.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics; using osu.Framework.Localisation; diff --git a/osu.Game/Beatmaps/BeatmapStatisticIcon.cs b/osu.Game/Beatmaps/BeatmapStatisticIcon.cs index 181fb540df..ca07e5f365 100644 --- a/osu.Game/Beatmaps/BeatmapStatisticIcon.cs +++ b/osu.Game/Beatmaps/BeatmapStatisticIcon.cs @@ -1,17 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using Humanizer; +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; +using osu.Game.Extensions; namespace osu.Game.Beatmaps { /// /// A default implementation of an icon used to represent beatmap statistics. /// - public class BeatmapStatisticIcon : Sprite + public partial class BeatmapStatisticIcon : Sprite { private readonly BeatmapStatisticsIconType iconType; @@ -23,7 +25,7 @@ namespace osu.Game.Beatmaps [BackgroundDependencyLoader] private void load(TextureStore textures) { - Texture = textures.Get($"Icons/BeatmapDetails/{iconType.ToString().Kebaberize()}"); + Texture = textures.Get($"Icons/BeatmapDetails/{iconType.ToString().ToKebabCase()}"); } } diff --git a/osu.Game/Beatmaps/BeatmapUpdater.cs b/osu.Game/Beatmaps/BeatmapUpdater.cs new file mode 100644 index 0000000000..af9f32f834 --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapUpdater.cs @@ -0,0 +1,112 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Logging; +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 +{ + /// + /// Handles all processing required to ensure a local beatmap is in a consistent state with any changes. + /// + public class BeatmapUpdater : IDisposable + { + private readonly IWorkingBeatmapCache workingBeatmapCache; + + private readonly BeatmapDifficultyCache difficultyCache; + + private readonly BeatmapUpdaterMetadataLookup metadataLookup; + + private const int update_queue_request_concurrency = 4; + + private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler(update_queue_request_concurrency, nameof(BeatmapUpdaterMetadataLookup)); + + public BeatmapUpdater(IWorkingBeatmapCache workingBeatmapCache, BeatmapDifficultyCache difficultyCache, IAPIProvider api, Storage storage) + { + this.workingBeatmapCache = workingBeatmapCache; + this.difficultyCache = difficultyCache; + + metadataLookup = new BeatmapUpdaterMetadataLookup(api, storage); + } + + /// + /// Queue a beatmap for background processing. + /// + /// The managed beatmap set to update. A transaction will be opened to apply changes. + /// 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, 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. + /// 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); + + if (lookupScope != MetadataLookupScope.None) + metadataLookup.Update(beatmapSet, lookupScope == MetadataLookupScope.OnlineFirst); + + foreach (var beatmap in beatmapSet.Beatmaps) + { + difficultyCache.Invalidate(beatmap); + + var working = workingBeatmapCache.GetWorkingBeatmap(beatmap); + var ruleset = working.BeatmapInfo.Ruleset.CreateInstance(); + + Debug.Assert(ruleset != null); + + var calculator = ruleset.CreateDifficultyCalculator(working); + + beatmap.StarRating = calculator.Calculate().StarRating; + beatmap.Length = calculateLength(working.Beatmap); + beatmap.BPM = 60000 / working.Beatmap.GetMostCommonBeatLength(); + } + + // And invalidate again afterwards as re-fetching the most up-to-date database metadata will be required. + 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() + { + if (metadataLookup.IsNotNull()) + metadataLookup.Dispose(); + + if (updateScheduler.IsNotNull()) + updateScheduler.Dispose(); + } + + #endregion + } +} diff --git a/osu.Game/Beatmaps/BeatmapOnlineLookupQueue.cs b/osu.Game/Beatmaps/BeatmapUpdaterMetadataLookup.cs similarity index 58% rename from osu.Game/Beatmaps/BeatmapOnlineLookupQueue.cs rename to osu.Game/Beatmaps/BeatmapUpdaterMetadataLookup.cs index fc39887e79..71d40b1a48 100644 --- a/osu.Game/Beatmaps/BeatmapOnlineLookupQueue.cs +++ b/osu.Game/Beatmaps/BeatmapUpdaterMetadataLookup.cs @@ -1,11 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using System.IO; using System.Linq; -using System.Threading; using System.Threading.Tasks; using Microsoft.Data.Sqlite; using osu.Framework.Development; @@ -13,13 +14,12 @@ using osu.Framework.IO.Network; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Testing; -using osu.Framework.Threading; using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Online.API.Requests; -using osu.Game.Stores; using SharpCompress.Compressors; using SharpCompress.Compressors.BZip2; +using SQLitePCL; namespace osu.Game.Beatmaps { @@ -31,21 +31,28 @@ namespace osu.Game.Beatmaps /// This will always be checked before doing a second online query to get required metadata. /// [ExcludeFromDynamicCompile] - public class BeatmapOnlineLookupQueue : IDisposable + public class BeatmapUpdaterMetadataLookup : IDisposable { private readonly IAPIProvider api; private readonly Storage storage; - private const int update_queue_request_concurrency = 4; - - private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler(update_queue_request_concurrency, nameof(BeatmapOnlineLookupQueue)); - private FileWebRequest cacheDownloadRequest; private const string cache_database_name = "online.db"; - public BeatmapOnlineLookupQueue(IAPIProvider api, Storage storage) + public BeatmapUpdaterMetadataLookup(IAPIProvider api, Storage storage) { + try + { + // required to initialise native SQLite libraries on some platforms. + Batteries_V2.Init(); + raw.sqlite3_config(2 /*SQLITE_CONFIG_MULTITHREAD*/); + } + catch + { + // may fail if platform not supported. + } + this.api = api; this.storage = storage; @@ -54,27 +61,30 @@ namespace osu.Game.Beatmaps prepareLocalCache(); } - public void Update(BeatmapSetInfo beatmapSet) + /// + /// Queue an update for a beatmap set. + /// + /// + /// This may happen during initial import, or at a later stage in response to a user action or server event. + /// + /// The beatmap set to update. Updates will be applied directly (so a transaction should be started if this instance is managed). + /// 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 Update(BeatmapSetInfo beatmapSet, bool preferOnlineFetch) { foreach (var b in beatmapSet.Beatmaps) - lookup(beatmapSet, b); + lookup(beatmapSet, b, preferOnlineFetch); } - public Task UpdateAsync(BeatmapSetInfo beatmapSet, CancellationToken cancellationToken) + private void lookup(BeatmapSetInfo set, BeatmapInfo beatmapInfo, bool preferOnlineFetch) { - return Task.WhenAll(beatmapSet.Beatmaps.Select(b => UpdateAsync(beatmapSet, b, cancellationToken)).ToArray()); - } + bool apiAvailable = api?.State.Value == APIState.Online; - // todo: expose this when we need to do individual difficulty lookups. - protected Task UpdateAsync(BeatmapSetInfo beatmapSet, BeatmapInfo beatmapInfo, CancellationToken cancellationToken) - => Task.Factory.StartNew(() => lookup(beatmapSet, beatmapInfo), cancellationToken, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler); + bool useLocalCache = !apiAvailable || !preferOnlineFetch; - private void lookup(BeatmapSetInfo set, BeatmapInfo beatmapInfo) - { - if (checkLocalCache(set, beatmapInfo)) + if (useLocalCache && checkLocalCache(set, beatmapInfo)) return; - if (api?.State.Value != APIState.Online) + if (!apiAvailable) return; var req = new GetBeatmapRequest(beatmapInfo); @@ -87,7 +97,7 @@ namespace osu.Game.Beatmaps if (req.CompletionState == APIRequestCompletionState.Failed) { logForModel(set, $"Online retrieval failed for {beatmapInfo}"); - beatmapInfo.OnlineID = -1; + beatmapInfo.ResetOnlineInfo(); return; } @@ -95,15 +105,26 @@ namespace osu.Game.Beatmaps if (res != null) { - beatmapInfo.Status = res.Status; + beatmapInfo.OnlineID = res.OnlineID; + beatmapInfo.OnlineMD5Hash = res.MD5Hash; + beatmapInfo.LastOnlineUpdate = res.LastUpdated; Debug.Assert(beatmapInfo.BeatmapSet != null); - - beatmapInfo.BeatmapSet.Status = res.BeatmapSet?.Status ?? BeatmapOnlineStatus.None; beatmapInfo.BeatmapSet.OnlineID = res.OnlineBeatmapSetID; - beatmapInfo.OnlineID = res.OnlineID; - beatmapInfo.Metadata.Author.OnlineID = res.AuthorID; + // Some metadata should only be applied if there's no local changes. + if (shouldSaveOnlineMetadata(beatmapInfo)) + { + beatmapInfo.Status = res.Status; + beatmapInfo.Metadata.Author.OnlineID = res.AuthorID; + } + + if (beatmapInfo.BeatmapSet.Beatmaps.All(shouldSaveOnlineMetadata)) + { + beatmapInfo.BeatmapSet.Status = res.BeatmapSet?.Status ?? BeatmapOnlineStatus.None; + beatmapInfo.BeatmapSet.DateRanked = res.BeatmapSet?.Ranked; + beatmapInfo.BeatmapSet.DateSubmitted = res.BeatmapSet?.Submitted; + } logForModel(set, $"Online retrieval mapped {beatmapInfo} to {res.OnlineBeatmapSetID} / {res.OnlineID}."); } @@ -111,7 +132,7 @@ namespace osu.Game.Beatmaps catch (Exception e) { logForModel(set, $"Online retrieval failed for {beatmapInfo} ({e.Message})"); - beatmapInfo.OnlineID = -1; + beatmapInfo.ResetOnlineInfo(); } } @@ -127,7 +148,7 @@ namespace osu.Game.Beatmaps File.Delete(compressedCacheFilePath); File.Delete(cacheFilePath); - Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache download failed: {ex}", LoggingTarget.Database); + Logger.Log($"{nameof(BeatmapUpdaterMetadataLookup)}'s online cache download failed: {ex}", LoggingTarget.Database); }; cacheDownloadRequest.Finished += () => @@ -144,7 +165,7 @@ namespace osu.Game.Beatmaps } catch (Exception ex) { - Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache extraction failed: {ex}", LoggingTarget.Database); + Logger.Log($"{nameof(BeatmapUpdaterMetadataLookup)}'s online cache extraction failed: {ex}", LoggingTarget.Database); File.Delete(cacheFilePath); } finally @@ -157,7 +178,7 @@ namespace osu.Game.Beatmaps { try { - await cacheDownloadRequest.PerformAsync(); + await cacheDownloadRequest.PerformAsync().ConfigureAwait(false); } catch { @@ -183,13 +204,14 @@ namespace osu.Game.Beatmaps try { - using (var db = new SqliteConnection(DatabaseContextFactory.CreateDatabaseConnectionString("online.db", storage))) + using (var db = new SqliteConnection(string.Concat("Data Source=", storage.GetFullPath($@"{"online.db"}", true)))) { db.Open(); using (var cmd = db.CreateCommand()) { - cmd.CommandText = "SELECT beatmapset_id, beatmap_id, approved, user_id FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineID OR filename = @Path"; + cmd.CommandText = + "SELECT beatmapset_id, beatmap_id, approved, user_id, checksum, last_update FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineID OR filename = @Path"; cmd.Parameters.Add(new SqliteParameter("@MD5Hash", beatmapInfo.MD5Hash)); cmd.Parameters.Add(new SqliteParameter("@OnlineID", beatmapInfo.OnlineID)); @@ -201,15 +223,25 @@ namespace osu.Game.Beatmaps { var status = (BeatmapOnlineStatus)reader.GetByte(2); - beatmapInfo.Status = status; + // Some metadata should only be applied if there's no local changes. + if (shouldSaveOnlineMetadata(beatmapInfo)) + { + beatmapInfo.Status = status; + beatmapInfo.Metadata.Author.OnlineID = reader.GetInt32(3); + } + + // TODO: DateSubmitted and DateRanked are not provided by local cache. + beatmapInfo.OnlineID = reader.GetInt32(1); + beatmapInfo.OnlineMD5Hash = reader.GetString(4); + beatmapInfo.LastOnlineUpdate = reader.GetDateTimeOffset(5); Debug.Assert(beatmapInfo.BeatmapSet != null); - - beatmapInfo.BeatmapSet.Status = status; beatmapInfo.BeatmapSet.OnlineID = reader.GetInt32(0); - beatmapInfo.OnlineID = reader.GetInt32(1); - beatmapInfo.Metadata.Author.OnlineID = reader.GetInt32(3); + if (beatmapInfo.BeatmapSet.Beatmaps.All(shouldSaveOnlineMetadata)) + { + beatmapInfo.BeatmapSet.Status = status; + } logForModel(set, $"Cached local retrieval for {beatmapInfo}."); return true; @@ -227,12 +259,17 @@ namespace osu.Game.Beatmaps } private void logForModel(BeatmapSetInfo set, string message) => - RealmArchiveModelImporter.LogForModel(set, $"[{nameof(BeatmapOnlineLookupQueue)}] {message}"); + RealmArchiveModelImporter.LogForModel(set, $"[{nameof(BeatmapUpdaterMetadataLookup)}] {message}"); + + /// + /// Check whether the provided beatmap is in a state where online "ranked" status metadata should be saved against it. + /// Handles the case where a user may have locally modified a beatmap in the editor and expects the local status to stick. + /// + private static bool shouldSaveOnlineMetadata(BeatmapInfo beatmapInfo) => beatmapInfo.MatchesOnlineVersion || beatmapInfo.Status != BeatmapOnlineStatus.LocallyModified; public void Dispose() { cacheDownloadRequest?.Dispose(); - updateScheduler?.Dispose(); } } } diff --git a/osu.Game/Beatmaps/BeatmapUserSettings.cs b/osu.Game/Beatmaps/BeatmapUserSettings.cs index 5c71bf34b1..1d993e6d67 100644 --- a/osu.Game/Beatmaps/BeatmapUserSettings.cs +++ b/osu.Game/Beatmaps/BeatmapUserSettings.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. -#nullable enable using Realms; namespace osu.Game.Beatmaps diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs index 4079a0cd5f..f46e4af332 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs @@ -9,17 +9,14 @@ using osuTK.Graphics; namespace osu.Game.Beatmaps.ControlPoints { - public abstract class ControlPoint : IComparable, IDeepCloneable + public abstract class ControlPoint : IComparable, IDeepCloneable, IEquatable, IControlPoint { - /// - /// The time at which the control point takes effect. - /// [JsonIgnore] public double Time { get; set; } public void AttachGroup(ControlPointGroup pointGroup) => Time = pointGroup.Time; - public int CompareTo(ControlPoint other) => Time.CompareTo(other.Time); + public int CompareTo(ControlPoint? other) => Time.CompareTo(other?.Time); public virtual Color4 GetRepresentingColour(OsuColour colours) => colours.Yellow; @@ -28,14 +25,14 @@ namespace osu.Game.Beatmaps.ControlPoints /// /// An existing control point to compare with. /// Whether this is redundant when placed alongside . - public abstract bool IsRedundant(ControlPoint existing); + public abstract bool IsRedundant(ControlPoint? existing); /// /// Create an unbound copy of this control point. /// public ControlPoint DeepClone() { - var copy = (ControlPoint)Activator.CreateInstance(GetType()); + var copy = (ControlPoint)Activator.CreateInstance(GetType())!; copy.CopyFrom(this); @@ -46,5 +43,20 @@ namespace osu.Game.Beatmaps.ControlPoints { Time = other.Time; } + + public sealed override bool Equals(object? obj) + => obj is ControlPoint otherControlPoint + && Equals(otherControlPoint); + + public virtual bool Equals(ControlPoint? other) + { + if (ReferenceEquals(other, null)) return false; + if (ReferenceEquals(other, this)) return true; + + return Time == other.Time; + } + + // ReSharper disable once NonReadonlyMemberInGetHashCode + public override int GetHashCode() => Time.GetHashCode(); } } diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPointGroup.cs b/osu.Game/Beatmaps/ControlPoints/ControlPointGroup.cs index cb73ce884e..1f34f3777d 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPointGroup.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPointGroup.cs @@ -7,10 +7,10 @@ using osu.Framework.Bindables; namespace osu.Game.Beatmaps.ControlPoints { - public class ControlPointGroup : IComparable + public class ControlPointGroup : IComparable, IEquatable { - public event Action ItemAdded; - public event Action ItemRemoved; + public event Action? ItemAdded; + public event Action? ItemRemoved; /// /// The time at which the control point takes effect. @@ -26,7 +26,7 @@ namespace osu.Game.Beatmaps.ControlPoints Time = time; } - public int CompareTo(ControlPointGroup other) => Time.CompareTo(other.Time); + public int CompareTo(ControlPointGroup? other) => Time.CompareTo(other?.Time); public void Add(ControlPoint point) { @@ -46,5 +46,23 @@ namespace osu.Game.Beatmaps.ControlPoints controlPoints.Remove(point); ItemRemoved?.Invoke(point); } + + public sealed override bool Equals(object? obj) + => obj is ControlPointGroup otherGroup + && Equals(otherGroup); + + public virtual bool Equals(ControlPointGroup? other) + => other != null + && Time == other.Time + && ControlPoints.SequenceEqual(other.ControlPoints); + + public override int GetHashCode() + { + HashCode hashCode = new HashCode(); + hashCode.Add(Time); + foreach (var point in controlPoints) + hashCode.Add(point); + return hashCode.ToHashCode(); + } } } diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs index af03d639be..1a15db98e4 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -68,14 +70,14 @@ namespace osu.Game.Beatmaps.ControlPoints /// [JsonIgnore] public double BPMMaximum => - 60000 / (TimingPoints.OrderBy(c => c.BeatLength).FirstOrDefault() ?? TimingControlPoint.DEFAULT).BeatLength; + 60000 / (TimingPoints.MinBy(c => c.BeatLength) ?? TimingControlPoint.DEFAULT).BeatLength; /// /// Finds the minimum BPM represented by any timing control point. /// [JsonIgnore] public double BPMMinimum => - 60000 / (TimingPoints.OrderByDescending(c => c.BeatLength).FirstOrDefault() ?? TimingControlPoint.DEFAULT).BeatLength; + 60000 / (TimingPoints.MaxBy(c => c.BeatLength) ?? TimingControlPoint.DEFAULT).BeatLength; /// /// Remove all s and return to a pristine state. @@ -181,9 +183,15 @@ namespace osu.Game.Beatmaps.ControlPoints private static double getClosestSnappedTime(TimingControlPoint timingPoint, double time, int beatDivisor) { double beatLength = timingPoint.BeatLength / beatDivisor; - int beatLengths = (int)Math.Round((time - timingPoint.Time) / beatLength, MidpointRounding.AwayFromZero); + double beats = (Math.Max(time, 0) - timingPoint.Time) / beatLength; - return timingPoint.Time + beatLengths * beatLength; + int roundedBeats = (int)Math.Round(beats, MidpointRounding.AwayFromZero); + double snappedTime = timingPoint.Time + roundedBeats * beatLength; + + if (snappedTime >= 0) + return snappedTime; + + return snappedTime + beatLength; } /// @@ -194,8 +202,8 @@ namespace osu.Game.Beatmaps.ControlPoints /// The time to find the control point at. /// The control point to use when is before any control points. /// The active control point at , or a fallback if none found. - protected T BinarySearchWithFallback(IReadOnlyList list, double time, T fallback) - where T : ControlPoint + public static T BinarySearchWithFallback(IReadOnlyList list, double time, T fallback) + where T : class, IControlPoint { return BinarySearch(list, time) ?? fallback; } @@ -205,12 +213,11 @@ namespace osu.Game.Beatmaps.ControlPoints /// /// The list to search. /// The time to find the control point at. - /// The active control point at . - protected virtual T BinarySearch(IReadOnlyList list, double time) - where T : ControlPoint + /// The active control point at . Will return null if there are no control points, or if the time is before the first control point. + public static T BinarySearch(IReadOnlyList list, double time) + where T : class, IControlPoint { - if (list == null) - throw new ArgumentNullException(nameof(list)); + ArgumentNullException.ThrowIfNull(list); if (list.Count == 0) return null; @@ -252,12 +259,12 @@ namespace osu.Game.Beatmaps.ControlPoints switch (newPoint) { - case TimingControlPoint _: + case TimingControlPoint: // Timing points are a special case and need to be added regardless of fallback availability. existing = BinarySearch(TimingPoints, time); break; - case EffectControlPoint _: + case EffectControlPoint: existing = EffectPointAt(time); break; } @@ -298,7 +305,7 @@ namespace osu.Game.Beatmaps.ControlPoints public ControlPointInfo DeepClone() { - var controlPointInfo = (ControlPointInfo)Activator.CreateInstance(GetType()); + var controlPointInfo = (ControlPointInfo)Activator.CreateInstance(GetType())!; foreach (var point in AllControlPoints) controlPointInfo.Add(point.Time, point.DeepClone()); diff --git a/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs index bf7ed8e6f5..e6f1609d7f 100644 --- a/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Bindables; using osu.Game.Graphics; using osuTK.Graphics; @@ -10,7 +11,7 @@ namespace osu.Game.Beatmaps.ControlPoints /// /// Note that going forward, this control point type should always be assigned directly to HitObjects. /// - public class DifficultyControlPoint : ControlPoint + public class DifficultyControlPoint : ControlPoint, IEquatable { public static readonly DifficultyControlPoint DEFAULT = new DifficultyControlPoint { @@ -23,7 +24,6 @@ namespace osu.Game.Beatmaps.ControlPoints public readonly BindableDouble SliderVelocityBindable = new BindableDouble(1) { Precision = 0.01, - Default = 1, MinValue = 0.1, MaxValue = 10 }; @@ -39,7 +39,7 @@ namespace osu.Game.Beatmaps.ControlPoints set => SliderVelocityBindable.Value = value; } - public override bool IsRedundant(ControlPoint existing) + public override bool IsRedundant(ControlPoint? existing) => existing is DifficultyControlPoint existingDifficulty && SliderVelocity == existingDifficulty.SliderVelocity; @@ -49,5 +49,15 @@ namespace osu.Game.Beatmaps.ControlPoints base.CopyFrom(other); } + + public override bool Equals(ControlPoint? other) + => other is DifficultyControlPoint otherDifficultyControlPoint + && Equals(otherDifficultyControlPoint); + + public bool Equals(DifficultyControlPoint? other) + => base.Equals(other) + && SliderVelocity == other.SliderVelocity; + + public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), SliderVelocity); } } diff --git a/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs index 7f550a52fc..7edf892f35 100644 --- a/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs @@ -1,33 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Bindables; using osu.Game.Graphics; using osuTK.Graphics; namespace osu.Game.Beatmaps.ControlPoints { - public class EffectControlPoint : ControlPoint + public class EffectControlPoint : ControlPoint, IEquatable { public static readonly EffectControlPoint DEFAULT = new EffectControlPoint { KiaiModeBindable = { Disabled = true }, - OmitFirstBarLineBindable = { Disabled = true }, ScrollSpeedBindable = { Disabled = true } }; - /// - /// Whether the first bar line of this control point is ignored. - /// - public readonly BindableBool OmitFirstBarLineBindable = new BindableBool(); - /// /// The relative scroll speed at this control point. /// public readonly BindableDouble ScrollSpeedBindable = new BindableDouble(1) { Precision = 0.01, - Default = 1, MinValue = 0.01, MaxValue = 10 }; @@ -43,15 +37,6 @@ namespace osu.Game.Beatmaps.ControlPoints public override Color4 GetRepresentingColour(OsuColour colours) => colours.Purple; - /// - /// Whether the first bar line of this control point is ignored. - /// - public bool OmitFirstBarLine - { - get => OmitFirstBarLineBindable.Value; - set => OmitFirstBarLineBindable.Value = value; - } - /// /// Whether this control point enables Kiai mode. /// @@ -66,20 +51,28 @@ namespace osu.Game.Beatmaps.ControlPoints set => KiaiModeBindable.Value = value; } - public override bool IsRedundant(ControlPoint existing) - => !OmitFirstBarLine - && existing is EffectControlPoint existingEffect + public override bool IsRedundant(ControlPoint? existing) + => existing is EffectControlPoint existingEffect && KiaiMode == existingEffect.KiaiMode - && OmitFirstBarLine == existingEffect.OmitFirstBarLine && ScrollSpeed == existingEffect.ScrollSpeed; public override void CopyFrom(ControlPoint other) { KiaiMode = ((EffectControlPoint)other).KiaiMode; - OmitFirstBarLine = ((EffectControlPoint)other).OmitFirstBarLine; ScrollSpeed = ((EffectControlPoint)other).ScrollSpeed; base.CopyFrom(other); } + + public override bool Equals(ControlPoint? other) + => other is EffectControlPoint otherEffectControlPoint + && Equals(otherEffectControlPoint); + + public bool Equals(EffectControlPoint? other) + => base.Equals(other) + && ScrollSpeed == other.ScrollSpeed + && KiaiMode == other.KiaiMode; + + public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), ScrollSpeed, KiaiMode); } } diff --git a/osu.Game/Beatmaps/ControlPoints/IControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/IControlPoint.cs new file mode 100644 index 0000000000..091e99e029 --- /dev/null +++ b/osu.Game/Beatmaps/ControlPoints/IControlPoint.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Beatmaps.ControlPoints +{ + public interface IControlPoint + { + /// + /// The time at which the control point takes effect. + /// + double Time { get; } + } +} diff --git a/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs index fb489f73b1..c454439c5c 100644 --- a/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Bindables; using osu.Game.Audio; using osu.Game.Graphics; @@ -11,7 +12,7 @@ namespace osu.Game.Beatmaps.ControlPoints /// /// Note that going forward, this control point type should always be assigned directly to HitObjects. /// - public class SampleControlPoint : ControlPoint + public class SampleControlPoint : ControlPoint, IEquatable { public const string DEFAULT_BANK = "normal"; @@ -44,7 +45,6 @@ namespace osu.Game.Beatmaps.ControlPoints { MinValue = 0, MaxValue = 100, - Default = 100 }; /// @@ -71,7 +71,7 @@ namespace osu.Game.Beatmaps.ControlPoints public virtual HitSampleInfo ApplyTo(HitSampleInfo hitSampleInfo) => hitSampleInfo.With(newBank: hitSampleInfo.Bank ?? SampleBank, newVolume: hitSampleInfo.Volume > 0 ? hitSampleInfo.Volume : SampleVolume); - public override bool IsRedundant(ControlPoint existing) + public override bool IsRedundant(ControlPoint? existing) => existing is SampleControlPoint existingSample && SampleBank == existingSample.SampleBank && SampleVolume == existingSample.SampleVolume; @@ -83,5 +83,16 @@ namespace osu.Game.Beatmaps.ControlPoints base.CopyFrom(other); } + + public override bool Equals(ControlPoint? other) + => other is SampleControlPoint otherSampleControlPoint + && Equals(otherSampleControlPoint); + + public bool Equals(SampleControlPoint? other) + => base.Equals(other) + && SampleBank == other.SampleBank + && SampleVolume == other.SampleVolume; + + public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), SampleBank, SampleVolume); } } diff --git a/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs index 3a7c8b2ec0..4e69486e2d 100644 --- a/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs @@ -1,8 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - +using System; using osu.Framework.Bindables; using osu.Game.Beatmaps.Timing; using osu.Game.Graphics; @@ -10,13 +9,18 @@ using osuTK.Graphics; namespace osu.Game.Beatmaps.ControlPoints { - public class TimingControlPoint : ControlPoint + public class TimingControlPoint : ControlPoint, IEquatable { /// /// The time signature at this control point. /// public readonly Bindable TimeSignatureBindable = new Bindable(TimeSignature.SimpleQuadruple); + /// + /// Whether the first bar line of this control point is ignored. + /// + public readonly BindableBool OmitFirstBarLineBindable = new BindableBool(); + /// /// Default length of a beat in milliseconds. Used whenever there is no beatmap or track playing. /// @@ -31,6 +35,7 @@ namespace osu.Game.Beatmaps.ControlPoints Value = default_beat_length, Disabled = true }, + OmitFirstBarLineBindable = { Disabled = true }, TimeSignatureBindable = { Disabled = true } }; @@ -43,6 +48,15 @@ namespace osu.Game.Beatmaps.ControlPoints set => TimeSignatureBindable.Value = value; } + /// + /// Whether the first bar line of this control point is ignored. + /// + public bool OmitFirstBarLine + { + get => OmitFirstBarLineBindable.Value; + set => OmitFirstBarLineBindable.Value = value; + } + public const double DEFAULT_BEAT_LENGTH = 1000; /// @@ -50,7 +64,6 @@ namespace osu.Game.Beatmaps.ControlPoints /// public readonly BindableDouble BeatLengthBindable = new BindableDouble(DEFAULT_BEAT_LENGTH) { - Default = DEFAULT_BEAT_LENGTH, MinValue = 6, MaxValue = 60000 }; @@ -70,14 +83,27 @@ namespace osu.Game.Beatmaps.ControlPoints public double BPM => 60000 / BeatLength; // Timing points are never redundant as they can change the time signature. - public override bool IsRedundant(ControlPoint existing) => false; + public override bool IsRedundant(ControlPoint? existing) => false; public override void CopyFrom(ControlPoint other) { TimeSignature = ((TimingControlPoint)other).TimeSignature; + OmitFirstBarLine = ((TimingControlPoint)other).OmitFirstBarLine; BeatLength = ((TimingControlPoint)other).BeatLength; base.CopyFrom(other); } + + public override bool Equals(ControlPoint? other) + => other is TimingControlPoint otherTimingControlPoint + && Equals(otherTimingControlPoint); + + public bool Equals(TimingControlPoint? other) + => base.Equals(other) + && TimeSignature.Equals(other.TimeSignature) + && OmitFirstBarLine == other.OmitFirstBarLine + && BeatLength.Equals(other.BeatLength); + + public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), TimeSignature, BeatLength, OmitFirstBarLine); } } diff --git a/osu.Game/Beatmaps/CountdownType.cs b/osu.Game/Beatmaps/CountdownType.cs index 73f85bf701..7fb3de74fb 100644 --- a/osu.Game/Beatmaps/CountdownType.cs +++ b/osu.Game/Beatmaps/CountdownType.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; namespace osu.Game.Beatmaps diff --git a/osu.Game/Beatmaps/DifficultyRating.cs b/osu.Game/Beatmaps/DifficultyRating.cs index f0ee0ad705..478c0e36df 100644 --- a/osu.Game/Beatmaps/DifficultyRating.cs +++ b/osu.Game/Beatmaps/DifficultyRating.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Beatmaps { public enum DifficultyRating diff --git a/osu.Game/Beatmaps/DifficultyRecommender.cs b/osu.Game/Beatmaps/DifficultyRecommender.cs index 93c2fccbc7..ec00756fd9 100644 --- a/osu.Game/Beatmaps/DifficultyRecommender.cs +++ b/osu.Game/Beatmaps/DifficultyRecommender.cs @@ -1,17 +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; using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; -using osu.Game.Extensions; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; using osu.Game.Rulesets; namespace osu.Game.Beatmaps @@ -20,31 +19,20 @@ namespace osu.Game.Beatmaps /// A class which will recommend the most suitable difficulty for the local user from a beatmap set. /// This requires the user to be logged in, as it sources from the user's online profile. /// - public class DifficultyRecommender : Component + public partial class DifficultyRecommender : Component { [Resolved] private IAPIProvider api { get; set; } - [Resolved] - private IRulesetStore rulesets { get; set; } - [Resolved] private Bindable ruleset { get; set; } - /// - /// The user for which the last requests were run. - /// - private int? requestedUserId; - - private readonly Dictionary recommendedDifficultyMapping = new Dictionary(); - - private readonly IBindable apiState = new Bindable(); + private readonly Dictionary recommendedDifficultyMapping = new Dictionary(); [BackgroundDependencyLoader] private void load() { - apiState.BindTo(api.State); - apiState.BindValueChanged(onlineStateChanged, true); + api.LocalUser.BindValueChanged(_ => populateValues(), true); } /// @@ -58,16 +46,16 @@ namespace osu.Game.Beatmaps [CanBeNull] public BeatmapInfo GetRecommendedBeatmap(IEnumerable beatmaps) { - foreach (var r in orderedRulesets) + foreach (string r in orderedRulesets) { if (!recommendedDifficultyMapping.TryGetValue(r, out double recommendation)) continue; - BeatmapInfo beatmapInfo = beatmaps.Where(b => b.Ruleset.Equals(r)).OrderBy(b => + BeatmapInfo beatmapInfo = beatmaps.Where(b => b.Ruleset.ShortName.Equals(r, StringComparison.Ordinal)).MinBy(b => { double difference = b.StarRating - recommendation; return difference >= 0 ? difference * 2 : difference * -1; // prefer easier over harder - }).FirstOrDefault(); + }); if (beatmapInfo != null) return beatmapInfo; @@ -76,55 +64,35 @@ namespace osu.Game.Beatmaps return null; } - private void fetchRecommendedValues() + private void populateValues() { - if (recommendedDifficultyMapping.Count > 0 && api.LocalUser.Value.Id == requestedUserId) + if (api.LocalUser.Value.RulesetsStatistics == null) return; - requestedUserId = api.LocalUser.Value.Id; - - // only query API for built-in rulesets - rulesets.AvailableRulesets.Where(ruleset => ruleset.IsLegacyRuleset()).ForEach(rulesetInfo => + foreach (var kvp in api.LocalUser.Value.RulesetsStatistics) { - var req = new GetUserRequest(api.LocalUser.Value.Id, rulesetInfo); - - req.Success += result => - { - // algorithm taken from https://github.com/ppy/osu-web/blob/e6e2825516449e3d0f3f5e1852c6bdd3428c3437/app/Models/User.php#L1505 - recommendedDifficultyMapping[rulesetInfo] = Math.Pow((double)(result.Statistics.PP ?? 0), 0.4) * 0.195; - }; - - api.Queue(req); - }); + // algorithm taken from https://github.com/ppy/osu-web/blob/e6e2825516449e3d0f3f5e1852c6bdd3428c3437/app/Models/User.php#L1505 + recommendedDifficultyMapping[kvp.Key] = Math.Pow((double)(kvp.Value.PP ?? 0), 0.4) * 0.195; + } } /// /// Rulesets ordered descending by their respective recommended difficulties. /// The currently selected ruleset will always be first. /// - private IEnumerable orderedRulesets + private IEnumerable orderedRulesets { get { if (LoadState < LoadState.Ready || ruleset.Value == null) - return Enumerable.Empty(); + return Enumerable.Empty(); return recommendedDifficultyMapping .OrderByDescending(pair => pair.Value) .Select(pair => pair.Key) - .Where(r => !r.Equals(ruleset.Value)) - .Prepend(ruleset.Value); + .Where(r => !r.Equals(ruleset.Value.ShortName, StringComparison.Ordinal)) + .Prepend(ruleset.Value.ShortName); } } - - private void onlineStateChanged(ValueChangedEvent state) => Schedule(() => - { - switch (state.NewValue) - { - case APIState.Online: - fetchRecommendedValues(); - break; - } - }); } } diff --git a/osu.Game/Beatmaps/Drawables/BeatmapBackgroundSprite.cs b/osu.Game/Beatmaps/Drawables/BeatmapBackgroundSprite.cs index 916df429a4..767504fcb1 100644 --- a/osu.Game/Beatmaps/Drawables/BeatmapBackgroundSprite.cs +++ b/osu.Game/Beatmaps/Drawables/BeatmapBackgroundSprite.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.Graphics.Sprites; namespace osu.Game.Beatmaps.Drawables { - public class BeatmapBackgroundSprite : Sprite + public partial class BeatmapBackgroundSprite : Sprite { private readonly IWorkingBeatmap working; public BeatmapBackgroundSprite(IWorkingBeatmap working) { - if (working == null) - throw new ArgumentNullException(nameof(working)); + ArgumentNullException.ThrowIfNull(working); this.working = working; } diff --git a/osu.Game/Beatmaps/Drawables/BeatmapDownloadButton.cs b/osu.Game/Beatmaps/Drawables/BeatmapDownloadButton.cs index 6ab92a2ba2..cf27023a9c 100644 --- a/osu.Game/Beatmaps/Drawables/BeatmapDownloadButton.cs +++ b/osu.Game/Beatmaps/Drawables/BeatmapDownloadButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -16,7 +18,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Beatmaps.Drawables { - public class BeatmapDownloadButton : CompositeDrawable + public partial class BeatmapDownloadButton : CompositeDrawable { protected bool DownloadEnabled => button.Enabled.Value; diff --git a/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs b/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs index 19026638ba..c353b9e904 100644 --- a/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs +++ b/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs @@ -1,22 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; using osu.Game.Overlays; using osuTK.Graphics; namespace osu.Game.Beatmaps.Drawables { - public class BeatmapSetOnlineStatusPill : CircularContainer + public partial class BeatmapSetOnlineStatusPill : CircularContainer, IHasTooltip { private BeatmapOnlineStatus status; @@ -98,5 +99,19 @@ namespace osu.Game.Beatmaps.Drawables background.Colour = OsuColour.ForBeatmapSetOnlineStatus(Status) ?? colourProvider?.Light1 ?? colours.GreySeaFoamLighter; } + + public LocalisableString TooltipText + { + get + { + switch (Status) + { + case BeatmapOnlineStatus.LocallyModified: + return SongSelectStrings.LocallyModifiedTooltip; + } + + return string.Empty; + } + } } } diff --git a/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs b/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs index 5ebdee0b09..21ab1b78ea 100644 --- a/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs +++ b/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; @@ -19,7 +21,7 @@ using osu.Game.Utils; namespace osu.Game.Beatmaps.Drawables { - public class BundledBeatmapDownloader : CompositeDrawable + public partial class BundledBeatmapDownloader : CompositeDrawable { private readonly bool shouldPostNotifications; @@ -100,7 +102,7 @@ namespace osu.Game.Beatmaps.Drawables // Matches osu-stable, in order to provide new users with roughly the same randomised selection of bundled beatmaps. var random = new LegacyRandom(DateTime.UtcNow.Year * 1000 + (DateTime.UtcNow.DayOfYear / 7)); - downloadableFilenames.AddRange(sourceFilenames.OrderBy(x => random.NextDouble()).Take(limit ?? int.MaxValue)); + downloadableFilenames.AddRange(sourceFilenames.OrderBy(_ => random.NextDouble()).Take(limit ?? int.MaxValue)); } catch { } } @@ -134,7 +136,9 @@ namespace osu.Game.Beatmaps.Drawables private static readonly string[] always_bundled_beatmaps = { // This thing is 40mb, I'm not sure we want it here... - @"1388906 Raphlesia & BilliumMoto - My Love.osz" + @"1388906 Raphlesia & BilliumMoto - My Love.osz", + // Winner of Triangles mapping competition: https://osu.ppy.sh/home/news/2022-10-06-results-triangles + @"1841885 cYsmix - triangles.osz", }; private static readonly string[] bundled_osu = diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs index 08aaa8da42..94b2956b4e 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs @@ -1,22 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; +using osu.Game.Localisation; namespace osu.Game.Beatmaps.Drawables.Cards { - public abstract class BeatmapCard : OsuClickableContainer + public abstract partial class BeatmapCard : OsuClickableContainer, IHasContextMenu { public const float TRANSITION_DURATION = 400; public const float CORNER_RADIUS = 10; @@ -98,5 +99,10 @@ namespace osu.Game.Beatmaps.Drawables.Cards throw new ArgumentOutOfRangeException(nameof(size), size, @"Unsupported card size"); } } + + public MenuItem[] ContextMenuItems => new MenuItem[] + { + new OsuMenuItem(ContextMenuStrings.ViewBeatmap, MenuItemType.Highlighted, Action), + }; } } diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardContent.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardContent.cs index 1aaa72f5f0..7deb5f768c 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardContent.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardContent.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 enable - using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -16,7 +14,7 @@ using osuTK; namespace osu.Game.Beatmaps.Drawables.Cards { - public class BeatmapCardContent : CompositeDrawable + public partial class BeatmapCardContent : CompositeDrawable { public Drawable MainContent { @@ -140,9 +138,18 @@ namespace osu.Game.Beatmaps.Drawables.Cards // This avoids depth issues where a hovered (scaled) card to the right of another card would be beneath the card to the left. this.ScaleTo(Expanded.Value ? 1.03f : 1, 500, Easing.OutQuint); - background.FadeTo(Expanded.Value ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); - dropdownContent.FadeTo(Expanded.Value ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); - borderContainer.FadeTo(Expanded.Value ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); + if (Expanded.Value) + { + background.FadeIn(BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); + dropdownContent.FadeIn(BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); + borderContainer.FadeIn(BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); + } + else + { + background.FadeOut(BeatmapCard.TRANSITION_DURATION / 3f, Easing.OutQuint); + dropdownContent.FadeOut(BeatmapCard.TRANSITION_DURATION / 3f, Easing.OutQuint); + borderContainer.FadeOut(BeatmapCard.TRANSITION_DURATION / 3f, Easing.OutQuint); + } content.TweenEdgeEffectTo(new EdgeEffectParameters { diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardContentBackground.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardContentBackground.cs index 392f5d1bfa..deb56bb281 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardContentBackground.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardContentBackground.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 enable - using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -13,7 +11,7 @@ using osu.Game.Overlays; namespace osu.Game.Beatmaps.Drawables.Cards { - public class BeatmapCardContentBackground : CompositeDrawable + public partial class BeatmapCardContentBackground : CompositeDrawable { public BindableBool Dimmed { get; } = new BindableBool(); diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardDifficultyList.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardDifficultyList.cs index eeb86f4702..84445dc14c 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardDifficultyList.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardDifficultyList.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -14,7 +16,7 @@ using osuTK; namespace osu.Game.Beatmaps.Drawables.Cards { - public class BeatmapCardDifficultyList : CompositeDrawable + public partial class BeatmapCardDifficultyList : CompositeDrawable { public BeatmapCardDifficultyList(IBeatmapSetInfo beatmapSetInfo) { @@ -51,7 +53,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards } } - private class BeatmapCardDifficultyRow : CompositeDrawable + private partial class BeatmapCardDifficultyRow : CompositeDrawable { private readonly IBeatmapInfo beatmapInfo; diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardDownloadProgressBar.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardDownloadProgressBar.cs index ffb4e0c540..3737715a7d 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardDownloadProgressBar.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardDownloadProgressBar.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osu.Game.Overlays; namespace osu.Game.Beatmaps.Drawables.Cards { - public class BeatmapCardDownloadProgressBar : CompositeDrawable + public partial class BeatmapCardDownloadProgressBar : CompositeDrawable { public IBindable State => state; private readonly Bindable state = new Bindable(); diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs index 7826d64296..5c6f0c4ee1 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.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 enable - using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -19,7 +17,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Beatmaps.Drawables.Cards { - public class BeatmapCardExtra : BeatmapCard + public partial class BeatmapCardExtra : BeatmapCard { protected override Drawable IdleContent => idleBottomContent; protected override Drawable DownloadInProgressContent => downloadProgressBar; @@ -83,7 +81,6 @@ namespace osu.Game.Beatmaps.Drawables.Cards FavouriteState = { BindTarget = FavouriteState }, ButtonsCollapsedWidth = CORNER_RADIUS, ButtonsExpandedWidth = 30, - ButtonsPadding = new MarginPadding { Vertical = 35 }, Children = new Drawable[] { new FillFlowContainer diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtraInfoRow.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtraInfoRow.cs index 2d411ad344..3a1b8f7e86 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtraInfoRow.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtraInfoRow.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 enable - using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -12,7 +10,7 @@ using osuTK; namespace osu.Game.Beatmaps.Drawables.Cards { - public class BeatmapCardExtraInfoRow : CompositeDrawable + public partial class BeatmapCardExtraInfoRow : CompositeDrawable { [Resolved(CanBeNull = true)] private BeatmapCardContent? content { get; set; } diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs index c1ba521925..720d892495 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.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 enable - using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -20,7 +18,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Beatmaps.Drawables.Cards { - public class BeatmapCardNormal : BeatmapCard + public partial class BeatmapCardNormal : BeatmapCard { protected override Drawable IdleContent => idleBottomContent; protected override Drawable DownloadInProgressContent => downloadProgressBar; @@ -84,7 +82,6 @@ namespace osu.Game.Beatmaps.Drawables.Cards FavouriteState = { BindTarget = FavouriteState }, ButtonsCollapsedWidth = CORNER_RADIUS, ButtonsExpandedWidth = 30, - ButtonsPadding = new MarginPadding { Vertical = 17.5f }, Children = new Drawable[] { new FillFlowContainer diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardSize.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardSize.cs index 098265506d..1f6538a890 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardSize.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardSize.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Beatmaps.Drawables.Cards { /// diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardThumbnail.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardThumbnail.cs index f11a5916e1..c99d1f0c76 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardThumbnail.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardThumbnail.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -9,13 +11,13 @@ using osu.Game.Beatmaps.Drawables.Cards.Buttons; using osu.Game.Graphics; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; -using osu.Game.Screens.Ranking.Expanded.Accuracy; +using osu.Framework.Graphics.UserInterface; using osuTK; using osuTK.Graphics; namespace osu.Game.Beatmaps.Drawables.Cards { - public class BeatmapCardThumbnail : Container + public partial class BeatmapCardThumbnail : Container { public BindableBool Dimmed { get; } = new BindableBool(); @@ -28,7 +30,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards private readonly UpdateableOnlineBeatmapSetCover cover; private readonly Container foreground; private readonly PlayButton playButton; - private readonly SmoothCircularProgress progress; + private readonly CircularProgress progress; private readonly Container content; protected override Container Content => content; @@ -51,7 +53,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards { RelativeSizeAxes = Axes.Both }, - progress = new SmoothCircularProgress + progress = new CircularProgress { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapSetFavouriteState.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapSetFavouriteState.cs index 82523cc865..8f8a47c199 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapSetFavouriteState.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapSetFavouriteState.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Beatmaps.Drawables.Cards.Buttons; using osu.Game.Beatmaps.Drawables.Cards.Statistics; diff --git a/osu.Game/Beatmaps/Drawables/Cards/Buttons/BeatmapCardIconButton.cs b/osu.Game/Beatmaps/Drawables/Cards/Buttons/BeatmapCardIconButton.cs index e362e3abeb..ee45d56b6e 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Buttons/BeatmapCardIconButton.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Buttons/BeatmapCardIconButton.cs @@ -1,18 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; using osu.Game.Overlays; using osuTK; +using osuTK.Graphics; namespace osu.Game.Beatmaps.Drawables.Cards.Buttons { - public abstract class BeatmapCardIconButton : OsuClickableContainer + public abstract partial class BeatmapCardIconButton : OsuClickableContainer { private Colour4 idleColour; @@ -57,6 +62,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons protected override Container Content => content; private readonly Container content; + private readonly Box hover; protected BeatmapCardIconButton() { @@ -67,19 +73,27 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons { RelativeSizeAxes = Axes.Both, Masking = true, + CornerRadius = BeatmapCard.CORNER_RADIUS, + Scale = new Vector2(0.8f), Origin = Anchor.Centre, Anchor = Anchor.Centre, Children = new Drawable[] { + hover = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.White.Opacity(0.1f), + Blending = BlendingParameters.Additive, + }, Icon = new SpriteIcon { Origin = Anchor.Centre, - Anchor = Anchor.Centre - } + Anchor = Anchor.Centre, + Scale = new Vector2(1.2f), + }, } }); - Size = new Vector2(24); IconSize = 12; } @@ -114,8 +128,9 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons { bool isHovered = IsHovered && Enabled.Value; - content.ScaleTo(isHovered ? 1.2f : 1, 500, Easing.OutQuint); - content.FadeColour(isHovered ? HoverColour : IdleColour, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); + hover.FadeTo(isHovered ? 1f : 0f, 500, Easing.OutQuint); + content.ScaleTo(isHovered ? 1 : 0.8f, 500, Easing.OutQuint); + Icon.FadeColour(isHovered ? HoverColour : IdleColour, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); } } } diff --git a/osu.Game/Beatmaps/Drawables/Cards/Buttons/DownloadButton.cs b/osu.Game/Beatmaps/Drawables/Cards/Buttons/DownloadButton.cs index c94e335e8f..7f23b46150 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Buttons/DownloadButton.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Buttons/DownloadButton.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 enable - using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -17,10 +15,9 @@ using osuTK; namespace osu.Game.Beatmaps.Drawables.Cards.Buttons { - public class DownloadButton : BeatmapCardIconButton + public partial class DownloadButton : BeatmapCardIconButton { - public IBindable State => state; - private readonly Bindable state = new Bindable(); + public Bindable State { get; } = new Bindable(); private readonly APIBeatmapSet beatmapSet; @@ -50,14 +47,19 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons { base.LoadComplete(); preferNoVideo.BindValueChanged(_ => updateState()); - state.BindValueChanged(_ => updateState(), true); + State.BindValueChanged(_ => updateState(), true); FinishTransforms(true); } private void updateState() { - switch (state.Value) + switch (State.Value) { + case DownloadState.Unknown: + Action = null; + TooltipText = string.Empty; + break; + case DownloadState.Downloading: case DownloadState.Importing: Action = null; diff --git a/osu.Game/Beatmaps/Drawables/Cards/Buttons/FavouriteButton.cs b/osu.Game/Beatmaps/Drawables/Cards/Buttons/FavouriteButton.cs index 9fed2fde6f..f698185863 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Buttons/FavouriteButton.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Buttons/FavouriteButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; @@ -13,7 +15,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Beatmaps.Drawables.Cards.Buttons { - public class FavouriteButton : BeatmapCardIconButton, IHasCurrentValue + public partial class FavouriteButton : BeatmapCardIconButton, IHasCurrentValue { private readonly BindableWithCurrent current; @@ -62,7 +64,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons }; favouriteRequest.Failure += e => { - Logger.Error(e, $"Failed to {actionType.ToString().ToLower()} beatmap: {e.Message}"); + Logger.Error(e, $"Failed to {actionType.ToString().ToLowerInvariant()} beatmap: {e.Message}"); Enabled.Value = true; }; diff --git a/osu.Game/Beatmaps/Drawables/Cards/Buttons/GoToBeatmapButton.cs b/osu.Game/Beatmaps/Drawables/Cards/Buttons/GoToBeatmapButton.cs index 9a6a3c01b7..3df94bf233 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Buttons/GoToBeatmapButton.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Buttons/GoToBeatmapButton.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 enable - using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -12,7 +10,7 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Beatmaps.Drawables.Cards.Buttons { - public class GoToBeatmapButton : BeatmapCardIconButton + public partial class GoToBeatmapButton : BeatmapCardIconButton { public IBindable State => state; private readonly Bindable state = new Bindable(); diff --git a/osu.Game/Beatmaps/Drawables/Cards/Buttons/PlayButton.cs b/osu.Game/Beatmaps/Drawables/Cards/Buttons/PlayButton.cs index f7bab26666..f808fd21b7 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Buttons/PlayButton.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Buttons/PlayButton.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 enable - using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -18,7 +16,7 @@ using osuTK; namespace osu.Game.Beatmaps.Drawables.Cards.Buttons { - public class PlayButton : OsuHoverContainer + public partial class PlayButton : OsuHoverContainer { public IBindable Progress => progress; private readonly BindableDouble progress = new BindableDouble(); @@ -43,6 +41,9 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons Anchor = Origin = Anchor.Centre; + // needed for touch input to work when card is not hovered/expanded + AlwaysPresent = true; + Children = new Drawable[] { icon = new SpriteIcon @@ -120,7 +121,10 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons // another async load might have completed before this one. // if so, do not make any changes. if (loadedPreview != previewTrack) + { + loadedPreview.Dispose(); return; + } AddInternal(loadedPreview); toggleLoading(false); diff --git a/osu.Game/Beatmaps/Drawables/Cards/CollapsibleButtonContainer.cs b/osu.Game/Beatmaps/Drawables/Cards/CollapsibleButtonContainer.cs index 3a2cb80a8d..fe2ee8c7cc 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/CollapsibleButtonContainer.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/CollapsibleButtonContainer.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 enable - using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -16,7 +14,7 @@ using osu.Game.Overlays; namespace osu.Game.Beatmaps.Drawables.Cards { - public class CollapsibleButtonContainer : Container + public partial class CollapsibleButtonContainer : Container { public Bindable ShowDetails = new Bindable(); public Bindable FavouriteState = new Bindable(); @@ -50,12 +48,6 @@ namespace osu.Game.Beatmaps.Drawables.Cards } } - public MarginPadding ButtonsPadding - { - get => buttons.Padding; - set => buttons.Padding = value; - } - protected override Container Content => mainContent; private readonly Container background; @@ -88,9 +80,6 @@ namespace osu.Game.Beatmaps.Drawables.Cards RelativeSizeAxes = Axes.Both, Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, - // workaround for masking artifacts at the top & bottom of card, - // which become especially visible on downloaded beatmaps (when the icon area has a lime background). - Padding = new MarginPadding { Vertical = 1 }, Child = new Box { RelativeSizeAxes = Axes.Both, @@ -106,25 +95,34 @@ namespace osu.Game.Beatmaps.Drawables.Cards Child = buttons = new Container { RelativeSizeAxes = Axes.Both, + // Padding of 4 avoids touching the card borders when in the expanded (ie. showing difficulties) state. + // Left override allows the buttons to visually be wider and look better. + Padding = new MarginPadding(4) { Left = 2 }, Children = new BeatmapCardIconButton[] { new FavouriteButton(beatmapSet) { Current = FavouriteState, Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Both, + Height = 0.48f, }, new DownloadButton(beatmapSet) { Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, - State = { BindTarget = downloadTracker.State } + State = { BindTarget = downloadTracker.State }, + RelativeSizeAxes = Axes.Both, + Height = 0.48f, }, new GoToBeatmapButton(beatmapSet) { Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, - State = { BindTarget = downloadTracker.State } + State = { BindTarget = downloadTracker.State }, + RelativeSizeAxes = Axes.Both, + Height = 0.48f, } } } diff --git a/osu.Game/Beatmaps/Drawables/Cards/ExpandedContentScrollContainer.cs b/osu.Game/Beatmaps/Drawables/Cards/ExpandedContentScrollContainer.cs index adde72d1e8..9a2a37a09a 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/ExpandedContentScrollContainer.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/ExpandedContentScrollContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics; using osu.Framework.Input.Events; @@ -9,7 +11,7 @@ using osu.Game.Graphics.Containers; namespace osu.Game.Beatmaps.Drawables.Cards { - public class ExpandedContentScrollContainer : OsuScrollContainer + public partial class ExpandedContentScrollContainer : OsuScrollContainer { public const float HEIGHT = 200; @@ -57,7 +59,9 @@ namespace osu.Game.Beatmaps.Drawables.Cards return base.OnScroll(e); } - private class ExpandedContentScrollbar : OsuScrollbar + protected override bool OnClick(ClickEvent e) => true; + + private partial class ExpandedContentScrollbar : OsuScrollbar { public ExpandedContentScrollbar(Direction scrollDir) : base(scrollDir) diff --git a/osu.Game/Beatmaps/Drawables/Cards/HoverHandlingContainer.cs b/osu.Game/Beatmaps/Drawables/Cards/HoverHandlingContainer.cs index fe37616755..91cd498b81 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/HoverHandlingContainer.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/HoverHandlingContainer.cs @@ -1,15 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - using System; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; namespace osu.Game.Beatmaps.Drawables.Cards { - public class HoverHandlingContainer : Container + public partial class HoverHandlingContainer : Container { public Func? Hovered { get; set; } public Action? Unhovered { get; set; } diff --git a/osu.Game/Beatmaps/Drawables/Cards/IconPill.cs b/osu.Game/Beatmaps/Drawables/Cards/IconPill.cs index 1b2c5d3ffc..3cabbba98d 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/IconPill.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/IconPill.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -12,7 +14,7 @@ using osuTK.Graphics; namespace osu.Game.Beatmaps.Drawables.Cards { - public abstract class IconPill : CircularContainer, IHasTooltip + public abstract partial class IconPill : CircularContainer, IHasTooltip { public Vector2 IconSize { diff --git a/osu.Game/Beatmaps/Drawables/Cards/Statistics/BeatmapCardDateStatistic.cs b/osu.Game/Beatmaps/Drawables/Cards/Statistics/BeatmapCardDateStatistic.cs index 9dbe5a9114..2948e89e60 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Statistics/BeatmapCardDateStatistic.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Statistics/BeatmapCardDateStatistic.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 enable - using System; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics.Cursor; @@ -11,7 +9,7 @@ using osu.Game.Graphics; namespace osu.Game.Beatmaps.Drawables.Cards.Statistics { - public class BeatmapCardDateStatistic : BeatmapCardStatistic + public partial class BeatmapCardDateStatistic : BeatmapCardStatistic { private readonly DateTimeOffset dateTime; diff --git a/osu.Game/Beatmaps/Drawables/Cards/Statistics/BeatmapCardStatistic.cs b/osu.Game/Beatmaps/Drawables/Cards/Statistics/BeatmapCardStatistic.cs index f46926284f..10de2b9128 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Statistics/BeatmapCardStatistic.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Statistics/BeatmapCardStatistic.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -17,7 +19,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Statistics /// /// A single statistic shown on a beatmap card. /// - public abstract class BeatmapCardStatistic : CompositeDrawable, IHasTooltip, IHasCustomTooltip + public abstract partial class BeatmapCardStatistic : CompositeDrawable, IHasTooltip, IHasCustomTooltip { protected IconUsage Icon { diff --git a/osu.Game/Beatmaps/Drawables/Cards/Statistics/FavouritesStatistic.cs b/osu.Game/Beatmaps/Drawables/Cards/Statistics/FavouritesStatistic.cs index d924fd938b..439e6acd22 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Statistics/FavouritesStatistic.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Statistics/FavouritesStatistic.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Humanizer; using osu.Framework.Bindables; using osu.Framework.Extensions.LocalisationExtensions; @@ -13,7 +15,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Statistics /// /// Shows the number of favourites that a beatmap set has received. /// - public class FavouritesStatistic : BeatmapCardStatistic, IHasCurrentValue + public partial class FavouritesStatistic : BeatmapCardStatistic, IHasCurrentValue { private readonly BindableWithCurrent current; diff --git a/osu.Game/Beatmaps/Drawables/Cards/Statistics/HypesStatistic.cs b/osu.Game/Beatmaps/Drawables/Cards/Statistics/HypesStatistic.cs index 521d1a5f21..840763b58e 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Statistics/HypesStatistic.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Statistics/HypesStatistic.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 enable - using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics.Sprites; using osu.Game.Resources.Localisation.Web; @@ -12,7 +10,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Statistics /// /// Shows the number of current hypes that a map has received, as well as the number of hypes required for nomination. /// - public class HypesStatistic : BeatmapCardStatistic + public partial class HypesStatistic : BeatmapCardStatistic { private HypesStatistic(BeatmapSetHypeStatus hypeStatus) { diff --git a/osu.Game/Beatmaps/Drawables/Cards/Statistics/NominationsStatistic.cs b/osu.Game/Beatmaps/Drawables/Cards/Statistics/NominationsStatistic.cs index 23bd6ef0a9..083f1a353b 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Statistics/NominationsStatistic.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Statistics/NominationsStatistic.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 enable - using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics.Sprites; using osu.Game.Resources.Localisation.Web; @@ -12,7 +10,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Statistics /// /// Shows the number of current nominations that a map has received, as well as the number of nominations required for qualification. /// - public class NominationsStatistic : BeatmapCardStatistic + public partial class NominationsStatistic : BeatmapCardStatistic { private NominationsStatistic(BeatmapSetNominationStatus nominationStatus) { diff --git a/osu.Game/Beatmaps/Drawables/Cards/Statistics/PlayCountStatistic.cs b/osu.Game/Beatmaps/Drawables/Cards/Statistics/PlayCountStatistic.cs index d8f0c36bd9..45ab6ddb40 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Statistics/PlayCountStatistic.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Statistics/PlayCountStatistic.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Humanizer; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics.Sprites; @@ -11,7 +13,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Statistics /// /// Shows the number of times the given beatmap set has been played. /// - public class PlayCountStatistic : BeatmapCardStatistic + public partial class PlayCountStatistic : BeatmapCardStatistic { public PlayCountStatistic(IBeatmapSetOnlineInfo onlineInfo) { diff --git a/osu.Game/Beatmaps/Drawables/Cards/StoryboardIconPill.cs b/osu.Game/Beatmaps/Drawables/Cards/StoryboardIconPill.cs index 2ebf9107f5..6de16da2b1 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/StoryboardIconPill.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/StoryboardIconPill.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Beatmaps.Drawables.Cards { - public class StoryboardIconPill : IconPill + public partial class StoryboardIconPill : IconPill { public StoryboardIconPill() : base(FontAwesome.Solid.Image) diff --git a/osu.Game/Beatmaps/Drawables/Cards/VideoIconPill.cs b/osu.Game/Beatmaps/Drawables/Cards/VideoIconPill.cs index b81e18b0dd..63b5e95b12 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/VideoIconPill.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/VideoIconPill.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Beatmaps.Drawables.Cards { - public class VideoIconPill : IconPill + public partial class VideoIconPill : IconPill { public VideoIconPill() : base(FontAwesome.Solid.Film) diff --git a/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs b/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs index 82be0559a7..1665ec52fa 100644 --- a/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs +++ b/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs @@ -1,10 +1,6 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; -using System.Threading; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -14,19 +10,17 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; using osuTK; using osuTK.Graphics; namespace osu.Game.Beatmaps.Drawables { - public class DifficultyIcon : CompositeDrawable, IHasCustomTooltip + public partial class DifficultyIcon : CompositeDrawable, IHasCustomTooltip, IHasCurrentValue { - private readonly Container iconContainer; - /// /// Size of this difficulty icon. /// @@ -36,57 +30,53 @@ namespace osu.Game.Beatmaps.Drawables set => iconContainer.Size = value; } - [NotNull] - private readonly IBeatmapInfo beatmapInfo; + /// + /// Whether to display a tooltip on hover. Only works if a beatmap was provided at construction time. + /// + public bool ShowTooltip { get; set; } = true; + + private readonly IBeatmapInfo? beatmap; - [CanBeNull] private readonly IRulesetInfo ruleset; - [CanBeNull] - private readonly IReadOnlyList mods; + private Drawable background = null!; - private readonly bool shouldShowTooltip; + private readonly Container iconContainer; - private readonly bool performBackgroundDifficultyLookup; + private readonly BindableWithCurrent difficulty = new BindableWithCurrent(); - private readonly Bindable difficultyBindable = new Bindable(); - - private Drawable background; - - /// - /// Creates a new with a given and combination. - /// - /// The beatmap to show the difficulty of. - /// The ruleset to show the difficulty with. - /// The mods to show the difficulty with. - /// Whether to display a tooltip when hovered. - /// Whether to perform difficulty lookup (including calculation if necessary). - public DifficultyIcon([NotNull] IBeatmapInfo beatmapInfo, [CanBeNull] IRulesetInfo ruleset, [CanBeNull] IReadOnlyList mods, bool shouldShowTooltip = true, bool performBackgroundDifficultyLookup = true) - : this(beatmapInfo, shouldShowTooltip, performBackgroundDifficultyLookup) + public virtual Bindable Current { - this.ruleset = ruleset ?? beatmapInfo.Ruleset; - this.mods = mods ?? Array.Empty(); - } - - /// - /// Creates a new that follows the currently-selected ruleset and mods. - /// - /// The beatmap to show the difficulty of. - /// Whether to display a tooltip when hovered. - /// Whether to perform difficulty lookup (including calculation if necessary). - public DifficultyIcon([NotNull] IBeatmapInfo beatmapInfo, bool shouldShowTooltip = true, bool performBackgroundDifficultyLookup = true) - { - this.beatmapInfo = beatmapInfo ?? throw new ArgumentNullException(nameof(beatmapInfo)); - this.shouldShowTooltip = shouldShowTooltip; - this.performBackgroundDifficultyLookup = performBackgroundDifficultyLookup; - - AutoSizeAxes = Axes.Both; - - InternalChild = iconContainer = new Container { Size = new Vector2(20f) }; + get => difficulty.Current; + set => difficulty.Current = value; } [Resolved] - private IRulesetStore rulesets { get; set; } + private IRulesetStore rulesets { get; set; } = null!; + + /// + /// Creates a new . Will use provided beatmap's for initial value. + /// + /// The beatmap to be displayed in the tooltip, and to be used for the initial star rating value. + /// An optional ruleset to be used for the icon display, in place of the beatmap's ruleset. + public DifficultyIcon(IBeatmapInfo beatmap, IRulesetInfo? ruleset = null) + : this(ruleset ?? beatmap.Ruleset) + { + this.beatmap = beatmap; + Current.Value = new StarDifficulty(beatmap.StarRating, 0); + } + + /// + /// Creates a new without an associated beatmap. + /// + /// The ruleset to be used for the icon display. + public DifficultyIcon(IRulesetInfo ruleset) + { + this.ruleset = ruleset; + + AutoSizeAxes = Axes.Both; + InternalChild = iconContainer = new Container { Size = new Vector2(20f) }; + } [BackgroundDependencyLoader] private void load(OsuColour colours) @@ -109,7 +99,6 @@ namespace osu.Game.Beatmaps.Drawables Child = background = new Box { RelativeSizeAxes = Axes.Both, - Colour = colours.ForStarDifficulty(beatmapInfo.StarRating) // Default value that will be re-populated once difficulty calculation completes }, }, new ConstrainedIconContainer @@ -122,17 +111,12 @@ namespace osu.Game.Beatmaps.Drawables }, }; - if (performBackgroundDifficultyLookup) - iconContainer.Add(new DelayedLoadUnloadWrapper(() => new DifficultyRetriever(beatmapInfo, ruleset, mods) { StarDifficulty = { BindTarget = difficultyBindable } }, 0)); - else - difficultyBindable.Value = new StarDifficulty(beatmapInfo.StarRating, 0); - - difficultyBindable.BindValueChanged(difficulty => background.Colour = colours.ForStarDifficulty(difficulty.NewValue.Stars)); + Current.BindValueChanged(difficulty => background.Colour = colours.ForStarDifficulty(difficulty.NewValue.Stars), true); } private Drawable getRulesetIcon() { - int? onlineID = (ruleset ?? beatmapInfo.Ruleset).OnlineID; + int? onlineID = ruleset.OnlineID; if (onlineID >= 0 && rulesets.GetRuleset(onlineID.Value)?.CreateInstance() is Ruleset rulesetInstance) return rulesetInstance.CreateIcon(); @@ -140,51 +124,10 @@ namespace osu.Game.Beatmaps.Drawables return new SpriteIcon { Icon = FontAwesome.Regular.QuestionCircle }; } - ITooltip IHasCustomTooltip.GetCustomTooltip() => new DifficultyIconTooltip(); + ITooltip IHasCustomTooltip. + GetCustomTooltip() => new DifficultyIconTooltip(); - DifficultyIconTooltipContent IHasCustomTooltip.TooltipContent => shouldShowTooltip ? new DifficultyIconTooltipContent(beatmapInfo, difficultyBindable) : null; - - private class DifficultyRetriever : Component - { - public readonly Bindable StarDifficulty = new Bindable(); - - private readonly IBeatmapInfo beatmapInfo; - private readonly IRulesetInfo ruleset; - private readonly IReadOnlyList mods; - - private CancellationTokenSource difficultyCancellation; - - [Resolved] - private BeatmapDifficultyCache difficultyCache { get; set; } - - public DifficultyRetriever(IBeatmapInfo beatmapInfo, IRulesetInfo ruleset, IReadOnlyList mods) - { - this.beatmapInfo = beatmapInfo; - this.ruleset = ruleset; - this.mods = mods; - } - - private IBindable localStarDifficulty; - - [BackgroundDependencyLoader] - private void load() - { - difficultyCancellation = new CancellationTokenSource(); - localStarDifficulty = ruleset != null - ? difficultyCache.GetBindableDifficulty(beatmapInfo, ruleset, mods, difficultyCancellation.Token) - : difficultyCache.GetBindableDifficulty(beatmapInfo, difficultyCancellation.Token); - localStarDifficulty.BindValueChanged(d => - { - if (d.NewValue is StarDifficulty diff) - StarDifficulty.Value = diff; - }); - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - difficultyCancellation?.Cancel(); - } - } + DifficultyIconTooltipContent IHasCustomTooltip. + TooltipContent => (ShowTooltip && beatmap != null ? new DifficultyIconTooltipContent(beatmap, Current) : null)!; } } diff --git a/osu.Game/Beatmaps/Drawables/DifficultyIconTooltip.cs b/osu.Game/Beatmaps/Drawables/DifficultyIconTooltip.cs index 5479644772..3fa24bcc3e 100644 --- a/osu.Game/Beatmaps/Drawables/DifficultyIconTooltip.cs +++ b/osu.Game/Beatmaps/Drawables/DifficultyIconTooltip.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -13,7 +15,7 @@ using osuTK; namespace osu.Game.Beatmaps.Drawables { - internal class DifficultyIconTooltip : VisibilityContainer, ITooltip + internal partial class DifficultyIconTooltip : VisibilityContainer, ITooltip { private OsuSpriteText difficultyName; private StarRatingDisplay starRating; diff --git a/osu.Game/Beatmaps/Drawables/DifficultySpectrumDisplay.cs b/osu.Game/Beatmaps/Drawables/DifficultySpectrumDisplay.cs index 5b467d67e2..efce0f80f1 100644 --- a/osu.Game/Beatmaps/Drawables/DifficultySpectrumDisplay.cs +++ b/osu.Game/Beatmaps/Drawables/DifficultySpectrumDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -16,7 +18,7 @@ using osuTK; namespace osu.Game.Beatmaps.Drawables { - public class DifficultySpectrumDisplay : CompositeDrawable + public partial class DifficultySpectrumDisplay : CompositeDrawable { private Vector2 dotSize = new Vector2(4, 8); @@ -81,7 +83,7 @@ namespace osu.Game.Beatmaps.Drawables } } - private class RulesetDifficultyGroup : FillFlowContainer + private partial class RulesetDifficultyGroup : FillFlowContainer { private readonly int rulesetId; private readonly IEnumerable beatmapInfos; @@ -141,7 +143,7 @@ namespace osu.Game.Beatmaps.Drawables } } - private class DifficultyDot : CircularContainer + private partial class DifficultyDot : CircularContainer { private readonly double starDifficulty; diff --git a/osu.Game/Beatmaps/Drawables/DownloadProgressBar.cs b/osu.Game/Beatmaps/Drawables/DownloadProgressBar.cs index ad0ff876e8..9877b628db 100644 --- a/osu.Game/Beatmaps/Drawables/DownloadProgressBar.cs +++ b/osu.Game/Beatmaps/Drawables/DownloadProgressBar.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osuTK.Graphics; namespace osu.Game.Beatmaps.Drawables { - public class DownloadProgressBar : CompositeDrawable + public partial class DownloadProgressBar : CompositeDrawable { private readonly ProgressBar progressBar; private readonly BeatmapDownloadTracker downloadTracker; diff --git a/osu.Game/Beatmaps/Drawables/GroupedDifficultyIcon.cs b/osu.Game/Beatmaps/Drawables/GroupedDifficultyIcon.cs deleted file mode 100644 index 799a02579e..0000000000 --- a/osu.Game/Beatmaps/Drawables/GroupedDifficultyIcon.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Graphics; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Rulesets; -using osuTK.Graphics; - -namespace osu.Game.Beatmaps.Drawables -{ - /// - /// A difficulty icon that contains a counter on the right-side of it. - /// - /// - /// Used in cases when there are too many difficulty icons to show. - /// - public class GroupedDifficultyIcon : DifficultyIcon - { - public GroupedDifficultyIcon(IEnumerable beatmaps, IRulesetInfo ruleset, Color4 counterColour) - : base(beatmaps.OrderBy(b => b.StarRating).Last(), ruleset, null, false) - { - AddInternal(new OsuSpriteText - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Padding = new MarginPadding { Left = Size.X }, - Margin = new MarginPadding { Left = 2, Right = 5 }, - Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold), - Text = beatmaps.Count().ToString(), - Colour = counterColour, - }); - } - } -} diff --git a/osu.Game/Beatmaps/Drawables/OnlineBeatmapSetCover.cs b/osu.Game/Beatmaps/Drawables/OnlineBeatmapSetCover.cs index 0b19c27022..fc7c14e734 100644 --- a/osu.Game/Beatmaps/Drawables/OnlineBeatmapSetCover.cs +++ b/osu.Game/Beatmaps/Drawables/OnlineBeatmapSetCover.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; @@ -9,15 +11,14 @@ using osu.Framework.Graphics.Textures; namespace osu.Game.Beatmaps.Drawables { [LongRunningLoad] - public class OnlineBeatmapSetCover : Sprite + public partial class OnlineBeatmapSetCover : Sprite { private readonly IBeatmapSetOnlineInfo set; private readonly BeatmapSetCoverType type; public OnlineBeatmapSetCover(IBeatmapSetOnlineInfo set, BeatmapSetCoverType type = BeatmapSetCoverType.Cover) { - if (set == null) - throw new ArgumentNullException(nameof(set)); + ArgumentNullException.ThrowIfNull(set); this.set = set; this.type = type; diff --git a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs index dde7680989..36fff1dc3c 100644 --- a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs +++ b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -21,7 +23,7 @@ namespace osu.Game.Beatmaps.Drawables /// /// A pill that displays the star rating of a beatmap. /// - public class StarRatingDisplay : CompositeDrawable, IHasCurrentValue + public partial class StarRatingDisplay : CompositeDrawable, IHasCurrentValue { private readonly bool animated; private readonly Box background; @@ -149,7 +151,7 @@ namespace osu.Game.Beatmaps.Drawables displayedStars.BindValueChanged(s => { - starsText.Text = s.NewValue.ToLocalisableString("0.00"); + starsText.Text = s.NewValue < 0 ? "-" : s.NewValue.ToLocalisableString("0.00"); background.Colour = colours.ForStarDifficulty(s.NewValue); diff --git a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs index 636c568bd0..2cd9785048 100644 --- a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs +++ b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -12,7 +14,7 @@ namespace osu.Game.Beatmaps.Drawables /// /// Display a beatmap background from a local source, but fallback to online source if not available. /// - public class UpdateableBeatmapBackgroundSprite : ModelBackedDrawable + public partial class UpdateableBeatmapBackgroundSprite : ModelBackedDrawable { public readonly Bindable Beatmap = new Bindable(); diff --git a/osu.Game/Beatmaps/Drawables/UpdateableOnlineBeatmapSetCover.cs b/osu.Game/Beatmaps/Drawables/UpdateableOnlineBeatmapSetCover.cs index 4a6a1b888e..93b0dd5c15 100644 --- a/osu.Game/Beatmaps/Drawables/UpdateableOnlineBeatmapSetCover.cs +++ b/osu.Game/Beatmaps/Drawables/UpdateableOnlineBeatmapSetCover.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -9,7 +11,7 @@ using osu.Game.Graphics; namespace osu.Game.Beatmaps.Drawables { - public class UpdateableOnlineBeatmapSetCover : ModelBackedDrawable + public partial class UpdateableOnlineBeatmapSetCover : ModelBackedDrawable { private readonly BeatmapSetCoverType coverType; diff --git a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs index 6e879d09d5..0b390a2ab5 100644 --- a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs +++ b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.IO; @@ -42,6 +44,10 @@ namespace osu.Game.Beatmaps }, audio) { this.textures = textures; + + // We are guaranteed to have a virtual track. + // To ease usability, ensure the track is available from point of construction. + LoadTrack(); } protected override IBeatmap GetBeatmap() => new Beatmap(); diff --git a/osu.Game/Beatmaps/EFBeatmapDifficulty.cs b/osu.Game/Beatmaps/EFBeatmapDifficulty.cs deleted file mode 100644 index 38371d3b38..0000000000 --- a/osu.Game/Beatmaps/EFBeatmapDifficulty.cs +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.ComponentModel.DataAnnotations.Schema; -using osu.Game.Database; - -namespace osu.Game.Beatmaps -{ - [Table(@"BeatmapDifficulty")] - public class EFBeatmapDifficulty : IHasPrimaryKey, IBeatmapDifficultyInfo - { - /// - /// The default value used for all difficulty settings except and . - /// - public const float DEFAULT_DIFFICULTY = 5; - - public int ID { get; set; } - - public bool IsManaged => ID > 0; - - public float DrainRate { get; set; } = DEFAULT_DIFFICULTY; - public float CircleSize { get; set; } = DEFAULT_DIFFICULTY; - public float OverallDifficulty { get; set; } = DEFAULT_DIFFICULTY; - - private float? approachRate; - - public EFBeatmapDifficulty() - { - } - - public EFBeatmapDifficulty(IBeatmapDifficultyInfo source) - { - CopyFrom(source); - } - - public float ApproachRate - { - get => approachRate ?? OverallDifficulty; - set => approachRate = value; - } - - public double SliderMultiplier { get; set; } = 1; - public double SliderTickRate { get; set; } = 1; - - /// - /// Returns a shallow-clone of this . - /// - public EFBeatmapDifficulty Clone() - { - var diff = (EFBeatmapDifficulty)Activator.CreateInstance(GetType()); - CopyTo(diff); - return diff; - } - - public virtual void CopyFrom(IBeatmapDifficultyInfo other) - { - ApproachRate = other.ApproachRate; - DrainRate = other.DrainRate; - CircleSize = other.CircleSize; - OverallDifficulty = other.OverallDifficulty; - - SliderMultiplier = other.SliderMultiplier; - SliderTickRate = other.SliderTickRate; - } - - public virtual void CopyTo(EFBeatmapDifficulty other) - { - other.ApproachRate = ApproachRate; - other.DrainRate = DrainRate; - other.CircleSize = CircleSize; - other.OverallDifficulty = OverallDifficulty; - - other.SliderMultiplier = SliderMultiplier; - other.SliderTickRate = SliderTickRate; - } - } -} diff --git a/osu.Game/Beatmaps/EFBeatmapInfo.cs b/osu.Game/Beatmaps/EFBeatmapInfo.cs deleted file mode 100644 index 740adfd1c7..0000000000 --- a/osu.Game/Beatmaps/EFBeatmapInfo.cs +++ /dev/null @@ -1,181 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; -using Newtonsoft.Json; -using osu.Framework.Testing; -using osu.Game.Database; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Rulesets; -using osu.Game.Scoring; - -namespace osu.Game.Beatmaps -{ - [ExcludeFromDynamicCompile] - [Serializable] - [Table(@"BeatmapInfo")] - public class EFBeatmapInfo : IEquatable, IHasPrimaryKey, IBeatmapInfo - { - public int ID { get; set; } - - public bool IsManaged => ID > 0; - - public int BeatmapVersion; - - private int? onlineID; - - [JsonProperty("id")] - [Column("OnlineBeatmapID")] - public int? OnlineID - { - get => onlineID; - set => onlineID = value > 0 ? value : null; - } - - [JsonIgnore] - public int BeatmapSetInfoID { get; set; } - - public BeatmapOnlineStatus Status { get; set; } = BeatmapOnlineStatus.None; - - [Required] - public EFBeatmapSetInfo BeatmapSetInfo { get; set; } - - public EFBeatmapMetadata Metadata { get; set; } - - [JsonIgnore] - public int BaseDifficultyID { get; set; } - - public EFBeatmapDifficulty BaseDifficulty { get; set; } - - [NotMapped] - public APIBeatmap OnlineInfo { get; set; } - - /// - /// The playable length in milliseconds of this beatmap. - /// - public double Length { get; set; } - - /// - /// The most common BPM of this beatmap. - /// - public double BPM { get; set; } - - public string Path { get; set; } - - [JsonProperty("file_sha2")] - public string Hash { get; set; } - - [JsonIgnore] - public bool Hidden { get; set; } - - /// - /// MD5 is kept for legacy support (matching against replays, osu-web-10 etc.). - /// - [JsonProperty("file_md5")] - public string MD5Hash { get; set; } - - // General - public double AudioLeadIn { get; set; } - public float StackLeniency { get; set; } = 0.7f; - public bool SpecialStyle { get; set; } - - [Column("RulesetID")] - public int RulesetInfoID { get; set; } - - public EFRulesetInfo RulesetInfo { get; set; } - - public bool LetterboxInBreaks { get; set; } - public bool WidescreenStoryboard { get; set; } - public bool EpilepsyWarning { get; set; } - - /// - /// Whether or not sound samples should change rate when playing with speed-changing mods. - /// TODO: only read/write supported for now, requires implementation in gameplay. - /// - public bool SamplesMatchPlaybackRate { get; set; } - - public CountdownType Countdown { get; set; } = CountdownType.Normal; - - /// - /// The number of beats to move the countdown backwards (compared to its default location). - /// - public int CountdownOffset { get; set; } - - [NotMapped] - public int[] Bookmarks { get; set; } = Array.Empty(); - - public double DistanceSpacing { get; set; } - public int BeatDivisor { get; set; } - public int GridSize { get; set; } - public double TimelineZoom { get; set; } - - // Metadata - [Column("Version")] - public string DifficultyName { get; set; } - - [JsonProperty("difficulty_rating")] - [Column("StarDifficulty")] - public double StarRating { get; set; } - - /// - /// Currently only populated for beatmap deletion. Use to query scores. - /// - public List Scores { get; set; } - - [JsonIgnore] - public DifficultyRating DifficultyRating => BeatmapDifficultyCache.GetDifficultyRating(StarRating); - - public override string ToString() => this.GetDisplayTitle(); - - public bool Equals(EFBeatmapInfo other) - { - if (ReferenceEquals(this, other)) return true; - if (other == null) return false; - - if (ID != 0 && other.ID != 0) - return ID == other.ID; - - return false; - } - - public bool Equals(IBeatmapInfo other) => other is EFBeatmapInfo b && Equals(b); - - public bool AudioEquals(EFBeatmapInfo other) => other != null && BeatmapSetInfo != null && other.BeatmapSetInfo != null && - BeatmapSetInfo.Hash == other.BeatmapSetInfo.Hash && - (Metadata ?? BeatmapSetInfo.Metadata).AudioFile == (other.Metadata ?? other.BeatmapSetInfo.Metadata).AudioFile; - - public bool BackgroundEquals(EFBeatmapInfo other) => other != null && BeatmapSetInfo != null && other.BeatmapSetInfo != null && - BeatmapSetInfo.Hash == other.BeatmapSetInfo.Hash && - (Metadata ?? BeatmapSetInfo.Metadata).BackgroundFile == (other.Metadata ?? other.BeatmapSetInfo.Metadata).BackgroundFile; - - /// - /// Returns a shallow-clone of this . - /// - public EFBeatmapInfo Clone() => (EFBeatmapInfo)MemberwiseClone(); - - #region Implementation of IHasOnlineID - - int IHasOnlineID.OnlineID => OnlineID ?? -1; - - #endregion - - #region Implementation of IBeatmapInfo - - [JsonIgnore] - IBeatmapMetadataInfo IBeatmapInfo.Metadata => Metadata ?? BeatmapSetInfo?.Metadata ?? new EFBeatmapMetadata(); - - [JsonIgnore] - IBeatmapDifficultyInfo IBeatmapInfo.Difficulty => BaseDifficulty; - - [JsonIgnore] - IBeatmapSetInfo IBeatmapInfo.BeatmapSet => BeatmapSetInfo; - - [JsonIgnore] - IRulesetInfo IBeatmapInfo.Ruleset => RulesetInfo; - - #endregion - } -} diff --git a/osu.Game/Beatmaps/EFBeatmapMetadata.cs b/osu.Game/Beatmaps/EFBeatmapMetadata.cs deleted file mode 100644 index 7c27863a7f..0000000000 --- a/osu.Game/Beatmaps/EFBeatmapMetadata.cs +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations.Schema; -using Newtonsoft.Json; -using osu.Framework.Testing; -using osu.Game.Database; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Users; - -#nullable enable - -namespace osu.Game.Beatmaps -{ - [ExcludeFromDynamicCompile] - [Serializable] - [Table(@"BeatmapMetadata")] - public class EFBeatmapMetadata : IEquatable, IHasPrimaryKey, IBeatmapMetadataInfo - { - public int ID { get; set; } - - public bool IsManaged => ID > 0; - - public string Title { get; set; } = string.Empty; - - [JsonProperty("title_unicode")] - public string TitleUnicode { get; set; } = string.Empty; - - public string Artist { get; set; } = string.Empty; - - [JsonProperty("artist_unicode")] - public string ArtistUnicode { get; set; } = string.Empty; - - [JsonIgnore] - public List Beatmaps { get; set; } = new List(); - - [JsonIgnore] - public List BeatmapSets { get; set; } = new List(); - - /// - /// The author of the beatmaps in this set. - /// - [JsonIgnore] - public APIUser Author = new APIUser(); - - /// - /// Helper property to deserialize a username to . - /// - [JsonProperty(@"user_id")] - [Column("AuthorID")] - public int AuthorID - { - get => Author.Id; // This should not be used, but is required to make EF work correctly. - set => Author.Id = value; - } - - /// - /// Helper property to deserialize a username to . - /// - [JsonProperty(@"creator")] - [Column("Author")] - public string AuthorString - { - get => Author.Username; // This should not be used, but is required to make EF work correctly. - set => Author.Username = value; - } - - public string Source { get; set; } = string.Empty; - - [JsonProperty(@"tags")] - public string Tags { get; set; } = string.Empty; - - /// - /// The time in milliseconds to begin playing the track for preview purposes. - /// If -1, the track should begin playing at 40% of its length. - /// - public int PreviewTime { get; set; } = -1; - - public string AudioFile { get; set; } = string.Empty; - - public string BackgroundFile { get; set; } = string.Empty; - - public bool Equals(EFBeatmapMetadata other) => ((IBeatmapMetadataInfo)this).Equals(other); - - public override string ToString() => this.GetDisplayTitle(); - - IUser IBeatmapMetadataInfo.Author => Author; - } -} diff --git a/osu.Game/Beatmaps/EFBeatmapSetInfo.cs b/osu.Game/Beatmaps/EFBeatmapSetInfo.cs deleted file mode 100644 index 12235abce0..0000000000 --- a/osu.Game/Beatmaps/EFBeatmapSetInfo.cs +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations.Schema; -using System.Linq; -using JetBrains.Annotations; -using Newtonsoft.Json; -using osu.Framework.Testing; -using osu.Game.Database; -using osu.Game.Extensions; - -namespace osu.Game.Beatmaps -{ - [ExcludeFromDynamicCompile] - [Serializable] - [Table(@"BeatmapSetInfo")] - public class EFBeatmapSetInfo : IHasPrimaryKey, IHasFiles, ISoftDelete, IEquatable, IBeatmapSetInfo - { - public int ID { get; set; } - - public bool IsManaged => ID > 0; - - private int? onlineID; - - [Column("OnlineBeatmapSetID")] - public int? OnlineID - { - get => onlineID; - set => onlineID = value > 0 ? value : null; - } - - public DateTimeOffset DateAdded { get; set; } - - public EFBeatmapMetadata Metadata { get; set; } - - [NotNull] - public List Beatmaps { get; } = new List(); - - public BeatmapOnlineStatus Status { get; set; } = BeatmapOnlineStatus.None; - - public List Files { get; } = new List(); - - /// - /// The maximum star difficulty of all beatmaps in this set. - /// - [JsonIgnore] - public double MaxStarDifficulty => Beatmaps.Count == 0 ? 0 : Beatmaps.Max(b => b.StarRating); - - /// - /// The maximum playable length in milliseconds of all beatmaps in this set. - /// - [JsonIgnore] - public double MaxLength => Beatmaps.Count == 0 ? 0 : Beatmaps.Max(b => b.Length); - - /// - /// The maximum BPM of all beatmaps in this set. - /// - [JsonIgnore] - public double MaxBPM => Beatmaps.Count == 0 ? 0 : Beatmaps.Max(b => b.BPM); - - [NotMapped] - public bool DeletePending { get; set; } - - public string Hash { get; set; } - - /// - /// Returns the storage path for the file in this beatmapset with the given filename, if any exists, otherwise null. - /// The path returned is relative to the user file storage. - /// - /// The name of the file to get the storage path of. - public string GetPathForFile(string filename) => Files.SingleOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.FileInfo.GetStoragePath(); - - public override string ToString() => Metadata?.ToString() ?? base.ToString(); - - public bool Protected { get; set; } - - public bool Equals(EFBeatmapSetInfo other) - { - if (ReferenceEquals(this, other)) return true; - if (other == null) return false; - - if (ID != 0 && other.ID != 0) - return ID == other.ID; - - return false; - } - - public bool Equals(IBeatmapSetInfo other) => other is EFBeatmapSetInfo b && Equals(b); - - #region Implementation of IHasOnlineID - - int IHasOnlineID.OnlineID => OnlineID ?? -1; - - #endregion - - #region Implementation of IBeatmapSetInfo - - IBeatmapMetadataInfo IBeatmapSetInfo.Metadata => Metadata ?? Beatmaps.FirstOrDefault()?.Metadata ?? new EFBeatmapMetadata(); - IEnumerable IBeatmapSetInfo.Beatmaps => Beatmaps; - IEnumerable IHasNamedFiles.Files => Files; - - #endregion - } -} diff --git a/osu.Game/Beatmaps/FlatFileWorkingBeatmap.cs b/osu.Game/Beatmaps/FlatFileWorkingBeatmap.cs index cd8aa31ead..02fcde5257 100644 --- a/osu.Game/Beatmaps/FlatFileWorkingBeatmap.cs +++ b/osu.Game/Beatmaps/FlatFileWorkingBeatmap.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.IO; using osu.Framework.Audio.Track; diff --git a/osu.Game/Beatmaps/Formats/Decoder.cs b/osu.Game/Beatmaps/Formats/Decoder.cs index c1537d7240..4f0f11d053 100644 --- a/osu.Game/Beatmaps/Formats/Decoder.cs +++ b/osu.Game/Beatmaps/Formats/Decoder.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.IO; @@ -55,8 +57,7 @@ namespace osu.Game.Beatmaps.Formats public static Decoder GetDecoder(LineBufferedReader stream) where T : new() { - if (stream == null) - throw new ArgumentNullException(nameof(stream)); + ArgumentNullException.ThrowIfNull(stream); if (!decoders.TryGetValue(typeof(T), out var typedDecoders)) throw new IOException(@"Unknown decoder type"); @@ -72,7 +73,7 @@ namespace osu.Game.Beatmaps.Formats } if (line == null) - throw new IOException("Unknown file format (null)"); + throw new IOException("Unknown file format (no content)"); var decoder = typedDecoders.Where(d => line.StartsWith(d.Key, StringComparison.InvariantCulture)).Select(d => d.Value).FirstOrDefault(); diff --git a/osu.Game/Beatmaps/Formats/IHasComboColours.cs b/osu.Game/Beatmaps/Formats/IHasComboColours.cs index 853a590595..1d9cc0be65 100644 --- a/osu.Game/Beatmaps/Formats/IHasComboColours.cs +++ b/osu.Game/Beatmaps/Formats/IHasComboColours.cs @@ -1,7 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; +#nullable disable + using System.Collections.Generic; using osuTK.Graphics; @@ -20,11 +21,5 @@ namespace osu.Game.Beatmaps.Formats /// if empty, will fall back to default combo colours. /// List CustomComboColours { get; } - - /// - /// Adds combo colours to the list. - /// - [Obsolete("Use CustomComboColours directly.")] // can be removed 20220215 - void AddComboColours(params Color4[] colours); } } diff --git a/osu.Game/Beatmaps/Formats/IHasCustomColours.cs b/osu.Game/Beatmaps/Formats/IHasCustomColours.cs index dba3a37545..b651ef9515 100644 --- a/osu.Game/Beatmaps/Formats/IHasCustomColours.cs +++ b/osu.Game/Beatmaps/Formats/IHasCustomColours.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osuTK.Graphics; diff --git a/osu.Game/Beatmaps/Formats/JsonBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/JsonBeatmapDecoder.cs index 0d5c48f64d..4f292a9a1f 100644 --- a/osu.Game/Beatmaps/Formats/JsonBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/JsonBeatmapDecoder.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.IO; using osu.Game.IO.Serialization; @@ -10,7 +12,7 @@ namespace osu.Game.Beatmaps.Formats { public static void Register() { - AddDecoder("{", m => new JsonBeatmapDecoder()); + AddDecoder("{", _ => new JsonBeatmapDecoder()); } protected override void ParseStreamInto(LineBufferedReader stream, Beatmap output) diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs index 3a893a1238..a9bdd21b64 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.IO; @@ -158,7 +160,7 @@ namespace osu.Game.Beatmaps.Formats break; case @"SampleSet": - defaultSampleBank = (LegacySampleBank)Enum.Parse(typeof(LegacySampleBank), pair.Value); + defaultSampleBank = Enum.Parse(pair.Value); break; case @"SampleVolume": @@ -216,7 +218,7 @@ namespace osu.Game.Beatmaps.Formats break; case @"Countdown": - beatmap.BeatmapInfo.Countdown = (CountdownType)Enum.Parse(typeof(CountdownType), pair.Value); + beatmap.BeatmapInfo.Countdown = Enum.Parse(pair.Value); break; case @"CountdownOffset": @@ -353,6 +355,27 @@ namespace osu.Game.Beatmaps.Formats switch (type) { + case LegacyEventType.Sprite: + // Generally, the background is the first thing defined in a beatmap file. + // In some older beatmaps, it is not present and replaced by a storyboard-level background instead. + // Allow the first sprite (by file order) to act as the background in such cases. + if (string.IsNullOrEmpty(beatmap.BeatmapInfo.Metadata.BackgroundFile)) + 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))) + { + beatmap.BeatmapInfo.Metadata.BackgroundFile = filename; + } + + break; + case LegacyEventType.Background: beatmap.BeatmapInfo.Metadata.BackgroundFile = CleanFilename(split[2]); break; @@ -371,7 +394,11 @@ namespace osu.Game.Beatmaps.Formats string[] split = line.Split(','); double time = getOffsetTime(Parsing.ParseDouble(split[0].Trim())); - double beatLength = Parsing.ParseDouble(split[1].Trim()); + + // beatLength is allowed to be NaN to handle an edge case in which some beatmaps use NaN slider velocity to disable slider tick generation (see LegacyDifficultyControlPoint). + double beatLength = Parsing.ParseDouble(split[1].Trim(), allowNaN: true); + + // If beatLength is NaN, speedMultiplier should still be 1 because all comparisons against NaN are false. double speedMultiplier = beatLength < 0 ? 100.0 / -beatLength : 1; TimeSignature timeSignature = TimeSignature.SimpleQuadruple; @@ -410,16 +437,22 @@ namespace osu.Game.Beatmaps.Formats if (timingChange) { + if (double.IsNaN(beatLength)) + throw new InvalidDataException("Beat length cannot be NaN in a timing control point"); + var controlPoint = CreateTimingControlPoint(); controlPoint.BeatLength = beatLength; controlPoint.TimeSignature = timeSignature; + controlPoint.OmitFirstBarLine = omitFirstBarSignature; addControlPoint(time, controlPoint, true); } + int onlineRulesetID = beatmap.BeatmapInfo.Ruleset.OnlineID; + #pragma warning disable 618 - addControlPoint(time, new LegacyDifficultyControlPoint(beatLength) + addControlPoint(time, new LegacyDifficultyControlPoint(onlineRulesetID, beatLength) #pragma warning restore 618 { SliderVelocity = speedMultiplier, @@ -428,11 +461,8 @@ namespace osu.Game.Beatmaps.Formats var effectPoint = new EffectControlPoint { KiaiMode = kiaiMode, - OmitFirstBarLine = omitFirstBarSignature, }; - int onlineRulesetID = beatmap.BeatmapInfo.Ruleset.OnlineID; - // osu!taiko and osu!mania use effect points rather than difficulty points for scroll speed adjustments. if (onlineRulesetID == 1 || onlineRulesetID == 3) effectPoint.ScrollSpeed = speedMultiplier; diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs index 3b4200e7a9..072e442dea 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Globalization; @@ -220,6 +222,7 @@ namespace osu.Game.Beatmaps.Formats { var samplePoint = legacyControlPoints.SamplePointAt(time); var effectPoint = legacyControlPoints.EffectPointAt(time); + var timingPoint = legacyControlPoints.TimingPointAt(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)); @@ -228,10 +231,10 @@ namespace osu.Game.Beatmaps.Formats LegacyEffectFlags effectFlags = LegacyEffectFlags.None; if (effectPoint.KiaiMode) effectFlags |= LegacyEffectFlags.Kiai; - if (effectPoint.OmitFirstBarLine) + if (timingPoint.OmitFirstBarLine) effectFlags |= LegacyEffectFlags.OmitFirstBarLine; - writer.Write(FormattableString.Invariant($"{legacyControlPoints.TimingPointAt(time).TimeSignature.Numerator},")); + 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},")); @@ -298,7 +301,7 @@ namespace osu.Game.Beatmaps.Formats { var comboColour = colours[i]; - writer.Write(FormattableString.Invariant($"Combo{i}: ")); + writer.Write(FormattableString.Invariant($"Combo{1 + i}: ")); writer.Write(FormattableString.Invariant($"{(byte)(comboColour.R * byte.MaxValue)},")); writer.Write(FormattableString.Invariant($"{(byte)(comboColour.G * byte.MaxValue)},")); writer.Write(FormattableString.Invariant($"{(byte)(comboColour.B * byte.MaxValue)},")); @@ -371,11 +374,11 @@ namespace osu.Game.Beatmaps.Formats switch (hitObject) { - case IHasPath _: + case IHasPath: type |= LegacyHitObjectType.Slider; break; - case IHasDuration _: + case IHasDuration: if (onlineRulesetID == 3) type |= LegacyHitObjectType.Hold; else diff --git a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs index ff13e61360..704756e3dd 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs @@ -29,7 +29,7 @@ namespace osu.Game.Beatmaps.Formats { Section section = Section.General; - string line; + string? line; while ((line = stream.ReadLine()) != null) { @@ -79,7 +79,7 @@ namespace osu.Game.Beatmaps.Formats switch (section) { case Section.Colours: - HandleColours(output, line); + HandleColours(output, line, false); return; } } @@ -93,7 +93,7 @@ namespace osu.Game.Beatmaps.Formats return line; } - protected void HandleColours(TModel output, string line) + protected void HandleColours(TModel output, string line, bool allowAlpha) { var pair = SplitKeyVal(line); @@ -108,7 +108,7 @@ namespace osu.Game.Beatmaps.Formats try { - byte alpha = split.Length == 4 ? byte.Parse(split[3]) : (byte)255; + byte alpha = allowAlpha && split.Length == 4 ? byte.Parse(split[3]) : (byte)255; colour = new Color4(byte.Parse(split[0]), byte.Parse(split[1]), byte.Parse(split[2]), alpha); } catch @@ -130,20 +130,24 @@ namespace osu.Game.Beatmaps.Formats } } - protected KeyValuePair SplitKeyVal(string line, char separator = ':') + protected KeyValuePair SplitKeyVal(string line, char separator = ':', bool shouldTrim = true) { - string[] split = line.Split(separator, 2); + string[] split = line.Split(separator, 2, shouldTrim ? StringSplitOptions.TrimEntries : StringSplitOptions.None); return new KeyValuePair ( - split[0].Trim(), - split.Length > 1 ? split[1].Trim() : string.Empty + split[0], + split.Length > 1 ? split[1] : string.Empty ); } - protected string CleanFilename(string path) => path.Trim('"').ToStandardisedPath(); + protected string CleanFilename(string path) => path + // User error which is supported by stable (https://github.com/ppy/osu/issues/21204) + .Replace(@"\\", @"\") + .Trim('"') + .ToStandardisedPath(); - protected enum Section + public enum Section { General, Editor, @@ -160,7 +164,7 @@ namespace osu.Game.Beatmaps.Formats } [Obsolete("Do not use unless you're a legacy ruleset and 100% sure.")] - public class LegacyDifficultyControlPoint : DifficultyControlPoint + public class LegacyDifficultyControlPoint : DifficultyControlPoint, IEquatable { /// /// Legacy BPM multiplier that introduces floating-point errors for rulesets that depend on it. @@ -168,11 +172,22 @@ namespace osu.Game.Beatmaps.Formats /// public double BpmMultiplier { get; private set; } - public LegacyDifficultyControlPoint(double beatLength) + /// + /// Whether or not slider ticks should be generated at this control point. + /// This exists for backwards compatibility with maps that abuse NaN slider velocity behavior on osu!stable (e.g. /b/2628991). + /// + public bool GenerateTicks { get; private set; } = true; + + public LegacyDifficultyControlPoint(int rulesetId, double beatLength) : this() { // Note: In stable, the division occurs on floats, but with compiler optimisations turned on actually seems to occur on doubles via some .NET black magic (possibly inlining?). - BpmMultiplier = beatLength < 0 ? Math.Clamp((float)-beatLength, 10, 10000) / 100.0 : 1; + if (rulesetId == 1 || rulesetId == 3) + BpmMultiplier = beatLength < 0 ? Math.Clamp((float)-beatLength, 10, 10000) / 100.0 : 1; + else + BpmMultiplier = beatLength < 0 ? Math.Clamp((float)-beatLength, 10, 1000) / 100.0 : 1; + + GenerateTicks = !double.IsNaN(beatLength); } public LegacyDifficultyControlPoint() @@ -180,15 +195,32 @@ namespace osu.Game.Beatmaps.Formats SliderVelocityBindable.Precision = double.Epsilon; } + public override bool IsRedundant(ControlPoint? existing) + => base.IsRedundant(existing) + && GenerateTicks == ((existing as LegacyDifficultyControlPoint)?.GenerateTicks ?? true); + public override void CopyFrom(ControlPoint other) { base.CopyFrom(other); BpmMultiplier = ((LegacyDifficultyControlPoint)other).BpmMultiplier; + GenerateTicks = ((LegacyDifficultyControlPoint)other).GenerateTicks; } + + public override bool Equals(ControlPoint? other) + => other is LegacyDifficultyControlPoint otherLegacyDifficultyControlPoint + && Equals(otherLegacyDifficultyControlPoint); + + public bool Equals(LegacyDifficultyControlPoint? other) + => base.Equals(other) + && BpmMultiplier == other.BpmMultiplier + && GenerateTicks == other.GenerateTicks; + + // ReSharper disable twice NonReadonlyMemberInGetHashCode + public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), BpmMultiplier, GenerateTicks); } - internal class LegacySampleControlPoint : SampleControlPoint + internal class LegacySampleControlPoint : SampleControlPoint, IEquatable { public int CustomSampleBank; @@ -202,7 +234,7 @@ namespace osu.Game.Beatmaps.Formats return baseInfo; } - public override bool IsRedundant(ControlPoint existing) + public override bool IsRedundant(ControlPoint? existing) => base.IsRedundant(existing) && existing is LegacySampleControlPoint existingSample && CustomSampleBank == existingSample.CustomSampleBank; @@ -213,6 +245,17 @@ namespace osu.Game.Beatmaps.Formats CustomSampleBank = ((LegacySampleControlPoint)other).CustomSampleBank; } + + public override bool Equals(ControlPoint? other) + => other is LegacySampleControlPoint otherLegacySampleControlPoint + && Equals(otherLegacySampleControlPoint); + + public bool Equals(LegacySampleControlPoint? other) + => base.Equals(other) + && CustomSampleBank == other.CustomSampleBank; + + // ReSharper disable once NonReadonlyMemberInGetHashCode + public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), CustomSampleBank); } } } diff --git a/osu.Game/Beatmaps/Formats/LegacyDifficultyCalculatorBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDifficultyCalculatorBeatmapDecoder.cs index 3420fcf260..bf69100361 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDifficultyCalculatorBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDifficultyCalculatorBeatmapDecoder.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; namespace osu.Game.Beatmaps.Formats diff --git a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs index 90a96e2ac8..f8308fe431 100644 --- a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.IO; @@ -33,7 +35,7 @@ namespace osu.Game.Beatmaps.Formats { // note that this isn't completely correct AddDecoder(@"osu file format v", m => new LegacyStoryboardDecoder(Parsing.ParseInt(m.Split('v').Last()))); - AddDecoder(@"[Events]", m => new LegacyStoryboardDecoder()); + AddDecoder(@"[Events]", _ => new LegacyStoryboardDecoder()); SetFallbackDecoder(() => new LegacyStoryboardDecoder()); } @@ -77,6 +79,8 @@ namespace osu.Game.Beatmaps.Formats private void handleEvents(string line) { + decodeVariables(ref line); + int depth = 0; foreach (char c in line) @@ -89,8 +93,6 @@ namespace osu.Game.Beatmaps.Formats line = line.Substring(depth); - decodeVariables(ref line); - string[] split = line.Split(','); if (depth == 0) @@ -107,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))) + break; + storyboard.GetLayer("Video").Add(new StoryboardVideo(path, offset)); break; } @@ -274,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": @@ -299,11 +310,11 @@ namespace osu.Game.Beatmaps.Formats } } - private string parseLayer(string value) => Enum.Parse(typeof(LegacyStoryLayer), value).ToString(); + private string parseLayer(string value) => Enum.Parse(value).ToString(); private Anchor parseOrigin(string value) { - var origin = (LegacyOrigins)Enum.Parse(typeof(LegacyOrigins), value); + var origin = Enum.Parse(value); switch (origin) { @@ -341,13 +352,13 @@ namespace osu.Game.Beatmaps.Formats private AnimationLoopType parseAnimationLoopType(string value) { - var parsed = (AnimationLoopType)Enum.Parse(typeof(AnimationLoopType), value); - return Enum.IsDefined(typeof(AnimationLoopType), parsed) ? parsed : AnimationLoopType.LoopForever; + var parsed = Enum.Parse(value); + return Enum.IsDefined(parsed) ? parsed : AnimationLoopType.LoopForever; } private void handleVariables(string line) { - var pair = SplitKeyVal(line, '='); + var pair = SplitKeyVal(line, '=', false); variables[pair.Key] = pair.Value; } diff --git a/osu.Game/Beatmaps/Formats/Parsing.cs b/osu.Game/Beatmaps/Formats/Parsing.cs index 4d512fdeed..9b0d200077 100644 --- a/osu.Game/Beatmaps/Formats/Parsing.cs +++ b/osu.Game/Beatmaps/Formats/Parsing.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Globalization; @@ -15,26 +17,26 @@ namespace osu.Game.Beatmaps.Formats public const double MAX_PARSE_VALUE = int.MaxValue; - public static float ParseFloat(string input, float parseLimit = (float)MAX_PARSE_VALUE) + public static float ParseFloat(string input, float parseLimit = (float)MAX_PARSE_VALUE, bool allowNaN = false) { float output = float.Parse(input, CultureInfo.InvariantCulture); if (output < -parseLimit) throw new OverflowException("Value is too low"); if (output > parseLimit) throw new OverflowException("Value is too high"); - if (float.IsNaN(output)) throw new FormatException("Not a number"); + if (!allowNaN && float.IsNaN(output)) throw new FormatException("Not a number"); return output; } - public static double ParseDouble(string input, double parseLimit = MAX_PARSE_VALUE) + public static double ParseDouble(string input, double parseLimit = MAX_PARSE_VALUE, bool allowNaN = false) { double output = double.Parse(input, CultureInfo.InvariantCulture); if (output < -parseLimit) throw new OverflowException("Value is too low"); if (output > parseLimit) throw new OverflowException("Value is too high"); - if (double.IsNaN(output)) throw new FormatException("Not a number"); + if (!allowNaN && double.IsNaN(output)) throw new FormatException("Not a number"); return output; } diff --git a/osu.Game/Beatmaps/FramedBeatmapClock.cs b/osu.Game/Beatmaps/FramedBeatmapClock.cs new file mode 100644 index 0000000000..080b0ce7ec --- /dev/null +++ b/osu.Game/Beatmaps/FramedBeatmapClock.cs @@ -0,0 +1,224 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using osu.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Timing; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.Screens.Play; + +namespace osu.Game.Beatmaps +{ + /// + /// A clock intended to be the single source-of-truth for beatmap timing. + /// + /// It provides some functionality: + /// - Optionally applies (and tracks changes of) beatmap, user, and platform offsets (see ctor argument applyOffsets). + /// - Adjusts operations to account for any applied offsets, seeking in raw "beatmap" time values. + /// - Exposes track length. + /// - Allows changing the source to a new track (for cases like editor track updating). + /// + public partial class FramedBeatmapClock : Component, IFrameBasedClock, IAdjustableClock, ISourceChangeableClock + { + private readonly bool applyOffsets; + + /// + /// The length of the underlying beatmap track. Will default to 60 seconds if unavailable. + /// + public double TrackLength => Track.Length; + + /// + /// The underlying beatmap track, if available. + /// + public Track Track { get; private set; } = new TrackVirtual(60000); + + /// + /// The total frequency adjustment from pause transforms. Should eventually be handled in a better way. + /// + public readonly BindableDouble ExternalPauseFrequencyAdjust = new BindableDouble(1); + + private readonly OffsetCorrectionClock? userGlobalOffsetClock; + private readonly OffsetCorrectionClock? platformOffsetClock; + private readonly OffsetCorrectionClock? userBeatmapOffsetClock; + + private readonly IFrameBasedClock finalClockSource; + + private Bindable? userAudioOffset; + + private IDisposable? beatmapOffsetSubscription; + + private readonly DecoupleableInterpolatingFramedClock decoupledClock; + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + public bool IsCoupled + { + get => decoupledClock.IsCoupled; + set => decoupledClock.IsCoupled = value; + } + + public FramedBeatmapClock(bool applyOffsets = false) + { + this.applyOffsets = applyOffsets; + + // A decoupled clock is used to ensure precise time values even when the host audio subsystem is not reporting + // high precision times (on windows there's generally only 5-10ms reporting intervals, as an example). + decoupledClock = new DecoupleableInterpolatingFramedClock { IsCoupled = true }; + + if (applyOffsets) + { + // Audio timings in general with newer BASS versions don't match stable. + // This only seems to be required on windows. We need to eventually figure out why, with a bit of luck. + platformOffsetClock = new OffsetCorrectionClock(decoupledClock, ExternalPauseFrequencyAdjust) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 }; + + // User global offset (set in settings) should also be applied. + userGlobalOffsetClock = new OffsetCorrectionClock(platformOffsetClock, ExternalPauseFrequencyAdjust); + + // User per-beatmap offset will be applied to this final clock. + finalClockSource = userBeatmapOffsetClock = new OffsetCorrectionClock(userGlobalOffsetClock, ExternalPauseFrequencyAdjust); + } + else + { + finalClockSource = decoupledClock; + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (applyOffsets) + { + Debug.Assert(userBeatmapOffsetClock != null); + Debug.Assert(userGlobalOffsetClock != null); + + userAudioOffset = config.GetBindable(OsuSetting.AudioOffset); + userAudioOffset.BindValueChanged(offset => userGlobalOffsetClock.Offset = offset.NewValue, true); + + beatmapOffsetSubscription = realm.SubscribeToPropertyChanged( + r => r.Find(beatmap.Value.BeatmapInfo.ID)?.UserSettings, + settings => settings.Offset, + val => + { + userBeatmapOffsetClock.Offset = val; + }); + } + } + + protected override void Update() + { + base.Update(); + + if (Source != null && Source is not IAdjustableClock && Source.CurrentTime < decoupledClock.CurrentTime) + { + // InterpolatingFramedClock won't interpolate backwards unless its source has an ElapsedFrameTime. + // See https://github.com/ppy/osu-framework/blob/ba1385330cc501f34937e08257e586c84e35d772/osu.Framework/Timing/InterpolatingFramedClock.cs#L91-L93 + // This is not always the case here when doing large seeks. + // (Of note, this is not an issue if the source is adjustable, as the source is seeked to be in time by DecoupleableInterpolatingFramedClock). + // Rather than trying to get around this by fixing the framework clock stack, let's work around it for now. + Seek(Source.CurrentTime); + } + else + finalClockSource.ProcessFrame(); + } + + public double TotalAppliedOffset + { + get + { + if (!applyOffsets) + return 0; + + Debug.Assert(userGlobalOffsetClock != null); + Debug.Assert(userBeatmapOffsetClock != null); + Debug.Assert(platformOffsetClock != null); + + return userGlobalOffsetClock.RateAdjustedOffset + userBeatmapOffsetClock.RateAdjustedOffset + platformOffsetClock.RateAdjustedOffset; + } + } + + #region Delegation of IAdjustableClock / ISourceChangeableClock to decoupled clock. + + public void ChangeSource(IClock? source) + { + Track = source as Track ?? new TrackVirtual(60000); + decoupledClock.ChangeSource(source); + } + + public IClock? Source => decoupledClock.Source; + + public void Reset() + { + decoupledClock.Reset(); + finalClockSource.ProcessFrame(); + } + + public void Start() + { + decoupledClock.Start(); + finalClockSource.ProcessFrame(); + } + + public void Stop() + { + decoupledClock.Stop(); + finalClockSource.ProcessFrame(); + } + + public bool Seek(double position) + { + bool success = decoupledClock.Seek(position - TotalAppliedOffset); + finalClockSource.ProcessFrame(); + + return success; + } + + public void ResetSpeedAdjustments() => decoupledClock.ResetSpeedAdjustments(); + + public double Rate + { + get => decoupledClock.Rate; + set => decoupledClock.Rate = value; + } + + #endregion + + #region Delegation of IFrameBasedClock to clock with all offsets applied + + public double CurrentTime => finalClockSource.CurrentTime; + + public bool IsRunning => finalClockSource.IsRunning; + + public void ProcessFrame() + { + // Noop to ensure an external consumer doesn't process the internal clock an extra time. + } + + public double ElapsedFrameTime => finalClockSource.ElapsedFrameTime; + + public double FramesPerSecond => finalClockSource.FramesPerSecond; + + public FrameTimeInfo TimeInfo => finalClockSource.TimeInfo; + + #endregion + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + beatmapOffsetSubscription?.Dispose(); + } + } +} diff --git a/osu.Game/Beatmaps/IBeatSyncProvider.cs b/osu.Game/Beatmaps/IBeatSyncProvider.cs index cc1cfc3cb5..9ee19e720d 100644 --- a/osu.Game/Beatmaps/IBeatSyncProvider.cs +++ b/osu.Game/Beatmaps/IBeatSyncProvider.cs @@ -1,10 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - using osu.Framework.Allocation; -using osu.Framework.Audio.Track; +using osu.Framework.Audio; using osu.Framework.Timing; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.Containers; @@ -16,12 +14,16 @@ namespace osu.Game.Beatmaps /// Primarily intended for use with . /// [Cached] - public interface IBeatSyncProvider + public interface IBeatSyncProvider : IHasAmplitudes { + /// + /// Access any available control points from a beatmap providing beat sync. If null, no current provider is available. + /// ControlPointInfo? ControlPoints { get; } + /// + /// Access a clock currently responsible for providing beat sync. If null, no current provider is available. + /// IClock? Clock { get; } - - ChannelAmplitudes? Amplitudes { get; } } } diff --git a/osu.Game/Beatmaps/IBeatmap.cs b/osu.Game/Beatmaps/IBeatmap.cs index dec1ef4294..f6771f7adf 100644 --- a/osu.Game/Beatmaps/IBeatmap.cs +++ b/osu.Game/Beatmaps/IBeatmap.cs @@ -1,7 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; +using System.Linq; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Timing; using osu.Game.Rulesets.Objects; @@ -9,6 +12,10 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Beatmaps { + /// + /// A materialised beatmap. + /// Generally this interface will be implemented alongside , which exposes the ruleset-typed hit objects. + /// public interface IBeatmap { /// @@ -63,6 +70,9 @@ namespace osu.Game.Beatmaps IBeatmap Clone(); } + /// + /// A materialised beatmap containing converted HitObjects. + /// public interface IBeatmap : IBeatmap where T : HitObject { @@ -93,5 +103,16 @@ namespace osu.Game.Beatmaps addCombo(nested, ref combo); } } + + /// + /// Find the absolute end time of the latest in a beatmap. Will throw if beatmap contains no objects. + /// + /// + /// This correctly accounts for rulesets which have concurrent hitobjects which may have durations, causing the .Last() object + /// to not necessarily have the latest end time. + /// + /// 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()); } } diff --git a/osu.Game/Beatmaps/IBeatmapConverter.cs b/osu.Game/Beatmaps/IBeatmapConverter.cs index 2833af8ca2..f84188c5e2 100644 --- a/osu.Game/Beatmaps/IBeatmapConverter.cs +++ b/osu.Game/Beatmaps/IBeatmapConverter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Threading; diff --git a/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs b/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs index 339364d442..dad9bbbd0b 100644 --- a/osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs +++ b/osu.Game/Beatmaps/IBeatmapDifficultyInfo.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 enable - namespace osu.Game.Beatmaps { /// diff --git a/osu.Game/Beatmaps/IBeatmapInfo.cs b/osu.Game/Beatmaps/IBeatmapInfo.cs index ab096b8897..4f2c08f63d 100644 --- a/osu.Game/Beatmaps/IBeatmapInfo.cs +++ b/osu.Game/Beatmaps/IBeatmapInfo.cs @@ -5,8 +5,6 @@ using System; using osu.Game.Database; using osu.Game.Rulesets; -#nullable enable - namespace osu.Game.Beatmaps { /// diff --git a/osu.Game/Beatmaps/IBeatmapMetadataInfo.cs b/osu.Game/Beatmaps/IBeatmapMetadataInfo.cs index 61adc0ac34..9c96aba85b 100644 --- a/osu.Game/Beatmaps/IBeatmapMetadataInfo.cs +++ b/osu.Game/Beatmaps/IBeatmapMetadataInfo.cs @@ -4,8 +4,6 @@ using System; using osu.Game.Users; -#nullable enable - namespace osu.Game.Beatmaps { /// diff --git a/osu.Game/Beatmaps/IBeatmapOnlineInfo.cs b/osu.Game/Beatmaps/IBeatmapOnlineInfo.cs index 385646eeaa..e1634e7d24 100644 --- a/osu.Game/Beatmaps/IBeatmapOnlineInfo.cs +++ b/osu.Game/Beatmaps/IBeatmapOnlineInfo.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 enable - namespace osu.Game.Beatmaps { /// @@ -15,25 +13,50 @@ namespace osu.Game.Beatmaps /// int? MaxCombo { get; } + /// + /// The approach rate. + /// + float ApproachRate { get; } + + /// + /// The circle size. + /// + float CircleSize { get; } + + /// + /// The drain rate. + /// + float DrainRate { get; } + + /// + /// The overall difficulty. + /// + float OverallDifficulty { get; } + /// /// The amount of circles in this beatmap. /// - public int CircleCount { get; } + int CircleCount { get; } /// /// The amount of sliders in this beatmap. /// - public int SliderCount { get; } + int SliderCount { get; } + + /// + /// The amount of spinners in tihs beatmap. + /// + int SpinnerCount { get; } /// /// The amount of plays this beatmap has. /// - public int PlayCount { get; } + int PlayCount { get; } /// /// The amount of passes this beatmap has. /// - public int PassCount { get; } + int PassCount { get; } APIFailTimes? FailTimes { get; } } diff --git a/osu.Game/Beatmaps/IBeatmapProcessor.cs b/osu.Game/Beatmaps/IBeatmapProcessor.cs index 014dccf5e3..0a4a98c606 100644 --- a/osu.Game/Beatmaps/IBeatmapProcessor.cs +++ b/osu.Game/Beatmaps/IBeatmapProcessor.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets; using osu.Game.Rulesets.Objects; diff --git a/osu.Game/Beatmaps/IBeatmapResourceProvider.cs b/osu.Game/Beatmaps/IBeatmapResourceProvider.cs index dfea0c7a30..22ff7ce8c8 100644 --- a/osu.Game/Beatmaps/IBeatmapResourceProvider.cs +++ b/osu.Game/Beatmaps/IBeatmapResourceProvider.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; using osu.Game.IO; diff --git a/osu.Game/Beatmaps/IBeatmapSetInfo.cs b/osu.Game/Beatmaps/IBeatmapSetInfo.cs index 9755120457..2ecd16cb75 100644 --- a/osu.Game/Beatmaps/IBeatmapSetInfo.cs +++ b/osu.Game/Beatmaps/IBeatmapSetInfo.cs @@ -5,8 +5,6 @@ using System; using System.Collections.Generic; using osu.Game.Database; -#nullable enable - namespace osu.Game.Beatmaps { /// diff --git a/osu.Game/Beatmaps/IBeatmapSetOnlineInfo.cs b/osu.Game/Beatmaps/IBeatmapSetOnlineInfo.cs index 33d8929008..3f50b4eaf3 100644 --- a/osu.Game/Beatmaps/IBeatmapSetOnlineInfo.cs +++ b/osu.Game/Beatmaps/IBeatmapSetOnlineInfo.cs @@ -3,8 +3,6 @@ using System; -#nullable enable - namespace osu.Game.Beatmaps { /// diff --git a/osu.Game/Beatmaps/IWorkingBeatmap.cs b/osu.Game/Beatmaps/IWorkingBeatmap.cs index 3e2868470a..0f0e72b0ac 100644 --- a/osu.Game/Beatmaps/IWorkingBeatmap.cs +++ b/osu.Game/Beatmaps/IWorkingBeatmap.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.IO; using System.Threading; @@ -16,7 +18,12 @@ using osu.Game.Storyboards; namespace osu.Game.Beatmaps { /// - /// Provides access to the multiple resources offered by a beatmap model (textures, skins, playable beatmaps etc.) + /// A more expensive representation of a beatmap which allows access to various associated resources. + /// - Access textures and other resources via . + /// - Access the storyboard via . + /// - Access a local skin via . + /// - Access the track via (and then for subsequent accesses). + /// - Create a playable via . /// public interface IWorkingBeatmap { @@ -127,6 +134,6 @@ namespace osu.Game.Beatmaps /// /// Reads the correct track restart point from beatmap metadata and sets looping to enabled. /// - void PrepareTrackForPreviewLooping(); + void PrepareTrackForPreview(bool looping, double offsetFromPreviewPoint = 0); } } diff --git a/osu.Game/Beatmaps/Legacy/LegacyControlPointInfo.cs b/osu.Game/Beatmaps/Legacy/LegacyControlPointInfo.cs index 2b0a2e7a4d..5c3c72c9e4 100644 --- a/osu.Game/Beatmaps/Legacy/LegacyControlPointInfo.cs +++ b/osu.Game/Beatmaps/Legacy/LegacyControlPointInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using JetBrains.Annotations; using Newtonsoft.Json; @@ -54,12 +56,12 @@ namespace osu.Game.Beatmaps.Legacy { switch (newPoint) { - case SampleControlPoint _: + case SampleControlPoint: // intentionally don't use SamplePointAt (we always need to consider the first sample point). var existing = BinarySearch(SamplePoints, time); return newPoint.IsRedundant(existing); - case DifficultyControlPoint _: + case DifficultyControlPoint: return newPoint.IsRedundant(DifficultyPointAt(time)); default: diff --git a/osu.Game/Beatmaps/Legacy/LegacyEffectFlags.cs b/osu.Game/Beatmaps/Legacy/LegacyEffectFlags.cs index 5bf80c34d7..b3717c81dc 100644 --- a/osu.Game/Beatmaps/Legacy/LegacyEffectFlags.cs +++ b/osu.Game/Beatmaps/Legacy/LegacyEffectFlags.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; namespace osu.Game.Beatmaps.Legacy diff --git a/osu.Game/Beatmaps/Legacy/LegacyEventType.cs b/osu.Game/Beatmaps/Legacy/LegacyEventType.cs index 32a7122978..42d21d14f6 100644 --- a/osu.Game/Beatmaps/Legacy/LegacyEventType.cs +++ b/osu.Game/Beatmaps/Legacy/LegacyEventType.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Beatmaps.Legacy { internal enum LegacyEventType diff --git a/osu.Game/Beatmaps/Legacy/LegacyHitObjectType.cs b/osu.Game/Beatmaps/Legacy/LegacyHitObjectType.cs index ec9839b893..ec947c6dc2 100644 --- a/osu.Game/Beatmaps/Legacy/LegacyHitObjectType.cs +++ b/osu.Game/Beatmaps/Legacy/LegacyHitObjectType.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; namespace osu.Game.Beatmaps.Legacy diff --git a/osu.Game/Beatmaps/Legacy/LegacyHitSoundType.cs b/osu.Game/Beatmaps/Legacy/LegacyHitSoundType.cs index d7743565f8..d1782ec862 100644 --- a/osu.Game/Beatmaps/Legacy/LegacyHitSoundType.cs +++ b/osu.Game/Beatmaps/Legacy/LegacyHitSoundType.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; namespace osu.Game.Beatmaps.Legacy diff --git a/osu.Game/Beatmaps/Legacy/LegacyMods.cs b/osu.Game/Beatmaps/Legacy/LegacyMods.cs index 0e517ea3df..27b2e313ec 100644 --- a/osu.Game/Beatmaps/Legacy/LegacyMods.cs +++ b/osu.Game/Beatmaps/Legacy/LegacyMods.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; namespace osu.Game.Beatmaps.Legacy diff --git a/osu.Game/Beatmaps/Legacy/LegacyOrigins.cs b/osu.Game/Beatmaps/Legacy/LegacyOrigins.cs index 31f67d6dfd..62b0edc384 100644 --- a/osu.Game/Beatmaps/Legacy/LegacyOrigins.cs +++ b/osu.Game/Beatmaps/Legacy/LegacyOrigins.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Beatmaps.Legacy { internal enum LegacyOrigins diff --git a/osu.Game/Beatmaps/Legacy/LegacySampleBank.cs b/osu.Game/Beatmaps/Legacy/LegacySampleBank.cs index 8cac29cb87..f8a57c3ac9 100644 --- a/osu.Game/Beatmaps/Legacy/LegacySampleBank.cs +++ b/osu.Game/Beatmaps/Legacy/LegacySampleBank.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Beatmaps.Legacy { internal enum LegacySampleBank diff --git a/osu.Game/Beatmaps/Legacy/LegacyStoryLayer.cs b/osu.Game/Beatmaps/Legacy/LegacyStoryLayer.cs index ea23c49c4a..69d0c96b57 100644 --- a/osu.Game/Beatmaps/Legacy/LegacyStoryLayer.cs +++ b/osu.Game/Beatmaps/Legacy/LegacyStoryLayer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Beatmaps.Legacy { internal enum LegacyStoryLayer 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/MetadataUtils.cs b/osu.Game/Beatmaps/MetadataUtils.cs index b27c59b4de..89c821c16c 100644 --- a/osu.Game/Beatmaps/MetadataUtils.cs +++ b/osu.Game/Beatmaps/MetadataUtils.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 enable - using System.Linq; using System.Text; diff --git a/osu.Game/Beatmaps/StarDifficulty.cs b/osu.Game/Beatmaps/StarDifficulty.cs index f438b6f0bc..6aac275a6a 100644 --- a/osu.Game/Beatmaps/StarDifficulty.cs +++ b/osu.Game/Beatmaps/StarDifficulty.cs @@ -1,7 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; +using osu.Framework.Utils; using osu.Game.Rulesets.Difficulty; namespace osu.Game.Beatmaps @@ -31,7 +34,7 @@ namespace osu.Game.Beatmaps /// public StarDifficulty([NotNull] DifficultyAttributes attributes) { - Stars = attributes.StarRating; + Stars = double.IsFinite(attributes.StarRating) ? attributes.StarRating : 0; MaxCombo = attributes.MaxCombo; Attributes = attributes; // Todo: Add more members (BeatmapInfo.DifficultyRating? Attributes? Etc...) @@ -43,11 +46,39 @@ namespace osu.Game.Beatmaps /// public StarDifficulty(double starDifficulty, int maxCombo) { - Stars = starDifficulty; + Stars = double.IsFinite(starDifficulty) ? starDifficulty : 0; MaxCombo = maxCombo; Attributes = null; } - public DifficultyRating DifficultyRating => BeatmapDifficultyCache.GetDifficultyRating(Stars); + public DifficultyRating DifficultyRating => GetDifficultyRating(Stars); + + /// + /// Retrieves the that describes a star rating. + /// + /// + /// For more information, see: https://osu.ppy.sh/help/wiki/Difficulties + /// + /// The star rating. + /// The that best describes . + public static DifficultyRating GetDifficultyRating(double starRating) + { + if (Precision.AlmostBigger(starRating, 6.5, 0.005)) + return DifficultyRating.ExpertPlus; + + if (Precision.AlmostBigger(starRating, 5.3, 0.005)) + return DifficultyRating.Expert; + + if (Precision.AlmostBigger(starRating, 4.0, 0.005)) + return DifficultyRating.Insane; + + if (Precision.AlmostBigger(starRating, 2.7, 0.005)) + return DifficultyRating.Hard; + + if (Precision.AlmostBigger(starRating, 2.0, 0.005)) + return DifficultyRating.Normal; + + return DifficultyRating.Easy; + } } } diff --git a/osu.Game/Beatmaps/Timing/BreakPeriod.cs b/osu.Game/Beatmaps/Timing/BreakPeriod.cs index 4c90b16745..274b56a862 100644 --- a/osu.Game/Beatmaps/Timing/BreakPeriod.cs +++ b/osu.Game/Beatmaps/Timing/BreakPeriod.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Screens.Play; namespace osu.Game.Beatmaps.Timing diff --git a/osu.Game/Beatmaps/Timing/TimeSignature.cs b/osu.Game/Beatmaps/Timing/TimeSignature.cs index eebbcc34cd..7499a725dc 100644 --- a/osu.Game/Beatmaps/Timing/TimeSignature.cs +++ b/osu.Game/Beatmaps/Timing/TimeSignature.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; namespace osu.Game.Beatmaps.Timing diff --git a/osu.Game/Beatmaps/Timing/TimeSignatures.cs b/osu.Game/Beatmaps/Timing/TimeSignatures.cs deleted file mode 100644 index d783d3f9ec..0000000000 --- a/osu.Game/Beatmaps/Timing/TimeSignatures.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.ComponentModel; - -namespace osu.Game.Beatmaps.Timing -{ - [Obsolete("Use osu.Game.Beatmaps.Timing.TimeSignature instead.")] - public enum TimeSignatures // can be removed 20220722 - { - [Description("4/4")] - SimpleQuadruple = 4, - - [Description("3/4")] - SimpleTriple = 3 - } -} diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index 09072ec897..ab790617bb 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; @@ -32,8 +34,6 @@ namespace osu.Game.Beatmaps // TODO: remove once the fallback lookup is not required (and access via `working.BeatmapInfo.Metadata` directly). public BeatmapMetadata Metadata => BeatmapInfo.Metadata; - public Waveform Waveform => waveform.Value; - public Storyboard Storyboard => storyboard.Value; public Texture Background => GetBackground(); // Texture uses ref counting, so we want to return a new instance every usage. @@ -46,10 +46,11 @@ namespace osu.Game.Beatmaps private readonly object beatmapFetchLock = new object(); - private readonly Lazy waveform; private readonly Lazy storyboard; private readonly Lazy skin; + private Track track; // track is not Lazy as we allow transferring and loading multiple times. + private Waveform waveform; // waveform is also not Lazy as the track may change. protected WorkingBeatmap(BeatmapInfo beatmapInfo, AudioManager audioManager) { @@ -58,7 +59,6 @@ namespace osu.Game.Beatmaps BeatmapInfo = beatmapInfo; BeatmapSetInfo = beatmapInfo.BeatmapSet ?? new BeatmapSetInfo(); - waveform = new Lazy(GetWaveform); storyboard = new Lazy(GetStoryboard); skin = new Lazy(GetSkin); } @@ -106,11 +106,20 @@ namespace osu.Game.Beatmaps public virtual bool TrackLoaded => track != null; - public Track LoadTrack() => track = GetBeatmapTrack() ?? GetVirtualTrack(1000); - - public void PrepareTrackForPreviewLooping() + public Track LoadTrack() { - Track.Looping = true; + track = GetBeatmapTrack() ?? GetVirtualTrack(1000); + + // the track may have changed, recycle the current waveform. + waveform?.Dispose(); + waveform = null; + + return track; + } + + public void PrepareTrackForPreview(bool looping, double offsetFromPreviewPoint = 0) + { + Track.Looping = looping; Track.RestartPoint = Metadata.PreviewTime; if (Track.RestartPoint == -1) @@ -123,6 +132,8 @@ namespace osu.Game.Beatmaps Track.RestartPoint = 0.4f * Track.Length; } + + Track.RestartPoint += offsetFromPreviewPoint; } /// @@ -144,6 +155,7 @@ namespace osu.Game.Beatmaps /// Get the loaded audio track instance. must have first been called. /// This generally happens via MusicController when changing the global beatmap. /// + [NotNull] public Track Track { get @@ -166,6 +178,12 @@ namespace osu.Game.Beatmaps #endregion + #region Waveform + + public Waveform Waveform => waveform ??= GetWaveform(); + + #endregion + #region Beatmap public virtual bool BeatmapLoaded => beatmapLoadTask?.IsCompleted ?? false; @@ -277,12 +295,15 @@ namespace osu.Game.Beatmaps } } - IBeatmapProcessor processor = rulesetInstance.CreateBeatmapProcessor(converted); + var processor = rulesetInstance.CreateBeatmapProcessor(converted); - foreach (var mod in mods.OfType()) - mod.ApplyToBeatmapProcessor(processor); + if (processor != null) + { + foreach (var mod in mods.OfType()) + mod.ApplyToBeatmapProcessor(processor); - processor?.PreProcess(); + processor.PreProcess(); + } // Compute default values for hitobjects, including creating nested hitobjects in-case they're needed foreach (var obj in converted.HitObjects) diff --git a/osu.Game/Beatmaps/WorkingBeatmapCache.cs b/osu.Game/Beatmaps/WorkingBeatmapCache.cs index bc810ee35e..76a31a6f78 100644 --- a/osu.Game/Beatmaps/WorkingBeatmapCache.cs +++ b/osu.Game/Beatmaps/WorkingBeatmapCache.cs @@ -1,12 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.IO; using System.Linq; using JetBrains.Annotations; using osu.Framework.Audio; using osu.Framework.Audio.Track; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Rendering.Dummy; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Framework.Lists; @@ -54,7 +58,7 @@ namespace osu.Game.Beatmaps this.resources = resources; this.host = host; this.files = files; - largeTextureStore = new LargeTextureStore(host?.CreateTextureLoaderStore(files)); + largeTextureStore = new LargeTextureStore(host?.Renderer ?? new DummyRenderer(), host?.CreateTextureLoaderStore(files)); this.trackStore = trackStore; } @@ -74,10 +78,13 @@ namespace osu.Game.Beatmaps { Logger.Log($"Invalidating working beatmap cache for {info}"); workingCache.Remove(working); + OnInvalidated?.Invoke(working); } } } + public event Action OnInvalidated; + public virtual WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo) { if (beatmapInfo?.BeatmapSet == null) @@ -105,6 +112,7 @@ namespace osu.Game.Beatmaps TextureStore IBeatmapResourceProvider.LargeTextureStore => largeTextureStore; ITrackStore IBeatmapResourceProvider.Tracks => trackStore; + IRenderer IStorageResourceProvider.Renderer => host?.Renderer ?? new DummyRenderer(); AudioManager IStorageResourceProvider.AudioManager => audioManager; RealmAccess IStorageResourceProvider.RealmAccess => null; IResourceStore IStorageResourceProvider.Files => files; @@ -132,8 +140,20 @@ namespace osu.Game.Beatmaps try { - using (var stream = new LineBufferedReader(GetStream(BeatmapSetInfo.GetPathForFile(BeatmapInfo.Path)))) - return Decoder.GetDecoder(stream).Decode(stream); + string fileStorePath = BeatmapSetInfo.GetPathForFile(BeatmapInfo.Path); + + // TODO: check validity of file + + var stream = GetStream(fileStorePath); + + if (stream == null) + { + Logger.Log($"Beatmap failed to load (file {BeatmapInfo.Path} not found on disk at expected location {fileStorePath}).", level: LogLevel.Error); + return null; + } + + using (var reader = new LineBufferedReader(stream)) + return Decoder.GetDecoder(reader).Decode(reader); } catch (Exception e) { @@ -149,7 +169,16 @@ namespace osu.Game.Beatmaps try { - return resources.LargeTextureStore.Get(BeatmapSetInfo.GetPathForFile(Metadata.BackgroundFile)); + string fileStorePath = BeatmapSetInfo.GetPathForFile(Metadata.BackgroundFile); + var texture = resources.LargeTextureStore.Get(fileStorePath); + + if (texture == null) + { + Logger.Log($"Beatmap background failed to load (file {Metadata.BackgroundFile} not found on disk at expected location {fileStorePath})."); + return null; + } + + return texture; } catch (Exception e) { @@ -168,7 +197,16 @@ namespace osu.Game.Beatmaps try { - return resources.Tracks.Get(BeatmapSetInfo.GetPathForFile(Metadata.AudioFile)); + string fileStorePath = BeatmapSetInfo.GetPathForFile(Metadata.AudioFile); + var track = resources.Tracks.Get(fileStorePath); + + if (track == null) + { + Logger.Log($"Beatmap failed to load (file {Metadata.AudioFile} not found on disk at expected location {fileStorePath}).", level: LogLevel.Error); + return null; + } + + return track; } catch (Exception e) { @@ -187,8 +225,17 @@ namespace osu.Game.Beatmaps try { - var trackData = GetStream(BeatmapSetInfo.GetPathForFile(Metadata.AudioFile)); - return trackData == null ? null : new Waveform(trackData); + string fileStorePath = BeatmapSetInfo.GetPathForFile(Metadata.AudioFile); + + var trackData = GetStream(fileStorePath); + + if (trackData == null) + { + Logger.Log($"Beatmap waveform failed to load (file {Metadata.AudioFile} not found on disk at expected location {fileStorePath}).", level: LogLevel.Error); + return null; + } + + return new Waveform(trackData); } catch (Exception e) { @@ -206,20 +253,41 @@ namespace osu.Game.Beatmaps try { - using (var stream = new LineBufferedReader(GetStream(BeatmapSetInfo.GetPathForFile(BeatmapInfo.Path)))) + string fileStorePath = BeatmapSetInfo.GetPathForFile(BeatmapInfo.Path); + var beatmapFileStream = GetStream(fileStorePath); + + if (beatmapFileStream == null) { - var decoder = Decoder.GetDecoder(stream); + Logger.Log($"Beatmap failed to load (file {BeatmapInfo.Path} not found on disk at expected location {fileStorePath})", level: LogLevel.Error); + return null; + } - string storyboardFilename = BeatmapSetInfo?.Files.FirstOrDefault(f => f.Filename.EndsWith(".osb", StringComparison.OrdinalIgnoreCase))?.Filename; + using (var reader = new LineBufferedReader(beatmapFileStream)) + { + var decoder = Decoder.GetDecoder(reader); - // todo: support loading from both set-wide storyboard *and* beatmap specific. - if (string.IsNullOrEmpty(storyboardFilename)) - storyboard = decoder.Decode(stream); - else + Stream storyboardFileStream = null; + + string mainStoryboardFilename = getMainStoryboardFilename(BeatmapSetInfo.Metadata); + + if (BeatmapSetInfo?.Files.FirstOrDefault(f => f.Filename.Equals(mainStoryboardFilename, StringComparison.OrdinalIgnoreCase))?.Filename is string + storyboardFilename) { - using (var secondaryStream = new LineBufferedReader(GetStream(BeatmapSetInfo.GetPathForFile(storyboardFilename)))) - storyboard = decoder.Decode(stream, secondaryStream); + string storyboardFileStorePath = BeatmapSetInfo?.GetPathForFile(storyboardFilename); + storyboardFileStream = GetStream(storyboardFileStorePath); + + if (storyboardFileStream == null) + Logger.Log($"Storyboard failed to load (file {storyboardFilename} not found on disk at expected location {storyboardFileStorePath})", level: LogLevel.Error); } + + if (storyboardFileStream != null) + { + // Stand-alone storyboard was found, so parse in addition to the beatmap's local storyboard. + using (var secondaryReader = new LineBufferedReader(storyboardFileStream)) + storyboard = decoder.Decode(reader, secondaryReader); + } + else + storyboard = decoder.Decode(reader); } } catch (Exception e) @@ -247,6 +315,33 @@ namespace osu.Game.Beatmaps } public override Stream GetStream(string storagePath) => resources.Files.GetStream(storagePath); + + private string getMainStoryboardFilename(IBeatmapMetadataInfo metadata) + { + // Matches stable implementation, because it's probably simpler than trying to do anything else. + // This may need to be reconsidered after we begin storing storyboards in the new editor. + return windowsFilenameStrip( + (metadata.Artist.Length > 0 ? metadata.Artist + @" - " + metadata.Title : Path.GetFileNameWithoutExtension(metadata.AudioFile)) + + (metadata.Author.Username.Length > 0 ? @" (" + metadata.Author.Username + @")" : string.Empty) + + @".osb"); + + string windowsFilenameStrip(string entry) + { + // Inlined from Path.GetInvalidFilenameChars() to ensure the windows characters are used (to match stable). + char[] invalidCharacters = + { + '\x00', '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', + '\x08', '\x09', '\x0A', '\x0B', '\x0C', '\x0D', '\x0E', '\x0F', '\x10', '\x11', '\x12', + '\x13', '\x14', '\x15', '\x16', '\x17', '\x18', '\x19', '\x1A', '\x1B', '\x1C', '\x1D', + '\x1E', '\x1F', '\x22', '\x3C', '\x3E', '\x7C', ':', '*', '?', '\\', '/' + }; + + foreach (char c in invalidCharacters) + entry = entry.Replace(c.ToString(), string.Empty); + + return entry; + } + } } } } diff --git a/osu.Game/Collections/BeatmapCollection.cs b/osu.Game/Collections/BeatmapCollection.cs index 7e4b15ecf9..ca5f8dbe53 100644 --- a/osu.Game/Collections/BeatmapCollection.cs +++ b/osu.Game/Collections/BeatmapCollection.cs @@ -2,46 +2,56 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osu.Framework.Bindables; +using System.Collections.Generic; +using JetBrains.Annotations; using osu.Game.Beatmaps; +using osu.Game.Database; +using Realms; namespace osu.Game.Collections { /// /// A collection of beatmaps grouped by a name. /// - public class BeatmapCollection + public class BeatmapCollection : RealmObject, IHasGuidPrimaryKey { - /// - /// Invoked whenever any change occurs on this . - /// - public event Action Changed; + [PrimaryKey] + public Guid ID { get; set; } /// /// The collection's name. /// - public readonly Bindable Name = new Bindable(); + public string Name { get; set; } = string.Empty; /// - /// The beatmaps contained by the collection. + /// The es of beatmaps contained by the collection. /// - public readonly BindableList Beatmaps = new BindableList(); + /// + /// We store as hashes rather than references to s to allow collections to maintain + /// references to beatmaps even if they are removed. This helps with cases like importing collections before + /// importing the beatmaps they contain, or when sharing collections between users. + /// + /// This can probably change in the future as we build the system up. + /// + public IList BeatmapMD5Hashes { get; } = null!; /// /// The date when this collection was last modified. /// - public DateTimeOffset LastModifyDate { get; private set; } = DateTimeOffset.UtcNow; + public DateTimeOffset LastModified { get; set; } - public BeatmapCollection() + public BeatmapCollection(string? name = null, IList? beatmapMD5Hashes = null) { - Beatmaps.CollectionChanged += (_, __) => onChange(); - Name.ValueChanged += _ => onChange(); + ID = Guid.NewGuid(); + Name = name ?? string.Empty; + BeatmapMD5Hashes = beatmapMD5Hashes ?? new List(); + + LastModified = DateTimeOffset.UtcNow; } - private void onChange() + [UsedImplicitly] + private BeatmapCollection() { - LastModifyDate = DateTimeOffset.Now; - Changed?.Invoke(); } } } diff --git a/osu.Game/Collections/CollectionDropdown.cs b/osu.Game/Collections/CollectionDropdown.cs new file mode 100644 index 0000000000..19fa3a3d66 --- /dev/null +++ b/osu.Game/Collections/CollectionDropdown.cs @@ -0,0 +1,258 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osuTK; +using Realms; + +namespace osu.Game.Collections +{ + /// + /// A dropdown to select the collection to be used to filter results. + /// + public partial class CollectionDropdown : OsuDropdown + { + /// + /// Whether to show the "manage collections..." menu item in the dropdown. + /// + protected virtual bool ShowManageCollectionsItem => true; + + public Action? RequestFilter { private get; set; } + + private readonly BindableList filters = new BindableList(); + + [Resolved] + private ManageCollectionsDialog? manageCollectionsDialog { get; set; } + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + private IDisposable? realmSubscription; + + public CollectionDropdown() + { + ItemSource = filters; + + Current.Value = new AllBeatmapsCollectionFilterMenuItem(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + realmSubscription = realm.RegisterForNotifications(r => r.All().OrderBy(c => c.Name), collectionsChanged); + + Current.BindValueChanged(selectionChanged); + } + + private void collectionsChanged(IRealmCollection collections, ChangeSet? changes, Exception error) + { + var selectedItem = SelectedItem?.Value?.Collection; + + var allBeatmaps = new AllBeatmapsCollectionFilterMenuItem(); + + filters.Clear(); + filters.Add(allBeatmaps); + filters.AddRange(collections.Select(c => new CollectionFilterMenuItem(c.ToLive(realm)))); + + if (ShowManageCollectionsItem) + filters.Add(new ManageCollectionsFilterMenuItem()); + + // This current update and schedule is required to work around dropdown headers not updating text even when the selected item + // changes. It's not great but honestly the whole dropdown menu structure isn't great. This needs to be fixed, but I'll issue + // a warning that it's going to be a frustrating journey. + Current.Value = allBeatmaps; + Schedule(() => + { + // current may have changed before the scheduled call is run. + if (Current.Value != allBeatmaps) + return; + + Current.Value = filters.SingleOrDefault(f => f.Collection != null && f.Collection.ID == selectedItem?.ID) ?? filters[0]; + }); + + // Trigger a re-filter if the current item was in the change set. + if (selectedItem != null && changes != null) + { + foreach (int index in changes.ModifiedIndices) + { + if (collections[index].ID == selectedItem.ID) + RequestFilter?.Invoke(); + } + } + } + + private Live? lastFiltered; + + private void selectionChanged(ValueChangedEvent filter) + { + // May be null during .Clear(). + if (filter.NewValue.IsNull()) + return; + + // Never select the manage collection filter - rollback to the previous filter. + // This is done after the above since it is important that bindable is unbound from OldValue, which is lost after forcing it back to the old value. + if (filter.NewValue is ManageCollectionsFilterMenuItem) + { + Current.Value = filter.OldValue; + manageCollectionsDialog?.Show(); + return; + } + + var newCollection = filter.NewValue.Collection; + + // This dropdown be weird. + // We only care about filtering if the actual collection has changed. + if (newCollection != lastFiltered) + { + RequestFilter?.Invoke(); + lastFiltered = newCollection; + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + realmSubscription?.Dispose(); + } + + protected override LocalisableString GenerateItemText(CollectionFilterMenuItem item) => item.CollectionName; + + protected sealed override DropdownHeader CreateHeader() => CreateCollectionHeader(); + + protected sealed override DropdownMenu CreateMenu() => CreateCollectionMenu(); + + protected virtual CollectionDropdownHeader CreateCollectionHeader() => new CollectionDropdownHeader(); + + protected virtual CollectionDropdownMenu CreateCollectionMenu() => new CollectionDropdownMenu(); + + public partial class CollectionDropdownHeader : OsuDropdownHeader + { + public CollectionDropdownHeader() + { + Height = 25; + Icon.Size = new Vector2(16); + Foreground.Padding = new MarginPadding { Top = 4, Bottom = 4, Left = 8, Right = 4 }; + } + } + + protected partial class CollectionDropdownMenu : OsuDropdownMenu + { + public CollectionDropdownMenu() + { + MaxHeight = 200; + } + + protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new CollectionDropdownDrawableMenuItem(item) + { + BackgroundColourHover = HoverColour, + BackgroundColourSelected = SelectionColour + }; + } + + protected partial class CollectionDropdownDrawableMenuItem : OsuDropdownMenu.DrawableOsuDropdownMenuItem + { + private IconButton addOrRemoveButton = null!; + + private bool beatmapInCollection; + + private readonly Live? collection; + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + public CollectionDropdownDrawableMenuItem(MenuItem item) + : base(item) + { + collection = ((DropdownMenuItem)item).Value.Collection; + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(addOrRemoveButton = new IconButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + X = -OsuScrollContainer.SCROLL_BAR_HEIGHT, + Scale = new Vector2(0.65f), + Action = addOrRemove, + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (collection != null) + { + beatmap.BindValueChanged(_ => + { + beatmapInCollection = collection.PerformRead(c => c.BeatmapMD5Hashes.Contains(beatmap.Value.BeatmapInfo.MD5Hash)); + + addOrRemoveButton.Enabled.Value = !beatmap.IsDefault; + addOrRemoveButton.Icon = beatmapInCollection ? FontAwesome.Solid.MinusSquare : FontAwesome.Solid.PlusSquare; + addOrRemoveButton.TooltipText = beatmapInCollection ? "Remove selected beatmap" : "Add selected beatmap"; + + updateButtonVisibility(); + }, true); + } + + updateButtonVisibility(); + } + + protected override bool OnHover(HoverEvent e) + { + updateButtonVisibility(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateButtonVisibility(); + base.OnHoverLost(e); + } + + protected override void OnSelectChange() + { + base.OnSelectChange(); + updateButtonVisibility(); + } + + private void updateButtonVisibility() + { + if (collection == null) + addOrRemoveButton.Alpha = 0; + else + addOrRemoveButton.Alpha = IsHovered || IsPreSelected || beatmapInCollection ? 1 : 0; + } + + private void addOrRemove() + { + Debug.Assert(collection != null); + + collection.PerformWrite(c => + { + if (!c.BeatmapMD5Hashes.Remove(beatmap.Value.BeatmapInfo.MD5Hash)) + c.BeatmapMD5Hashes.Add(beatmap.Value.BeatmapInfo.MD5Hash); + }); + } + + protected override Drawable CreateContent() => (Content)base.CreateContent(); + } + } +} diff --git a/osu.Game/Collections/CollectionFilterDropdown.cs b/osu.Game/Collections/CollectionFilterDropdown.cs deleted file mode 100644 index c46ba8e06e..0000000000 --- a/osu.Game/Collections/CollectionFilterDropdown.cs +++ /dev/null @@ -1,295 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Specialized; -using System.Diagnostics; -using System.Linq; -using JetBrains.Annotations; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Events; -using osu.Framework.Localisation; -using osu.Game.Beatmaps; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; -using osuTK; - -namespace osu.Game.Collections -{ - /// - /// A dropdown to select the to filter beatmaps using. - /// - public class CollectionFilterDropdown : OsuDropdown - { - /// - /// Whether to show the "manage collections..." menu item in the dropdown. - /// - protected virtual bool ShowManageCollectionsItem => true; - - private readonly BindableWithCurrent current = new BindableWithCurrent(); - - public new Bindable Current - { - get => current.Current; - set => current.Current = value; - } - - private readonly IBindableList collections = new BindableList(); - private readonly IBindableList beatmaps = new BindableList(); - private readonly BindableList filters = new BindableList(); - - [Resolved(CanBeNull = true)] - private ManageCollectionsDialog manageCollectionsDialog { get; set; } - - [Resolved(CanBeNull = true)] - private CollectionManager collectionManager { get; set; } - - public CollectionFilterDropdown() - { - ItemSource = filters; - Current.Value = new AllBeatmapsCollectionFilterMenuItem(); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - if (collectionManager != null) - collections.BindTo(collectionManager.Collections); - - // Dropdown has logic which triggers a change on the bindable with every change to the contained items. - // This is not desirable here, as it leads to multiple filter operations running even though nothing has changed. - // An extra bindable is enough to subvert this behaviour. - base.Current = Current; - - collections.BindCollectionChanged((_, __) => collectionsChanged(), true); - Current.BindValueChanged(filterChanged, true); - } - - /// - /// Occurs when a collection has been added or removed. - /// - private void collectionsChanged() - { - var selectedItem = SelectedItem?.Value?.Collection; - - filters.Clear(); - filters.Add(new AllBeatmapsCollectionFilterMenuItem()); - filters.AddRange(collections.Select(c => new CollectionFilterMenuItem(c))); - - if (ShowManageCollectionsItem) - filters.Add(new ManageCollectionsFilterMenuItem()); - - Current.Value = filters.SingleOrDefault(f => f.Collection != null && f.Collection == selectedItem) ?? filters[0]; - } - - /// - /// Occurs when the selection has changed. - /// - private void filterChanged(ValueChangedEvent filter) - { - // Binding the beatmaps will trigger a collection change event, which results in an infinite-loop. This is rebound later, when it's safe to do so. - beatmaps.CollectionChanged -= filterBeatmapsChanged; - - if (filter.OldValue?.Collection != null) - beatmaps.UnbindFrom(filter.OldValue.Collection.Beatmaps); - - if (filter.NewValue?.Collection != null) - beatmaps.BindTo(filter.NewValue.Collection.Beatmaps); - - beatmaps.CollectionChanged += filterBeatmapsChanged; - - // Never select the manage collection filter - rollback to the previous filter. - // This is done after the above since it is important that bindable is unbound from OldValue, which is lost after forcing it back to the old value. - if (filter.NewValue is ManageCollectionsFilterMenuItem) - { - Current.Value = filter.OldValue; - manageCollectionsDialog?.Show(); - } - } - - /// - /// Occurs when the beatmaps contained by a have changed. - /// - private void filterBeatmapsChanged(object sender, NotifyCollectionChangedEventArgs e) - { - // The filtered beatmaps have changed, without the filter having changed itself. So a change in filter must be notified. - // Note that this does NOT propagate to bound bindables, so the FilterControl must bind directly to the value change event of this bindable. - Current.TriggerChange(); - } - - protected override LocalisableString GenerateItemText(CollectionFilterMenuItem item) => item.CollectionName.Value; - - protected sealed override DropdownHeader CreateHeader() => CreateCollectionHeader().With(d => - { - d.SelectedItem.BindTarget = Current; - }); - - protected sealed override DropdownMenu CreateMenu() => CreateCollectionMenu(); - - protected virtual CollectionDropdownHeader CreateCollectionHeader() => new CollectionDropdownHeader(); - - protected virtual CollectionDropdownMenu CreateCollectionMenu() => new CollectionDropdownMenu(); - - public class CollectionDropdownHeader : OsuDropdownHeader - { - public readonly Bindable SelectedItem = new Bindable(); - private readonly Bindable collectionName = new Bindable(); - - protected override LocalisableString Label - { - get => base.Label; - set { } // See updateText(). - } - - public CollectionDropdownHeader() - { - Height = 25; - Icon.Size = new Vector2(16); - Foreground.Padding = new MarginPadding { Top = 4, Bottom = 4, Left = 8, Right = 4 }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - SelectedItem.BindValueChanged(_ => updateBindable(), true); - } - - private void updateBindable() - { - collectionName.UnbindAll(); - - if (SelectedItem.Value != null) - collectionName.BindTo(SelectedItem.Value.CollectionName); - - collectionName.BindValueChanged(_ => updateText(), true); - } - - // Dropdowns don't bind to value changes, so the real name is copied directly from the selected item here. - private void updateText() => base.Label = collectionName.Value; - } - - protected class CollectionDropdownMenu : OsuDropdownMenu - { - public CollectionDropdownMenu() - { - MaxHeight = 200; - } - - protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new CollectionDropdownMenuItem(item) - { - BackgroundColourHover = HoverColour, - BackgroundColourSelected = SelectionColour - }; - } - - protected class CollectionDropdownMenuItem : OsuDropdownMenu.DrawableOsuDropdownMenuItem - { - [NotNull] - protected new CollectionFilterMenuItem Item => ((DropdownMenuItem)base.Item).Value; - - [Resolved] - private IBindable beatmap { get; set; } - - [CanBeNull] - private readonly BindableList collectionBeatmaps; - - [NotNull] - private readonly Bindable collectionName; - - private IconButton addOrRemoveButton; - private Content content; - private bool beatmapInCollection; - - public CollectionDropdownMenuItem(MenuItem item) - : base(item) - { - collectionBeatmaps = Item.Collection?.Beatmaps.GetBoundCopy(); - collectionName = Item.CollectionName.GetBoundCopy(); - } - - [BackgroundDependencyLoader] - private void load() - { - AddInternal(addOrRemoveButton = new IconButton - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - X = -OsuScrollContainer.SCROLL_BAR_HEIGHT, - Scale = new Vector2(0.65f), - Action = addOrRemove, - }); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - if (collectionBeatmaps != null) - { - collectionBeatmaps.CollectionChanged += (_, __) => collectionChanged(); - beatmap.BindValueChanged(_ => collectionChanged(), true); - } - - // Although the DrawableMenuItem binds to value changes of the item's text, the item is an internal implementation detail of Dropdown that has no knowledge - // of the underlying CollectionFilter value and its accompanying name, so the real name has to be copied here. Without this, the collection name wouldn't update when changed. - collectionName.BindValueChanged(name => content.Text = name.NewValue, true); - - updateButtonVisibility(); - } - - protected override bool OnHover(HoverEvent e) - { - updateButtonVisibility(); - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - updateButtonVisibility(); - base.OnHoverLost(e); - } - - private void collectionChanged() - { - Debug.Assert(collectionBeatmaps != null); - - beatmapInCollection = collectionBeatmaps.Contains(beatmap.Value.BeatmapInfo); - - addOrRemoveButton.Enabled.Value = !beatmap.IsDefault; - addOrRemoveButton.Icon = beatmapInCollection ? FontAwesome.Solid.MinusSquare : FontAwesome.Solid.PlusSquare; - addOrRemoveButton.TooltipText = beatmapInCollection ? "Remove selected beatmap" : "Add selected beatmap"; - - updateButtonVisibility(); - } - - protected override void OnSelectChange() - { - base.OnSelectChange(); - updateButtonVisibility(); - } - - private void updateButtonVisibility() - { - if (collectionBeatmaps == null) - addOrRemoveButton.Alpha = 0; - else - addOrRemoveButton.Alpha = IsHovered || IsPreSelected || beatmapInCollection ? 1 : 0; - } - - private void addOrRemove() - { - Debug.Assert(collectionBeatmaps != null); - - if (!collectionBeatmaps.Remove(beatmap.Value.BeatmapInfo)) - collectionBeatmaps.Add(beatmap.Value.BeatmapInfo); - } - - protected override Drawable CreateContent() => content = (Content)base.CreateContent(); - } - } -} diff --git a/osu.Game/Collections/CollectionFilterMenuItem.cs b/osu.Game/Collections/CollectionFilterMenuItem.cs index 0617996872..2ac5784f09 100644 --- a/osu.Game/Collections/CollectionFilterMenuItem.cs +++ b/osu.Game/Collections/CollectionFilterMenuItem.cs @@ -2,8 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; -using JetBrains.Annotations; -using osu.Framework.Bindables; +using osu.Game.Database; namespace osu.Game.Collections { @@ -16,26 +15,29 @@ namespace osu.Game.Collections /// The collection to filter beatmaps from. /// May be null to not filter by collection (include all beatmaps). /// - [CanBeNull] - public readonly BeatmapCollection Collection; + public readonly Live? Collection; /// /// The name of the collection. /// - [NotNull] - public readonly Bindable CollectionName; + public string CollectionName { get; } /// /// Creates a new . /// /// The collection to filter beatmaps from. - public CollectionFilterMenuItem([CanBeNull] BeatmapCollection collection) + public CollectionFilterMenuItem(Live collection) + : this(collection.PerformRead(c => c.Name)) { Collection = collection; - CollectionName = Collection?.Name.GetBoundCopy() ?? new Bindable("All beatmaps"); } - public bool Equals(CollectionFilterMenuItem other) + protected CollectionFilterMenuItem(string name) + { + CollectionName = name; + } + + public bool Equals(CollectionFilterMenuItem? other) { if (other == null) return false; @@ -43,20 +45,20 @@ namespace osu.Game.Collections // collections may have the same name, so compare first on reference equality. // this relies on the assumption that only one instance of the BeatmapCollection exists game-wide, managed by CollectionManager. if (Collection != null) - return Collection == other.Collection; + return Collection.ID == other.Collection?.ID; // fallback to name-based comparison. // this is required for special dropdown items which don't have a collection (all beatmaps / manage collections items below). - return CollectionName.Value == other.CollectionName.Value; + return CollectionName == other.CollectionName; } - public override int GetHashCode() => CollectionName.Value.GetHashCode(); + public override int GetHashCode() => CollectionName.GetHashCode(); } public class AllBeatmapsCollectionFilterMenuItem : CollectionFilterMenuItem { public AllBeatmapsCollectionFilterMenuItem() - : base(null) + : base("All beatmaps") { } } @@ -64,9 +66,8 @@ namespace osu.Game.Collections public class ManageCollectionsFilterMenuItem : CollectionFilterMenuItem { public ManageCollectionsFilterMenuItem() - : base(null) + : base("Manage collections...") { - CollectionName.Value = "Manage collections..."; } } } diff --git a/osu.Game/Collections/CollectionManager.cs b/osu.Game/Collections/CollectionManager.cs deleted file mode 100644 index 700b0f5dcb..0000000000 --- a/osu.Game/Collections/CollectionManager.cs +++ /dev/null @@ -1,352 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Logging; -using osu.Framework.Platform; -using osu.Game.Beatmaps; -using osu.Game.Database; -using osu.Game.IO; -using osu.Game.IO.Legacy; -using osu.Game.Overlays.Notifications; - -namespace osu.Game.Collections -{ - /// - /// Handles user-defined collections of beatmaps. - /// - /// - /// This is currently reading and writing from the osu-stable file format. This is a temporary arrangement until we refactor the - /// database backing the game. Going forward writing should be done in a similar way to other model stores. - /// - public class CollectionManager : Component, IPostNotifications - { - /// - /// Database version in stable-compatible YYYYMMDD format. - /// - private const int database_version = 30000000; - - private const string database_name = "collection.db"; - private const string database_backup_name = "collection.db.bak"; - - public readonly BindableList Collections = new BindableList(); - - [Resolved] - private BeatmapManager beatmaps { get; set; } - - private readonly Storage storage; - - public CollectionManager(Storage storage) - { - this.storage = storage; - } - - [Resolved(canBeNull: true)] - private DatabaseContextFactory efContextFactory { get; set; } = null!; - - [BackgroundDependencyLoader] - private void load() - { - efContextFactory?.WaitForMigrationCompletion(); - - Collections.CollectionChanged += collectionsChanged; - - if (storage.Exists(database_backup_name)) - { - // If a backup file exists, it means the previous write operation didn't run to completion. - // Always prefer the backup file in such a case as it's the most recent copy that is guaranteed to not be malformed. - // - // The database is saved 100ms after any change, and again when the game is closed, so there shouldn't be a large diff between the two files in the worst case. - if (storage.Exists(database_name)) - storage.Delete(database_name); - File.Copy(storage.GetFullPath(database_backup_name), storage.GetFullPath(database_name)); - } - - if (storage.Exists(database_name)) - { - List beatmapCollections; - - using (var stream = storage.GetStream(database_name)) - beatmapCollections = readCollections(stream); - - // intentionally fire-and-forget async. - importCollections(beatmapCollections); - } - } - - private void collectionsChanged(object sender, NotifyCollectionChangedEventArgs e) => Schedule(() => - { - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - foreach (var c in e.NewItems.Cast()) - c.Changed += backgroundSave; - break; - - case NotifyCollectionChangedAction.Remove: - foreach (var c in e.OldItems.Cast()) - c.Changed -= backgroundSave; - break; - - case NotifyCollectionChangedAction.Replace: - foreach (var c in e.OldItems.Cast()) - c.Changed -= backgroundSave; - - foreach (var c in e.NewItems.Cast()) - c.Changed += backgroundSave; - break; - } - - backgroundSave(); - }); - - public Action PostNotification { protected get; set; } - - public Task GetAvailableCount(StableStorage stableStorage) - { - if (!stableStorage.Exists(database_name)) - return Task.FromResult(0); - - return Task.Run(() => - { - using (var stream = stableStorage.GetStream(database_name)) - return readCollections(stream).Count; - }); - } - - /// - /// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future. - /// - public Task ImportFromStableAsync(StableStorage stableStorage) - { - if (!stableStorage.Exists(database_name)) - { - // This handles situations like when the user does not have a collections.db file - Logger.Log($"No {database_name} available in osu!stable installation", LoggingTarget.Information, LogLevel.Error); - return Task.CompletedTask; - } - - return Task.Run(async () => - { - using (var stream = stableStorage.GetStream(database_name)) - await Import(stream).ConfigureAwait(false); - }); - } - - public async Task Import(Stream stream) - { - var notification = new ProgressNotification - { - State = ProgressNotificationState.Active, - Text = "Collections import is initialising..." - }; - - PostNotification?.Invoke(notification); - - var collections = readCollections(stream, notification); - await importCollections(collections).ConfigureAwait(false); - - notification.CompletionText = $"Imported {collections.Count} collections"; - notification.State = ProgressNotificationState.Completed; - } - - private Task importCollections(List newCollections) - { - var tcs = new TaskCompletionSource(); - - Schedule(() => - { - try - { - foreach (var newCol in newCollections) - { - var existing = Collections.FirstOrDefault(c => c.Name.Value == newCol.Name.Value); - if (existing == null) - Collections.Add(existing = new BeatmapCollection { Name = { Value = newCol.Name.Value } }); - - foreach (var newBeatmap in newCol.Beatmaps) - { - if (!existing.Beatmaps.Contains(newBeatmap)) - existing.Beatmaps.Add(newBeatmap); - } - } - - tcs.SetResult(true); - } - catch (Exception e) - { - Logger.Error(e, "Failed to import collection."); - tcs.SetException(e); - } - }); - - return tcs.Task; - } - - private List readCollections(Stream stream, ProgressNotification notification = null) - { - if (notification != null) - { - notification.Text = "Reading collections..."; - notification.Progress = 0; - } - - var result = new List(); - - try - { - using (var sr = new SerializationReader(stream)) - { - sr.ReadInt32(); // Version - - int collectionCount = sr.ReadInt32(); - result.Capacity = collectionCount; - - for (int i = 0; i < collectionCount; i++) - { - if (notification?.CancellationToken.IsCancellationRequested == true) - return result; - - var collection = new BeatmapCollection { Name = { Value = sr.ReadString() } }; - int mapCount = sr.ReadInt32(); - - for (int j = 0; j < mapCount; j++) - { - if (notification?.CancellationToken.IsCancellationRequested == true) - return result; - - string checksum = sr.ReadString(); - - var beatmap = beatmaps.QueryBeatmap(b => b.MD5Hash == checksum); - if (beatmap != null) - collection.Beatmaps.Add(beatmap); - } - - if (notification != null) - { - notification.Text = $"Imported {i + 1} of {collectionCount} collections"; - notification.Progress = (float)(i + 1) / collectionCount; - } - - result.Add(collection); - } - } - } - catch (Exception e) - { - Logger.Error(e, "Failed to read collection database."); - } - - return result; - } - - public void DeleteAll() - { - Collections.Clear(); - PostNotification?.Invoke(new ProgressCompletionNotification { Text = "Deleted all collections!" }); - } - - private readonly object saveLock = new object(); - private int lastSave; - private int saveFailures; - - /// - /// Perform a save with debounce. - /// - private void backgroundSave() - { - int current = Interlocked.Increment(ref lastSave); - Task.Delay(100).ContinueWith(task => - { - if (current != lastSave) - return; - - if (!save()) - backgroundSave(); - }); - } - - private bool save() - { - lock (saveLock) - { - Interlocked.Increment(ref lastSave); - - // This is NOT thread-safe!! - try - { - string tempPath = Path.GetTempFileName(); - - using (var ms = new MemoryStream()) - { - using (var sw = new SerializationWriter(ms, true)) - { - sw.Write(database_version); - - var collectionsCopy = Collections.ToArray(); - sw.Write(collectionsCopy.Length); - - foreach (var c in collectionsCopy) - { - sw.Write(c.Name.Value); - - var beatmapsCopy = c.Beatmaps.ToArray(); - sw.Write(beatmapsCopy.Length); - - foreach (var b in beatmapsCopy) - sw.Write(b.MD5Hash); - } - } - - using (var fs = File.OpenWrite(tempPath)) - ms.WriteTo(fs); - - string databasePath = storage.GetFullPath(database_name); - string databaseBackupPath = storage.GetFullPath(database_backup_name); - - // Back up the existing database, clearing any existing backup. - if (File.Exists(databaseBackupPath)) - File.Delete(databaseBackupPath); - if (File.Exists(databasePath)) - File.Move(databasePath, databaseBackupPath); - - // Move the new database in-place of the existing one. - File.Move(tempPath, databasePath); - - // If everything succeeded up to this point, remove the backup file. - if (File.Exists(databaseBackupPath)) - File.Delete(databaseBackupPath); - } - - if (saveFailures < 10) - saveFailures = 0; - return true; - } - catch (Exception e) - { - // Since this code is not thread-safe, we may run into random exceptions (such as collection enumeration or out of range indexing). - // Failures are thus only alerted if they exceed a threshold (once) to indicate "actual" errors having occurred. - if (++saveFailures == 10) - Logger.Error(e, "Failed to save collection database!"); - } - - return false; - } - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - save(); - } - } -} diff --git a/osu.Game/Collections/CollectionToggleMenuItem.cs b/osu.Game/Collections/CollectionToggleMenuItem.cs new file mode 100644 index 0000000000..5ad06a72c0 --- /dev/null +++ b/osu.Game/Collections/CollectionToggleMenuItem.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Collections +{ + public class CollectionToggleMenuItem : ToggleMenuItem + { + public CollectionToggleMenuItem(Live collection, IBeatmapInfo beatmap) + : base(collection.PerformRead(c => c.Name), MenuItemType.Standard, state => + { + collection.PerformWrite(c => + { + if (state) + c.BeatmapMD5Hashes.Add(beatmap.MD5Hash); + else + c.BeatmapMD5Hashes.Remove(beatmap.MD5Hash); + }); + }) + { + State.Value = collection.PerformRead(c => c.BeatmapMD5Hashes.Contains(beatmap.MD5Hash)); + } + } +} diff --git a/osu.Game/Collections/DeleteCollectionDialog.cs b/osu.Game/Collections/DeleteCollectionDialog.cs index e5a2f6fb81..9edc213077 100644 --- a/osu.Game/Collections/DeleteCollectionDialog.cs +++ b/osu.Game/Collections/DeleteCollectionDialog.cs @@ -3,32 +3,17 @@ using System; using Humanizer; -using osu.Framework.Graphics.Sprites; +using osu.Game.Database; using osu.Game.Overlays.Dialog; namespace osu.Game.Collections { - public class DeleteCollectionDialog : PopupDialog + public partial class DeleteCollectionDialog : DangerousActionDialog { - public DeleteCollectionDialog(BeatmapCollection collection, Action deleteAction) + public DeleteCollectionDialog(Live collection, Action deleteAction) { - HeaderText = "Confirm deletion of"; - BodyText = $"{collection.Name.Value} ({"beatmap".ToQuantity(collection.Beatmaps.Count)})"; - - Icon = FontAwesome.Regular.TrashAlt; - - Buttons = new PopupDialogButton[] - { - new PopupDialogOkButton - { - Text = @"Yes. Go for it.", - Action = deleteAction - }, - new PopupDialogCancelButton - { - Text = @"No! Abort mission!", - }, - }; + BodyText = collection.PerformRead(c => $"{c.Name} ({"beatmap".ToQuantity(c.BeatmapMD5Hashes.Count)})"); + DangerousAction = deleteAction; } } } diff --git a/osu.Game/Collections/DrawableCollectionList.cs b/osu.Game/Collections/DrawableCollectionList.cs index 3c664a11d9..0fdf196c4a 100644 --- a/osu.Game/Collections/DrawableCollectionList.cs +++ b/osu.Game/Collections/DrawableCollectionList.cs @@ -1,37 +1,66 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Diagnostics; using System.Linq; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Database; using osu.Game.Graphics.Containers; using osuTK; +using Realms; namespace osu.Game.Collections { /// /// Visualises a list of s. /// - public class DrawableCollectionList : OsuRearrangeableListContainer + public partial class DrawableCollectionList : OsuRearrangeableListContainer> { - private Scroll scroll; - protected override ScrollContainer CreateScrollContainer() => scroll = new Scroll(); - protected override FillFlowContainer> CreateListFillFlowContainer() => new Flow + [Resolved] + private RealmAccess realm { get; set; } = null!; + + private Scroll scroll = null!; + + private IDisposable? realmSubscription; + + protected override FillFlowContainer>> CreateListFillFlowContainer() => new Flow { DragActive = { BindTarget = DragActive } }; - protected override OsuRearrangeableListItem CreateOsuDrawable(BeatmapCollection item) + protected override void LoadComplete() { - if (item == scroll.PlaceholderItem.Model) + base.LoadComplete(); + + realmSubscription = realm.RegisterForNotifications(r => r.All().OrderBy(c => c.Name), collectionsChanged); + } + + private void collectionsChanged(IRealmCollection collections, ChangeSet? changes, Exception error) + { + Items.Clear(); + Items.AddRange(collections.AsEnumerable().Select(c => c.ToLive(realm))); + } + + protected override OsuRearrangeableListItem> CreateOsuDrawable(Live item) + { + if (item.ID == scroll.PlaceholderItem.Model.ID) return scroll.ReplacePlaceholder(); return new DrawableCollectionListItem(item, true); } + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + realmSubscription?.Dispose(); + } + /// /// The scroll container for this . /// Contains the main flow of and attaches a placeholder item to the end of the list. @@ -39,12 +68,12 @@ namespace osu.Game.Collections /// /// Use to transfer the placeholder into the main list. /// - private class Scroll : OsuScrollContainer + private partial class Scroll : OsuScrollContainer { /// /// The currently-displayed placeholder item. /// - public DrawableCollectionListItem PlaceholderItem { get; private set; } + public DrawableCollectionListItem PlaceholderItem { get; private set; } = null!; protected override Container Content => content; private readonly Container content; @@ -74,6 +103,7 @@ namespace osu.Game.Collections }); ReplacePlaceholder(); + Debug.Assert(PlaceholderItem != null); } protected override void Update() @@ -93,7 +123,7 @@ namespace osu.Game.Collections var previous = PlaceholderItem; placeholderContainer.Clear(false); - placeholderContainer.Add(PlaceholderItem = new DrawableCollectionListItem(new BeatmapCollection(), false)); + placeholderContainer.Add(PlaceholderItem = new DrawableCollectionListItem(new BeatmapCollection().ToLiveUnmanaged(), false)); return previous; } @@ -102,7 +132,7 @@ namespace osu.Game.Collections /// /// The flow of . Disables layout easing unless a drag is in progress. /// - private class Flow : FillFlowContainer> + private partial class Flow : FillFlowContainer>> { public readonly IBindable DragActive = new Bindable(); diff --git a/osu.Game/Collections/DrawableCollectionListItem.cs b/osu.Game/Collections/DrawableCollectionListItem.cs index 5a20b7e7bd..23156b1ad5 100644 --- a/osu.Game/Collections/DrawableCollectionListItem.cs +++ b/osu.Game/Collections/DrawableCollectionListItem.cs @@ -3,13 +3,14 @@ using System; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; @@ -22,79 +23,62 @@ namespace osu.Game.Collections /// /// Visualises a inside a . /// - public class DrawableCollectionListItem : OsuRearrangeableListItem + public partial class DrawableCollectionListItem : OsuRearrangeableListItem> { private const float item_height = 35; private const float button_width = item_height * 0.75f; - /// - /// Whether the currently exists inside the . - /// - public IBindable IsCreated => isCreated; - - private readonly Bindable isCreated = new Bindable(); - /// /// Creates a new . /// /// The . - /// Whether currently exists inside the . - public DrawableCollectionListItem(BeatmapCollection item, bool isCreated) + /// Whether currently exists inside realm. + public DrawableCollectionListItem(Live item, bool isCreated) : base(item) { - this.isCreated.Value = isCreated; - - ShowDragHandle.BindTo(this.isCreated); + ShowDragHandle.Value = item.IsManaged; } - protected override Drawable CreateContent() => new ItemContent(Model) - { - IsCreated = { BindTarget = isCreated } - }; + protected override Drawable CreateContent() => new ItemContent(Model); /// /// The main content of the . /// - private class ItemContent : CircularContainer + private partial class ItemContent : CircularContainer { - public readonly Bindable IsCreated = new Bindable(); + private readonly Live collection; - private readonly IBindable collectionName; - private readonly BeatmapCollection collection; + private ItemTextBox textBox = null!; - [Resolved(CanBeNull = true)] - private CollectionManager collectionManager { get; set; } + [Resolved] + private RealmAccess realm { get; set; } = null!; - private Container textBoxPaddingContainer; - private ItemTextBox textBox; - - public ItemContent(BeatmapCollection collection) + public ItemContent(Live collection) { this.collection = collection; RelativeSizeAxes = Axes.X; Height = item_height; Masking = true; - - collectionName = collection.Name.GetBoundCopy(); } [BackgroundDependencyLoader] private void load() { - Children = new Drawable[] + Children = new[] { - new DeleteButton(collection) - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - IsCreated = { BindTarget = IsCreated }, - IsTextBoxHovered = v => textBox.ReceivePositionalInputAt(v) - }, - textBoxPaddingContainer = new Container + collection.IsManaged + ? new DeleteButton(collection) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + IsTextBoxHovered = v => textBox.ReceivePositionalInputAt(v) + } + : Empty(), + new Container { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Right = button_width }, + Padding = new MarginPadding { Right = collection.IsManaged ? button_width : 0 }, Children = new Drawable[] { textBox = new ItemTextBox @@ -102,7 +86,7 @@ namespace osu.Game.Collections RelativeSizeAxes = Axes.Both, Size = Vector2.One, CornerRadius = item_height / 2, - PlaceholderText = IsCreated.Value ? string.Empty : "Create a new collection" + PlaceholderText = collection.IsManaged ? string.Empty : "Create a new collection" }, } }, @@ -114,32 +98,22 @@ namespace osu.Game.Collections base.LoadComplete(); // Bind late, as the collection name may change externally while still loading. - textBox.Current = collection.Name; - - collectionName.BindValueChanged(_ => createNewCollection(), true); - IsCreated.BindValueChanged(created => textBoxPaddingContainer.Padding = new MarginPadding { Right = created.NewValue ? button_width : 0 }, true); + textBox.Current.Value = collection.PerformRead(c => c.IsValid ? c.Name : string.Empty); + textBox.OnCommit += onCommit; } - private void createNewCollection() + private void onCommit(TextBox sender, bool newText) { - if (IsCreated.Value) - return; + if (collection.IsManaged) + collection.PerformWrite(c => c.Name = textBox.Current.Value); + else if (!string.IsNullOrEmpty(textBox.Current.Value)) + realm.Write(r => r.Add(new BeatmapCollection(textBox.Current.Value))); - if (string.IsNullOrEmpty(collectionName.Value)) - return; - - // Add the new collection and disable our placeholder. If all text is removed, the placeholder should not show back again. - collectionManager?.Collections.Add(collection); - textBox.PlaceholderText = string.Empty; - - // When this item changes from placeholder to non-placeholder (via changing containers), its textbox will lose focus, so it needs to be re-focused. - Schedule(() => GetContainingInputManager().ChangeFocus(textBox)); - - IsCreated.Value = true; + textBox.Text = string.Empty; } } - private class ItemTextBox : OsuTextBox + private partial class ItemTextBox : OsuTextBox { protected override float LeftRightPadding => item_height / 2; @@ -151,24 +125,19 @@ namespace osu.Game.Collections } } - public class DeleteButton : CompositeDrawable + public partial class DeleteButton : CompositeDrawable { - public readonly IBindable IsCreated = new Bindable(); + public Func IsTextBoxHovered = null!; - public Func IsTextBoxHovered; + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } - [Resolved(CanBeNull = true)] - private IDialogOverlay dialogOverlay { get; set; } + private readonly Live collection; - [Resolved(CanBeNull = true)] - private CollectionManager collectionManager { get; set; } + private Drawable fadeContainer = null!; + private Drawable background = null!; - private readonly BeatmapCollection collection; - - private Drawable fadeContainer; - private Drawable background; - - public DeleteButton(BeatmapCollection collection) + public DeleteButton(Live collection) { this.collection = collection; RelativeSizeAxes = Axes.Y; @@ -202,12 +171,6 @@ namespace osu.Game.Collections }; } - protected override void LoadComplete() - { - base.LoadComplete(); - IsCreated.BindValueChanged(created => Alpha = created.NewValue ? 1 : 0, true); - } - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) && !IsTextBoxHovered(screenSpacePos); protected override bool OnHover(HoverEvent e) @@ -225,7 +188,7 @@ namespace osu.Game.Collections { background.FlashColour(Color4.White, 150); - if (collection.Beatmaps.Count == 0) + if (collection.PerformRead(c => c.BeatmapMD5Hashes.Count) == 0) deleteCollection(); else dialogOverlay?.Push(new DeleteCollectionDialog(collection, deleteCollection)); @@ -233,7 +196,7 @@ namespace osu.Game.Collections return true; } - private void deleteCollection() => collectionManager?.Collections.Remove(collection); + private void deleteCollection() => collection.PerformWrite(c => c.Realm.Remove(c)); } } } diff --git a/osu.Game/Collections/ManageCollectionsDialog.cs b/osu.Game/Collections/ManageCollectionsDialog.cs index cb350bca33..36142cf26f 100644 --- a/osu.Game/Collections/ManageCollectionsDialog.cs +++ b/osu.Game/Collections/ManageCollectionsDialog.cs @@ -3,7 +3,6 @@ using osu.Framework.Allocation; using osu.Framework.Audio; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -17,15 +16,12 @@ using osuTK; namespace osu.Game.Collections { - public class ManageCollectionsDialog : OsuFocusedOverlayContainer + public partial class ManageCollectionsDialog : OsuFocusedOverlayContainer { private const double enter_duration = 500; private const double exit_duration = 200; - private AudioFilter lowPassFilter; - - [Resolved(CanBeNull = true)] - private CollectionManager collectionManager { get; set; } + private AudioFilter lowPassFilter = null!; public ManageCollectionsDialog() { @@ -105,7 +101,6 @@ namespace osu.Game.Collections new DrawableCollectionList { RelativeSizeAxes = Axes.Both, - Items = { BindTarget = collectionManager?.Collections ?? new BindableList() } } } } diff --git a/osu.Game/Configuration/BackgroundSource.cs b/osu.Game/Configuration/BackgroundSource.cs index 18e0603860..f08b29cffe 100644 --- a/osu.Game/Configuration/BackgroundSource.cs +++ b/osu.Game/Configuration/BackgroundSource.cs @@ -1,16 +1,22 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.ComponentModel; +#nullable disable + +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Configuration { public enum BackgroundSource { + [LocalisableDescription(typeof(SkinSettingsStrings), nameof(SkinSettingsStrings.SkinSectionHeader))] Skin, + + [LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.BeatmapHeader))] Beatmap, - [Description("Beatmap (with storyboard / video)")] + [LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.BeatmapWithStoryboard))] BeatmapWithStoryboard, } } diff --git a/osu.Game/Configuration/DatabasedSetting.cs b/osu.Game/Configuration/DatabasedSetting.cs deleted file mode 100644 index 65d9f7799d..0000000000 --- a/osu.Game/Configuration/DatabasedSetting.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.ComponentModel.DataAnnotations.Schema; -using osu.Game.Database; - -namespace osu.Game.Configuration -{ - [Table("Settings")] - public class DatabasedSetting : IHasPrimaryKey // can be removed 20220315. - { - public int ID { get; set; } - - public bool IsManaged => ID > 0; - - public int? RulesetID { get; set; } - - public int? Variant { get; set; } - - public int? SkinInfoID { get; set; } - - [Column("Key")] - public string Key { get; set; } - - [Column("Value")] - public string StringValue - { - get => Value.ToString(); - set => Value = value; - } - - public object Value; - - public DatabasedSetting(string key, object value) - { - Key = key; - Value = value; - } - - /// - /// Constructor for derived classes that may require serialisation. - /// - public DatabasedSetting() - { - } - - public override string ToString() => $"{Key}=>{Value}"; - } -} diff --git a/osu.Game/Configuration/DevelopmentOsuConfigManager.cs b/osu.Game/Configuration/DevelopmentOsuConfigManager.cs index ff19dd874c..dda30c1d00 100644 --- a/osu.Game/Configuration/DevelopmentOsuConfigManager.cs +++ b/osu.Game/Configuration/DevelopmentOsuConfigManager.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Platform; using osu.Framework.Testing; diff --git a/osu.Game/Configuration/DiscordRichPresenceMode.cs b/osu.Game/Configuration/DiscordRichPresenceMode.cs index 2e58e3554b..150d23447e 100644 --- a/osu.Game/Configuration/DiscordRichPresenceMode.cs +++ b/osu.Game/Configuration/DiscordRichPresenceMode.cs @@ -1,17 +1,22 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.ComponentModel; +#nullable disable + +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Configuration { public enum DiscordRichPresenceMode { + [LocalisableDescription(typeof(OnlineSettingsStrings), nameof(OnlineSettingsStrings.DiscordPresenceOff))] Off, - [Description("Hide identifiable information")] + [LocalisableDescription(typeof(OnlineSettingsStrings), nameof(OnlineSettingsStrings.HideIdentifiableInformation))] Limited, + [LocalisableDescription(typeof(OnlineSettingsStrings), nameof(OnlineSettingsStrings.DiscordPresenceFull))] Full } } diff --git a/osu.Game/Configuration/HUDVisibilityMode.cs b/osu.Game/Configuration/HUDVisibilityMode.cs index 10f3f65355..9c69f33220 100644 --- a/osu.Game/Configuration/HUDVisibilityMode.cs +++ b/osu.Game/Configuration/HUDVisibilityMode.cs @@ -1,17 +1,22 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.ComponentModel; +#nullable disable + +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Configuration { public enum HUDVisibilityMode { + [LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.NeverShowHUD))] Never, - [Description("Hide during gameplay")] + [LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.HideDuringGameplay))] HideDuringGameplay, + [LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.AlwaysShowHUD))] Always } } diff --git a/osu.Game/Configuration/IGameplaySettings.cs b/osu.Game/Configuration/IGameplaySettings.cs new file mode 100644 index 0000000000..8d66535017 --- /dev/null +++ b/osu.Game/Configuration/IGameplaySettings.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; + +namespace osu.Game.Configuration +{ + /// + /// A settings provider which generally sources from (global user settings) + /// but can allow overriding settings by caching more locally. For instance, in the editor compose screen. + /// + /// + /// More settings can be moved into this interface as required. + /// + [Cached] + public interface IGameplaySettings + { + IBindable ComboColourNormalisationAmount { get; } + + IBindable PositionalHitsoundsLevel { get; } + } +} diff --git a/osu.Game/Configuration/InMemoryConfigManager.cs b/osu.Game/Configuration/InMemoryConfigManager.cs index ccf697f680..d8879daa3f 100644 --- a/osu.Game/Configuration/InMemoryConfigManager.cs +++ b/osu.Game/Configuration/InMemoryConfigManager.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Configuration; diff --git a/osu.Game/Configuration/IntroSequence.cs b/osu.Game/Configuration/IntroSequence.cs index 5672c44bbe..8327ea2f57 100644 --- a/osu.Game/Configuration/IntroSequence.cs +++ b/osu.Game/Configuration/IntroSequence.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Configuration { public enum IntroSequence diff --git a/osu.Game/Configuration/ModSettingChangeTracker.cs b/osu.Game/Configuration/ModSettingChangeTracker.cs index e2ade7dc6a..0061746425 100644 --- a/osu.Game/Configuration/ModSettingChangeTracker.cs +++ b/osu.Game/Configuration/ModSettingChangeTracker.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 20d555c16c..70ad6bfc96 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -1,11 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; -using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; -using System.Linq; +using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Configuration.Tracking; using osu.Framework.Extensions; @@ -18,6 +18,7 @@ using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.Localisation; using osu.Game.Overlays; +using osu.Game.Overlays.Mods.Input; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; @@ -26,13 +27,19 @@ using osu.Game.Skinning; namespace osu.Game.Configuration { [ExcludeFromDynamicCompile] - public class OsuConfigManager : IniConfigManager + public class OsuConfigManager : IniConfigManager, IGameplaySettings { + public OsuConfigManager(Storage storage) + : base(storage) + { + Migrate(); + } + protected override void InitialiseDefaults() { // UI/selection defaults SetDefault(OsuSetting.Ruleset, string.Empty); - SetDefault(OsuSetting.Skin, SkinInfo.DEFAULT_SKIN.ToString()); + SetDefault(OsuSetting.Skin, SkinInfo.ARGON_SKIN.ToString()); SetDefault(OsuSetting.BeatmapDetailTab, PlayBeatmapDetailArea.TabType.Details); SetDefault(OsuSetting.BeatmapDetailModsFilter, false); @@ -45,13 +52,18 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.SongSelectSortingMode, SortMode.Title); SetDefault(OsuSetting.RandomSelectAlgorithm, RandomSelectAlgorithm.RandomPermutation); + SetDefault(OsuSetting.ModSelectHotkeyStyle, ModSelectHotkeyStyle.Sequential); SetDefault(OsuSetting.ChatDisplayHeight, ChatOverlay.DEFAULT_HEIGHT, 0.2f, 1f); SetDefault(OsuSetting.BeatmapListingCardSize, BeatmapCardSize.Normal); + SetDefault(OsuSetting.ProfileCoverExpanded, true); + SetDefault(OsuSetting.ToolbarClockDisplayMode, ToolbarClockDisplayMode.Full); + SetDefault(OsuSetting.SongSelectBackgroundBlur, true); + // Online settings SetDefault(OsuSetting.Username, string.Empty); SetDefault(OsuSetting.Token, string.Empty); @@ -87,6 +99,7 @@ namespace osu.Game.Configuration // Input SetDefault(OsuSetting.MenuCursorSize, 1.0f, 0.5f, 2f, 0.01f); SetDefault(OsuSetting.GameplayCursorSize, 1.0f, 0.1f, 2f, 0.01f); + SetDefault(OsuSetting.GameplayCursorDuringTouch, false); SetDefault(OsuSetting.AutoCursorSize, false); SetDefault(OsuSetting.MouseDisableButtons, false); @@ -106,22 +119,21 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.MenuParallax, true); // See https://stackoverflow.com/a/63307411 for default sourcing. - SetDefault(OsuSetting.Prefer24HourTime, CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern.Contains(@"tt")); + SetDefault(OsuSetting.Prefer24HourTime, !CultureInfoHelper.SystemCulture.DateTimeFormat.ShortTimePattern.Contains(@"tt")); // Gameplay - SetDefault(OsuSetting.PositionalHitsounds, true); // replaced by level setting below, can be removed 20220703. SetDefault(OsuSetting.PositionalHitsoundsLevel, 0.2f, 0, 1); - SetDefault(OsuSetting.DimLevel, 0.8, 0, 1, 0.01); + SetDefault(OsuSetting.DimLevel, 0.7, 0, 1, 0.01); SetDefault(OsuSetting.BlurLevel, 0, 0, 1, 0.01); SetDefault(OsuSetting.LightenDuringBreaks, true); SetDefault(OsuSetting.HitLighting, true); SetDefault(OsuSetting.HUDVisibilityMode, HUDVisibilityMode.Always); - SetDefault(OsuSetting.ShowProgressGraph, true); SetDefault(OsuSetting.ShowHealthDisplayWhenCantFail, true); SetDefault(OsuSetting.FadePlayfieldWhenHealthLow, true); SetDefault(OsuSetting.KeyOverlay, false); + SetDefault(OsuSetting.GameplayLeaderboard, true); SetDefault(OsuSetting.AlwaysPlayFirstComboBreak, true); SetDefault(OsuSetting.FloatingComments, false); @@ -144,6 +156,7 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.SongSelectRightMouseScroll, false); SetDefault(OsuSetting.Scaling, ScalingMode.Off); + SetDefault(OsuSetting.SafeAreaConsiderations, true); SetDefault(OsuSetting.ScalingSizeX, 0.8f, 0.2f, 1f); SetDefault(OsuSetting.ScalingSizeY, 0.8f, 0.2f, 1f); @@ -162,15 +175,19 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.DiscordRichPresence, DiscordRichPresenceMode.Full); - SetDefault(OsuSetting.EditorWaveformOpacity, 0.25f); + SetDefault(OsuSetting.EditorDim, 0.25f, 0f, 0.75f, 0.25f); + SetDefault(OsuSetting.EditorWaveformOpacity, 0.25f, 0f, 1f, 0.25f); + SetDefault(OsuSetting.EditorShowHitMarkers, true); + SetDefault(OsuSetting.EditorAutoSeekOnPlacement, true); + + SetDefault(OsuSetting.LastProcessedMetadataId, -1); + + SetDefault(OsuSetting.ComboColourNormalisationAmount, 0.2f, 0f, 1f, 0.01f); } - public IDictionary GetLoggableState() => - new Dictionary(ConfigStore.Where(kvp => !keyContainsPrivateInformation(kvp.Key)).ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToString())); - - private static bool keyContainsPrivateInformation(OsuSetting argKey) + protected override bool CheckLookupContainsPrivateInformation(OsuSetting lookup) { - switch (argKey) + switch (lookup) { case OsuSetting.Token: return true; @@ -179,12 +196,6 @@ namespace osu.Game.Configuration return false; } - public OsuConfigManager(Storage storage) - : base(storage) - { - Migrate(); - } - public void Migrate() { // arrives as 2020.123.0 @@ -200,14 +211,11 @@ namespace osu.Game.Configuration if (!int.TryParse(pieces[0], out int year)) return; if (!int.TryParse(pieces[1], out int monthDay)) return; + // ReSharper disable once UnusedVariable int combined = (year * 10000) + monthDay; - if (combined < 20220103) - { - var positionalHitsoundsEnabled = GetBindable(OsuSetting.PositionalHitsounds); - if (!positionalHitsoundsEnabled.Value) - SetValue(OsuSetting.PositionalHitsoundsLevel, 0); - } + // migrations can be added here using a condition like: + // if (combined < 20220103) { performMigration() } } public override TrackedSettings CreateTrackedSettings() @@ -218,6 +226,12 @@ namespace osu.Game.Configuration return new TrackedSettings { + new TrackedSetting(OsuSetting.ShowFpsDisplay, state => new SettingDescription( + rawValue: state, + name: GlobalActionKeyBindingStrings.ToggleFPSCounter, + value: state ? CommonStrings.Enabled.ToLower() : CommonStrings.Disabled.ToLower(), + shortcut: LookupKeyBindings(GlobalAction.ToggleFPSDisplay)) + ), new TrackedSetting(OsuSetting.MouseDisableButtons, disabledState => new SettingDescription( rawValue: !disabledState, name: GlobalActionKeyBindingStrings.ToggleGameplayMouseButtons, @@ -269,6 +283,9 @@ namespace osu.Game.Configuration public Func LookupSkinName { private get; set; } = _ => @"unknown"; public Func LookupKeyBindings { get; set; } = _ => @"unknown"; + + IBindable IGameplaySettings.ComboColourNormalisationAmount => GetOriginalBindable(OsuSetting.ComboColourNormalisationAmount); + IBindable IGameplaySettings.PositionalHitsoundsLevel => GetOriginalBindable(OsuSetting.PositionalHitsoundsLevel); } // IMPORTANT: These are used in user configuration files. @@ -280,19 +297,19 @@ namespace osu.Game.Configuration MenuCursorSize, GameplayCursorSize, AutoCursorSize, + GameplayCursorDuringTouch, DimLevel, BlurLevel, + EditorDim, LightenDuringBreaks, ShowStoryboard, KeyOverlay, - PositionalHitsounds, + GameplayLeaderboard, PositionalHitsoundsLevel, AlwaysPlayFirstComboBreak, FloatingComments, HUDVisibilityMode, - // This has been migrated to the component itself. can be removed 20221027. - ShowProgressGraph, ShowHealthDisplayWhenCantFail, FadePlayfieldWhenHealthLow, MouseDisableButtons, @@ -322,10 +339,12 @@ namespace osu.Game.Configuration SongSelectGroupingMode, SongSelectSortingMode, RandomSelectAlgorithm, + ModSelectHotkeyStyle, ShowFpsDisplay, ChatDisplayHeight, BeatmapListingCardSize, ToolbarClockDisplayMode, + SongSelectBackgroundBlur, Version, ShowFirstRunSetup, ShowConvertedBeatmaps, @@ -355,8 +374,14 @@ namespace osu.Game.Configuration GameplayDisableWinKey, SeasonalBackgroundMode, EditorWaveformOpacity, + EditorShowHitMarkers, + EditorAutoSeekOnPlacement, DiscordRichPresence, AutomaticallyDownloadWhenSpectating, ShowOnlineExplicitContent, + LastProcessedMetadataId, + SafeAreaConsiderations, + ComboColourNormalisationAmount, + ProfileCoverExpanded, } } diff --git a/osu.Game/Configuration/RandomSelectAlgorithm.cs b/osu.Game/Configuration/RandomSelectAlgorithm.cs index b22f2ae485..052c6b4c55 100644 --- a/osu.Game/Configuration/RandomSelectAlgorithm.cs +++ b/osu.Game/Configuration/RandomSelectAlgorithm.cs @@ -1,16 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.ComponentModel; +#nullable disable + +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Configuration { public enum RandomSelectAlgorithm { - [Description("Never repeat")] + [LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.NeverRepeat))] RandomPermutation, - [Description("True Random")] + [LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.TrueRandom))] Random } } diff --git a/osu.Game/Configuration/RealmRulesetSetting.cs b/osu.Game/Configuration/RealmRulesetSetting.cs index 3fea35ee9d..2e8ccc6b0c 100644 --- a/osu.Game/Configuration/RealmRulesetSetting.cs +++ b/osu.Game/Configuration/RealmRulesetSetting.cs @@ -3,8 +3,6 @@ using Realms; -#nullable enable - namespace osu.Game.Configuration { [MapTo(@"RulesetSetting")] diff --git a/osu.Game/Configuration/ReleaseStream.cs b/osu.Game/Configuration/ReleaseStream.cs index ed0bee1dd8..9cdd91bfd0 100644 --- a/osu.Game/Configuration/ReleaseStream.cs +++ b/osu.Game/Configuration/ReleaseStream.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Configuration { public enum ReleaseStream diff --git a/osu.Game/Configuration/ScalingMode.cs b/osu.Game/Configuration/ScalingMode.cs index 0bcc908f71..e0ad59746b 100644 --- a/osu.Game/Configuration/ScalingMode.cs +++ b/osu.Game/Configuration/ScalingMode.cs @@ -1,17 +1,25 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.ComponentModel; +#nullable disable + +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Configuration { public enum ScalingMode { + [LocalisableDescription(typeof(LayoutSettingsStrings), nameof(LayoutSettingsStrings.ScalingOff))] Off, + + [LocalisableDescription(typeof(LayoutSettingsStrings), nameof(LayoutSettingsStrings.ScaleEverything))] Everything, - [Description("Excluding overlays")] + [LocalisableDescription(typeof(LayoutSettingsStrings), nameof(LayoutSettingsStrings.ScaleEverythingExcludingOverlays))] ExcludeOverlays, + + [LocalisableDescription(typeof(LayoutSettingsStrings), nameof(LayoutSettingsStrings.ScaleGameplay))] Gameplay, } } diff --git a/osu.Game/Configuration/ScreenshotFormat.cs b/osu.Game/Configuration/ScreenshotFormat.cs index 6d4c96bfa9..13d0b64fd2 100644 --- a/osu.Game/Configuration/ScreenshotFormat.cs +++ b/osu.Game/Configuration/ScreenshotFormat.cs @@ -1,16 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.ComponentModel; +#nullable disable + +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Configuration { public enum ScreenshotFormat { - [Description("JPG (web-friendly)")] + [LocalisableDescription(typeof(GraphicsSettingsStrings), nameof(GraphicsSettingsStrings.Jpg))] Jpg = 1, - [Description("PNG (lossless)")] + [LocalisableDescription(typeof(GraphicsSettingsStrings), nameof(GraphicsSettingsStrings.Png))] Png = 2 } } diff --git a/osu.Game/Configuration/ScrollVisualisationMethod.cs b/osu.Game/Configuration/ScrollVisualisationMethod.cs index 5f48fe8bfd..111bb95e67 100644 --- a/osu.Game/Configuration/ScrollVisualisationMethod.cs +++ b/osu.Game/Configuration/ScrollVisualisationMethod.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; namespace osu.Game.Configuration diff --git a/osu.Game/Configuration/SeasonalBackgroundMode.cs b/osu.Game/Configuration/SeasonalBackgroundMode.cs index 6ef835ce5f..3e6d9e42aa 100644 --- a/osu.Game/Configuration/SeasonalBackgroundMode.cs +++ b/osu.Game/Configuration/SeasonalBackgroundMode.cs @@ -1,6 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using osu.Framework.Localisation; +using osu.Game.Localisation; + namespace osu.Game.Configuration { public enum SeasonalBackgroundMode @@ -8,16 +13,19 @@ namespace osu.Game.Configuration /// /// Seasonal backgrounds are shown regardless of season, if at all available. /// + [LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.AlwaysSeasonalBackground))] Always, /// /// Seasonal backgrounds are shown only during their corresponding season. /// + [LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.SometimesSeasonalBackground))] Sometimes, /// /// Seasonal backgrounds are never shown. /// + [LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.NeverSeasonalBackground))] Never } } diff --git a/osu.Game/Configuration/SessionStatics.cs b/osu.Game/Configuration/SessionStatics.cs index 837ee7e634..276563e163 100644 --- a/osu.Game/Configuration/SessionStatics.cs +++ b/osu.Game/Configuration/SessionStatics.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; @@ -17,6 +19,7 @@ namespace osu.Game.Configuration SetDefault(Static.LoginOverlayDisplayed, false); SetDefault(Static.MutedAudioNotificationShownOnce, false); SetDefault(Static.LowBatteryNotificationShownOnce, false); + SetDefault(Static.FeaturedArtistDisclaimerShownOnce, false); SetDefault(Static.LastHoverSoundPlaybackTime, (double?)null); SetDefault(Static.SeasonalBackgrounds, null); } @@ -40,6 +43,7 @@ namespace osu.Game.Configuration LoginOverlayDisplayed, MutedAudioNotificationShownOnce, LowBatteryNotificationShownOnce, + FeaturedArtistDisclaimerShownOnce, /// /// Info about seasonal backgrounds available fetched from API - see . @@ -51,6 +55,6 @@ namespace osu.Game.Configuration /// The last playback time in milliseconds of a hover sample (from ). /// Used to debounce hover sounds game-wide to avoid volume saturation, especially in scrolling views with many UI controls like . /// - LastHoverSoundPlaybackTime + LastHoverSoundPlaybackTime, } } diff --git a/osu.Game/Configuration/SettingSourceAttribute.cs b/osu.Game/Configuration/SettingSourceAttribute.cs index 89f0e73f4f..1e425c88a6 100644 --- a/osu.Game/Configuration/SettingSourceAttribute.cs +++ b/osu.Game/Configuration/SettingSourceAttribute.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 enable - using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -11,6 +9,7 @@ using System.Linq; using System.Reflection; using JetBrains.Annotations; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; using osu.Framework.Localisation; @@ -45,27 +44,62 @@ namespace osu.Game.Configuration /// public Type? SettingControlType { get; set; } + public SettingSourceAttribute(Type declaringType, string label, string? description = null) + { + Label = getLocalisableStringFromMember(label) ?? string.Empty; + Description = getLocalisableStringFromMember(description) ?? string.Empty; + + LocalisableString? getLocalisableStringFromMember(string? member) + { + if (member == null) + return null; + + var property = declaringType.GetMember(member, BindingFlags.Static | BindingFlags.Public).FirstOrDefault(); + + if (property == null) + return null; + + switch (property) + { + case FieldInfo f: + return (LocalisableString)f.GetValue(null).AsNonNull(); + + case PropertyInfo p: + return (LocalisableString)p.GetValue(null).AsNonNull(); + + default: + throw new InvalidOperationException($"Member \"{member}\" was not found in type {declaringType} (must be a static field or property)"); + } + } + } + public SettingSourceAttribute(string? label, string? description = null) { Label = label ?? string.Empty; Description = description ?? string.Empty; } + public SettingSourceAttribute(Type declaringType, string label, string description, int orderPosition) + : this(declaringType, label, description) + { + OrderPosition = orderPosition; + } + public SettingSourceAttribute(string label, string description, int orderPosition) : this(label, description) { OrderPosition = orderPosition; } - public int CompareTo(SettingSourceAttribute other) + public int CompareTo(SettingSourceAttribute? other) { - if (OrderPosition == other.OrderPosition) + if (OrderPosition == other?.OrderPosition) return 0; // unordered items come last (are greater than any ordered items). if (OrderPosition == null) return 1; - if (other.OrderPosition == null) + if (other?.OrderPosition == null) return -1; // ordered items are sorted by the order value. @@ -73,13 +107,13 @@ namespace osu.Game.Configuration } } - public static class SettingSourceExtensions + public static partial class SettingSourceExtensions { public static IEnumerable CreateSettingsControls(this object obj) { foreach (var (attr, property) in obj.GetOrderedSettingsSourceProperties()) { - object value = property.GetValue(obj); + object value = property.GetValue(obj)!; if (attr.SettingControlType != null) { @@ -87,7 +121,7 @@ namespace osu.Game.Configuration if (controlType.EnumerateBaseTypes().All(t => !t.IsGenericType || t.GetGenericTypeDefinition() != typeof(SettingsItem<>))) throw new InvalidOperationException($"{nameof(SettingSourceAttribute)} had an unsupported custom control type ({controlType.ReadableName()})"); - var control = (Drawable)Activator.CreateInstance(controlType); + var control = (Drawable)Activator.CreateInstance(controlType)!; controlType.GetProperty(nameof(SettingsItem.SettingSourceObject))?.SetValue(control, obj); controlType.GetProperty(nameof(SettingsItem.LabelText))?.SetValue(control, attr.Label); controlType.GetProperty(nameof(SettingsItem.TooltipText))?.SetValue(control, attr.Description); @@ -154,7 +188,7 @@ namespace osu.Game.Configuration case IBindable bindable: var dropdownType = typeof(ModSettingsEnumDropdown<>).MakeGenericType(bindable.GetType().GetGenericArguments()[0]); - var dropdown = (Drawable)Activator.CreateInstance(dropdownType); + var dropdown = (Drawable)Activator.CreateInstance(dropdownType)!; dropdownType.GetProperty(nameof(SettingsDropdown.LabelText))?.SetValue(dropdown, attr.Label); dropdownType.GetProperty(nameof(SettingsDropdown.TooltipText))?.SetValue(dropdown, attr.Description); @@ -197,7 +231,7 @@ namespace osu.Game.Configuration // An unknown (e.g. enum) generic type. var valueMethod = u.GetType().GetProperty(nameof(IBindable.Value)); Debug.Assert(valueMethod != null); - return valueMethod.GetValue(u); + return valueMethod.GetValue(u)!; default: // fall back for non-bindable cases. @@ -233,12 +267,12 @@ namespace osu.Game.Configuration .OrderBy(attr => attr.Item1) .ToArray(); - private class ModSettingsEnumDropdown : SettingsEnumDropdown + private partial class ModSettingsEnumDropdown : SettingsEnumDropdown where T : struct, Enum { protected override OsuDropdown CreateDropdown() => new ModDropdownControl(); - private class ModDropdownControl : DropdownControl + private partial class ModDropdownControl : DropdownControl { // Set menu's max height low enough to workaround nested scroll issues (see https://github.com/ppy/osu-framework/issues/4536). protected override DropdownMenu CreateMenu() => base.CreateMenu().With(m => m.MaxHeight = 100); diff --git a/osu.Game/Configuration/SettingsStore.cs b/osu.Game/Configuration/SettingsStore.cs index e5d2d572c8..fda7193fea 100644 --- a/osu.Game/Configuration/SettingsStore.cs +++ b/osu.Game/Configuration/SettingsStore.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Database; namespace osu.Game.Configuration diff --git a/osu.Game/Configuration/StorageConfigManager.cs b/osu.Game/Configuration/StorageConfigManager.cs index 90ea42b638..c8781918e1 100644 --- a/osu.Game/Configuration/StorageConfigManager.cs +++ b/osu.Game/Configuration/StorageConfigManager.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Configuration; using osu.Framework.Platform; diff --git a/osu.Game/Configuration/ToolbarClockDisplayMode.cs b/osu.Game/Configuration/ToolbarClockDisplayMode.cs index 2f42f7a9b5..682e221ef8 100644 --- a/osu.Game/Configuration/ToolbarClockDisplayMode.cs +++ b/osu.Game/Configuration/ToolbarClockDisplayMode.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Configuration { public enum ToolbarClockDisplayMode diff --git a/osu.Game/Database/BeatmapLookupCache.cs b/osu.Game/Database/BeatmapLookupCache.cs index 06edc3e2da..d9bf0138dc 100644 --- a/osu.Game/Database/BeatmapLookupCache.cs +++ b/osu.Game/Database/BeatmapLookupCache.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using System.Threading; @@ -11,7 +13,7 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Database { - public class BeatmapLookupCache : OnlineLookupCache + public partial class BeatmapLookupCache : OnlineLookupCache { /// /// Perform an API lookup on the specified beatmap, populating a model. diff --git a/osu.Game/Database/DatabaseContextFactory.cs b/osu.Game/Database/DatabaseContextFactory.cs deleted file mode 100644 index 45557aa5ec..0000000000 --- a/osu.Game/Database/DatabaseContextFactory.cs +++ /dev/null @@ -1,212 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.IO; -using System.Linq; -using System.Threading; -using Microsoft.EntityFrameworkCore.Storage; -using osu.Framework.Logging; -using osu.Framework.Platform; -using osu.Framework.Statistics; - -namespace osu.Game.Database -{ - public class DatabaseContextFactory : IDatabaseContextFactory - { - private readonly Storage storage; - - public const string DATABASE_NAME = @"client.db"; - - private ThreadLocal threadContexts; - - private readonly object writeLock = new object(); - - private bool currentWriteDidWrite; - private bool currentWriteDidError; - - private int currentWriteUsages; - - private IDbContextTransaction currentWriteTransaction; - - public DatabaseContextFactory(Storage storage) - { - this.storage = storage; - recycleThreadContexts(); - } - - private static readonly GlobalStatistic reads = GlobalStatistics.Get("Database", "Get (Read)"); - private static readonly GlobalStatistic writes = GlobalStatistics.Get("Database", "Get (Write)"); - private static readonly GlobalStatistic commits = GlobalStatistics.Get("Database", "Commits"); - private static readonly GlobalStatistic rollbacks = GlobalStatistics.Get("Database", "Rollbacks"); - - /// - /// Get a context for the current thread for read-only usage. - /// If a is in progress, the existing write-safe context will be returned. - /// - public OsuDbContext Get() - { - reads.Value++; - return threadContexts.Value; - } - - /// - /// Request a context for write usage. Can be consumed in a nested fashion (and will return the same underlying context). - /// This method may block if a write is already active on a different thread. - /// - /// Whether to start a transaction for this write. - /// A usage containing a usable context. - public DatabaseWriteUsage GetForWrite(bool withTransaction = true) - { - writes.Value++; - Monitor.Enter(writeLock); - OsuDbContext context; - - try - { - if (currentWriteTransaction == null && withTransaction) - { - // this mitigates the fact that changes on tracked entities will not be rolled back with the transaction by ensuring write operations are always executed in isolated contexts. - // if this results in sub-optimal efficiency, we may need to look into removing Database-level transactions in favour of running SaveChanges where we currently commit the transaction. - if (threadContexts.IsValueCreated) - recycleThreadContexts(); - - context = threadContexts.Value; - currentWriteTransaction = context.Database.BeginTransaction(); - } - else - { - // we want to try-catch the retrieval of the context because it could throw an error (in CreateContext). - context = threadContexts.Value; - } - } - catch - { - // retrieval of a context could trigger a fatal error. - Monitor.Exit(writeLock); - throw; - } - - Interlocked.Increment(ref currentWriteUsages); - - return new DatabaseWriteUsage(context, usageCompleted) { IsTransactionLeader = currentWriteTransaction != null && currentWriteUsages == 1 }; - } - - private void usageCompleted(DatabaseWriteUsage usage) - { - int usages = Interlocked.Decrement(ref currentWriteUsages); - - try - { - currentWriteDidWrite |= usage.PerformedWrite; - currentWriteDidError |= usage.Errors.Any(); - - if (usages == 0) - { - if (currentWriteDidError) - { - rollbacks.Value++; - currentWriteTransaction?.Rollback(); - } - else - { - commits.Value++; - currentWriteTransaction?.Commit(); - } - - if (currentWriteDidWrite || currentWriteDidError) - { - // explicitly dispose to ensure any outstanding flushes happen as soon as possible (and underlying resources are purged). - usage.Context.Dispose(); - - // once all writes are complete, we want to refresh thread-specific contexts to make sure they don't have stale local caches. - recycleThreadContexts(); - } - - currentWriteTransaction = null; - currentWriteDidWrite = false; - currentWriteDidError = false; - } - } - finally - { - Monitor.Exit(writeLock); - } - } - - private void recycleThreadContexts() - { - // Contexts for other threads are not disposed as they may be in use elsewhere. Instead, fresh contexts are exposed - // for other threads to use, and we rely on the finalizer inside OsuDbContext to handle their previous contexts - threadContexts?.Value.Dispose(); - threadContexts = new ThreadLocal(CreateContext, true); - } - - protected virtual OsuDbContext CreateContext() => new OsuDbContext(CreateDatabaseConnectionString(DATABASE_NAME, storage)) - { - Database = { AutoTransactionsEnabled = false } - }; - - public void CreateBackup(string backupFilename) - { - Logger.Log($"Creating full EF database backup at {backupFilename}", LoggingTarget.Database); - - using (var source = storage.GetStream(DATABASE_NAME)) - using (var destination = storage.GetStream(backupFilename, FileAccess.Write, FileMode.CreateNew)) - source.CopyTo(destination); - } - - public void ResetDatabase() - { - lock (writeLock) - { - recycleThreadContexts(); - - try - { - int attempts = 10; - - // Retry logic taken from MigratableStorage.AttemptOperation. - while (true) - { - try - { - storage.Delete(DATABASE_NAME); - return; - } - catch (Exception) - { - if (attempts-- == 0) - throw; - } - - Thread.Sleep(250); - } - } - catch - { - // for now we are not sure why file handles are kept open by EF, but this is generally only used in testing - } - } - } - - public void FlushConnections() - { - if (threadContexts != null) - { - foreach (var context in threadContexts.Values) - context.Dispose(); - } - - recycleThreadContexts(); - } - - public static string CreateDatabaseConnectionString(string filename, Storage storage) => string.Concat("Data Source=", storage.GetFullPath($@"{filename}", true)); - - private readonly ManualResetEventSlim migrationComplete = new ManualResetEventSlim(); - - public void SetMigrationCompletion() => migrationComplete.Set(); - - public void WaitForMigrationCompletion() => migrationComplete.Wait(); - } -} diff --git a/osu.Game/Database/DatabaseWriteUsage.cs b/osu.Game/Database/DatabaseWriteUsage.cs deleted file mode 100644 index 84c39e3532..0000000000 --- a/osu.Game/Database/DatabaseWriteUsage.cs +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; - -namespace osu.Game.Database -{ - public class DatabaseWriteUsage : IDisposable - { - public readonly OsuDbContext Context; - private readonly Action usageCompleted; - - public DatabaseWriteUsage(OsuDbContext context, Action onCompleted) - { - Context = context; - usageCompleted = onCompleted; - } - - public bool PerformedWrite { get; private set; } - - private bool isDisposed; - public List Errors = new List(); - - /// - /// Whether this write usage will commit a transaction on completion. - /// If false, there is a parent usage responsible for transaction commit. - /// - public bool IsTransactionLeader; - - protected void Dispose(bool disposing) - { - if (isDisposed) return; - - isDisposed = true; - - try - { - PerformedWrite |= Context.SaveChanges() > 0; - } - catch (Exception e) - { - Errors.Add(e); - throw; - } - finally - { - usageCompleted?.Invoke(this); - } - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - } -} diff --git a/osu.Game/Database/EFToRealmMigrator.cs b/osu.Game/Database/EFToRealmMigrator.cs deleted file mode 100644 index 4e98b7d3d2..0000000000 --- a/osu.Game/Database/EFToRealmMigrator.cs +++ /dev/null @@ -1,575 +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 enable - -using System; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; -using osu.Framework; -using osu.Framework.Allocation; -using osu.Framework.Development; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Logging; -using osu.Framework.Platform; -using osu.Game.Beatmaps; -using osu.Game.Configuration; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osu.Game.Models; -using osu.Game.Overlays; -using osu.Game.Overlays.Notifications; -using osu.Game.Rulesets; -using osu.Game.Scoring; -using osu.Game.Skinning; -using osuTK; -using Realms; -using SharpCompress.Archives; -using SharpCompress.Archives.Zip; -using SharpCompress.Common; -using SharpCompress.Writers.Zip; - -namespace osu.Game.Database -{ - internal class EFToRealmMigrator : CompositeDrawable - { - public Task MigrationCompleted => migrationCompleted.Task; - - private readonly TaskCompletionSource migrationCompleted = new TaskCompletionSource(); - - [Resolved] - private DatabaseContextFactory efContextFactory { get; set; } = null!; - - [Resolved] - private RealmAccess realm { get; set; } = null!; - - [Resolved] - private OsuConfigManager config { get; set; } = null!; - - [Resolved] - private INotificationOverlay notificationOverlay { get; set; } = null!; - - [Resolved] - private OsuGame game { get; set; } = null!; - - [Resolved] - private Storage storage { get; set; } = null!; - - private readonly OsuTextFlowContainer currentOperationText; - - public EFToRealmMigrator() - { - RelativeSizeAxes = Axes.Both; - - InternalChildren = new Drawable[] - { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Spacing = new Vector2(10), - Children = new Drawable[] - { - new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Text = "Database migration in progress", - Font = OsuFont.Default.With(size: 40) - }, - new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Text = "This could take a few minutes depending on the speed of your disk(s).", - Font = OsuFont.Default.With(size: 30) - }, - new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Text = "Please keep the window open until this completes!", - Font = OsuFont.Default.With(size: 30) - }, - new LoadingSpinner(true) - { - State = { Value = Visibility.Visible } - }, - currentOperationText = new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 30)) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - TextAnchor = Anchor.TopCentre, - }, - } - }, - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - beginMigration(); - } - - private void beginMigration() - { - Task.Factory.StartNew(() => - { - using (var ef = efContextFactory.Get()) - { - realm.Write(r => - { - // Before beginning, ensure realm is in an empty state. - // Migrations which are half-completed could lead to issues if the user tries a second time. - // Note that we only do this for beatmaps and scores since the other migrations are yonks old. - r.RemoveAll(); - r.RemoveAll(); - r.RemoveAll(); - r.RemoveAll(); - }); - - ef.Migrate(); - - migrateSettings(ef); - migrateSkins(ef); - migrateBeatmaps(ef); - migrateScores(ef); - } - }, TaskCreationOptions.LongRunning).ContinueWith(t => - { - if (t.Exception == null) - { - log("Migration successful!"); - - if (DebugUtils.IsDebugBuild) - { - Logger.Log( - "Your development database has been fully migrated to realm. If you switch back to a pre-realm branch and need your previous database, rename the backup file back to \"client.db\".\n\nNote that doing this can potentially leave your file store in a bad state.", - level: LogLevel.Important); - } - } - else - { - log("Migration failed!"); - Logger.Log(t.Exception.ToString(), LoggingTarget.Database); - - if (RuntimeInfo.OS == RuntimeInfo.Platform.macOS && t.Exception.Flatten().InnerException is TypeInitializationException) - { - // Not guaranteed to be the only cause of exception, but let's roll with it for now. - log("Please download and run the intel version of osu! once\nto allow data migration to complete!"); - efContextFactory.SetMigrationCompletion(); - return; - } - - notificationOverlay.Post(new SimpleErrorNotification - { - Text = - "IMPORTANT: During data migration, some of your data could not be successfully migrated. The previous version has been backed up.\n\nFor further assistance, please open a discussion on github and attach your backup files (click to get started).", - Activated = () => - { - game.OpenUrlExternally( - $@"https://github.com/ppy/osu/discussions/new?title=Realm%20migration%20issue ({t.Exception.Message})&body=Please%20drag%20the%20""attach_me.zip""%20file%20here!&category=q-a", - true); - - const string attachment_filename = "attach_me.zip"; - const string backup_folder = "backups"; - - var backupStorage = storage.GetStorageForDirectory(backup_folder); - - backupStorage.Delete(attachment_filename); - - try - { - using (var zip = ZipArchive.Create()) - { - zip.AddAllFromDirectory(backupStorage.GetFullPath(string.Empty)); - zip.SaveTo(Path.Combine(backupStorage.GetFullPath(string.Empty), attachment_filename), new ZipWriterOptions(CompressionType.Deflate)); - } - } - catch { } - - backupStorage.PresentFileExternally(attachment_filename); - - return true; - } - }); - } - - // Regardless of success, since the game is going to continue with startup let's move the ef database out of the way. - // If we were to not do this, the migration would run another time the next time the user starts the game. - deletePreRealmData(); - - migrationCompleted.SetResult(true); - efContextFactory.SetMigrationCompletion(); - }); - } - - private void deletePreRealmData() - { - // Delete the database permanently. - // Will cause future startups to not attempt migration. - efContextFactory.ResetDatabase(); - } - - private void log(string message) - { - Logger.Log(message, LoggingTarget.Database); - Scheduler.AddOnce(m => currentOperationText.Text = m, message); - } - - private void migrateBeatmaps(OsuDbContext ef) - { - // can be removed 20220730. - var existingBeatmapSets = ef.EFBeatmapSetInfo - .Include(s => s.Beatmaps).ThenInclude(b => b.RulesetInfo) - .Include(s => s.Beatmaps).ThenInclude(b => b.Metadata) - .Include(s => s.Beatmaps).ThenInclude(b => b.BaseDifficulty) - .Include(s => s.Files).ThenInclude(f => f.FileInfo) - .Include(s => s.Metadata) - .AsSplitQuery(); - - log("Beginning beatmaps migration to realm"); - - // previous entries in EF are removed post migration. - if (!existingBeatmapSets.Any()) - { - log("No beatmaps found to migrate"); - return; - } - - int count = existingBeatmapSets.Count(); - - realm.Run(r => - { - log($"Found {count} beatmaps in EF"); - - var transaction = r.BeginWrite(); - int written = 0; - int missing = 0; - - try - { - foreach (var beatmapSet in existingBeatmapSets) - { - if (++written % 1000 == 0) - { - transaction.Commit(); - transaction = r.BeginWrite(); - log($"Migrated {written}/{count} beatmaps..."); - } - - var realmBeatmapSet = new BeatmapSetInfo - { - OnlineID = beatmapSet.OnlineID ?? -1, - DateAdded = beatmapSet.DateAdded, - Status = beatmapSet.Status, - DeletePending = beatmapSet.DeletePending, - Hash = beatmapSet.Hash, - Protected = beatmapSet.Protected, - }; - - migrateFiles(beatmapSet, r, realmBeatmapSet); - - foreach (var beatmap in beatmapSet.Beatmaps) - { - var ruleset = r.Find(beatmap.RulesetInfo.ShortName); - var metadata = getBestMetadata(beatmap.Metadata, beatmapSet.Metadata); - - if (ruleset == null) - { - log($"Skipping {++missing} beatmaps with missing ruleset"); - continue; - } - - var realmBeatmap = new BeatmapInfo(ruleset, new BeatmapDifficulty(beatmap.BaseDifficulty), metadata) - { - DifficultyName = beatmap.DifficultyName, - Status = beatmap.Status, - OnlineID = beatmap.OnlineID ?? -1, - Length = beatmap.Length, - BPM = beatmap.BPM, - Hash = beatmap.Hash, - StarRating = beatmap.StarRating, - MD5Hash = beatmap.MD5Hash, - Hidden = beatmap.Hidden, - AudioLeadIn = beatmap.AudioLeadIn, - StackLeniency = beatmap.StackLeniency, - SpecialStyle = beatmap.SpecialStyle, - LetterboxInBreaks = beatmap.LetterboxInBreaks, - WidescreenStoryboard = beatmap.WidescreenStoryboard, - EpilepsyWarning = beatmap.EpilepsyWarning, - SamplesMatchPlaybackRate = beatmap.SamplesMatchPlaybackRate, - DistanceSpacing = beatmap.DistanceSpacing, - BeatDivisor = beatmap.BeatDivisor, - GridSize = beatmap.GridSize, - TimelineZoom = beatmap.TimelineZoom, - Countdown = beatmap.Countdown, - CountdownOffset = beatmap.CountdownOffset, - Bookmarks = beatmap.Bookmarks, - BeatmapSet = realmBeatmapSet, - }; - - realmBeatmapSet.Beatmaps.Add(realmBeatmap); - } - - r.Add(realmBeatmapSet); - } - } - finally - { - transaction.Commit(); - } - - log($"Successfully migrated {count} beatmaps to realm"); - }); - } - - private BeatmapMetadata getBestMetadata(EFBeatmapMetadata? beatmapMetadata, EFBeatmapMetadata? beatmapSetMetadata) - { - var metadata = beatmapMetadata ?? beatmapSetMetadata ?? new EFBeatmapMetadata(); - - return new BeatmapMetadata - { - Title = metadata.Title, - TitleUnicode = metadata.TitleUnicode, - Artist = metadata.Artist, - ArtistUnicode = metadata.ArtistUnicode, - Author = - { - OnlineID = metadata.Author.Id, - Username = metadata.Author.Username, - }, - Source = metadata.Source, - Tags = metadata.Tags, - PreviewTime = metadata.PreviewTime, - AudioFile = metadata.AudioFile, - BackgroundFile = metadata.BackgroundFile, - }; - } - - private void migrateScores(OsuDbContext db) - { - // can be removed 20220730. - var existingScores = db.ScoreInfo - .Include(s => s.Ruleset) - .Include(s => s.BeatmapInfo) - .Include(s => s.Files) - .ThenInclude(f => f.FileInfo) - .AsSplitQuery(); - - log("Beginning scores migration to realm"); - - // previous entries in EF are removed post migration. - if (!existingScores.Any()) - { - log("No scores found to migrate"); - return; - } - - int count = existingScores.Count(); - - realm.Run(r => - { - log($"Found {count} scores in EF"); - - var transaction = r.BeginWrite(); - int written = 0; - int missing = 0; - - try - { - foreach (var score in existingScores) - { - if (++written % 1000 == 0) - { - transaction.Commit(); - transaction = r.BeginWrite(); - log($"Migrated {written}/{count} scores..."); - } - - var beatmap = r.All().FirstOrDefault(b => b.Hash == score.BeatmapInfo.Hash); - var ruleset = r.Find(score.Ruleset.ShortName); - - if (beatmap == null || ruleset == null) - { - log($"Skipping {++missing} scores with missing ruleset or beatmap"); - continue; - } - - var user = new RealmUser - { - OnlineID = score.User.OnlineID, - Username = score.User.Username - }; - - var realmScore = new ScoreInfo(beatmap, ruleset, user) - { - Hash = score.Hash, - DeletePending = score.DeletePending, - OnlineID = score.OnlineID ?? -1, - ModsJson = score.ModsJson, - StatisticsJson = score.StatisticsJson, - TotalScore = score.TotalScore, - MaxCombo = score.MaxCombo, - Accuracy = score.Accuracy, - HasReplay = ((IScoreInfo)score).HasReplay, - Date = score.Date, - PP = score.PP, - Rank = score.Rank, - HitEvents = score.HitEvents, - Passed = score.Passed, - Combo = score.Combo, - Position = score.Position, - Statistics = score.Statistics, - Mods = score.Mods, - APIMods = score.APIMods, - }; - - migrateFiles(score, r, realmScore); - - r.Add(realmScore); - } - } - finally - { - transaction.Commit(); - } - - log($"Successfully migrated {count} scores to realm"); - }); - } - - private void migrateSkins(OsuDbContext db) - { - // can be removed 20220530. - var existingSkins = db.SkinInfo - .Include(s => s.Files) - .ThenInclude(f => f.FileInfo) - .AsSplitQuery() - .ToList(); - - // previous entries in EF are removed post migration. - if (!existingSkins.Any()) - return; - - var userSkinChoice = config.GetBindable(OsuSetting.Skin); - int.TryParse(userSkinChoice.Value, out int userSkinInt); - - switch (userSkinInt) - { - case EFSkinInfo.DEFAULT_SKIN: - userSkinChoice.Value = SkinInfo.DEFAULT_SKIN.ToString(); - break; - - case EFSkinInfo.CLASSIC_SKIN: - userSkinChoice.Value = SkinInfo.CLASSIC_SKIN.ToString(); - break; - } - - realm.Run(r => - { - using (var transaction = r.BeginWrite()) - { - // only migrate data if the realm database is empty. - // note that this cannot be written as: `r.All().All(s => s.Protected)`, because realm does not support `.All()`. - if (!r.All().Any(s => !s.Protected)) - { - log($"Migrating {existingSkins.Count} skins"); - - foreach (var skin in existingSkins) - { - var realmSkin = new SkinInfo - { - Name = skin.Name, - Creator = skin.Creator, - Hash = skin.Hash, - Protected = false, - InstantiationInfo = skin.InstantiationInfo, - }; - - migrateFiles(skin, r, realmSkin); - - r.Add(realmSkin); - - if (skin.ID == userSkinInt) - userSkinChoice.Value = realmSkin.ID.ToString(); - } - } - - transaction.Commit(); - } - }); - } - - private static void migrateFiles(IHasFiles fileSource, Realm realm, IHasRealmFiles realmObject) where T : INamedFileInfo - { - foreach (var file in fileSource.Files) - { - var realmFile = realm.Find(file.FileInfo.Hash); - - if (realmFile == null) - realm.Add(realmFile = new RealmFile { Hash = file.FileInfo.Hash }); - - realmObject.Files.Add(new RealmNamedFileUsage(realmFile, file.Filename)); - } - } - - private void migrateSettings(OsuDbContext db) - { - // migrate ruleset settings. can be removed 20220315. - var existingSettings = db.DatabasedSetting.ToList(); - - // previous entries in EF are removed post migration. - if (!existingSettings.Any()) - return; - - log("Beginning settings migration to realm"); - - realm.Run(r => - { - using (var transaction = r.BeginWrite()) - { - // only migrate data if the realm database is empty. - if (!r.All().Any()) - { - log($"Migrating {existingSettings.Count} settings"); - - foreach (var dkb in existingSettings) - { - if (dkb.RulesetID == null) - continue; - - string? shortName = getRulesetShortNameFromLegacyID(dkb.RulesetID.Value); - - if (string.IsNullOrEmpty(shortName)) - continue; - - r.Add(new RealmRulesetSetting - { - Key = dkb.Key, - Value = dkb.StringValue, - RulesetName = shortName, - Variant = dkb.Variant ?? 0, - }); - } - } - - transaction.Commit(); - } - }); - } - - private string? getRulesetShortNameFromLegacyID(long rulesetId) => - efContextFactory.Get().RulesetInfo.FirstOrDefault(r => r.ID == rulesetId)?.ShortName; - } -} diff --git a/osu.Game/Database/EmptyRealmSet.cs b/osu.Game/Database/EmptyRealmSet.cs index b7f27ba035..7db946d79f 100644 --- a/osu.Game/Database/EmptyRealmSet.cs +++ b/osu.Game/Database/EmptyRealmSet.cs @@ -9,8 +9,6 @@ using System.ComponentModel; using Realms; using Realms.Schema; -#nullable enable - namespace osu.Game.Database { public class EmptyRealmSet : IRealmCollection diff --git a/osu.Game/Database/ICanAcceptFiles.cs b/osu.Game/Database/ICanAcceptFiles.cs index 74fd6fcc36..da970a29d4 100644 --- a/osu.Game/Database/ICanAcceptFiles.cs +++ b/osu.Game/Database/ICanAcceptFiles.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Threading.Tasks; @@ -12,16 +14,25 @@ namespace osu.Game.Database public interface ICanAcceptFiles { /// - /// Import the specified paths. + /// Import one or more items from filesystem . /// + /// + /// This will be treated as a low priority batch import if more than one path is specified. + /// This will post notifications tracking progress. + /// /// The files which should be imported. Task Import(params string[] paths); /// /// Import the specified files from the given import tasks. /// + /// + /// This will be treated as a low priority batch import if more than one path is specified. + /// This will post notifications tracking progress. + /// /// The import tasks from which the files should be imported. - Task Import(params ImportTask[] tasks); + /// Parameters to further configure the import process. + Task Import(ImportTask[] tasks, ImportParameters parameters = default); /// /// An array of accepted file extensions (in the standard format of ".abc"). diff --git a/osu.Game/Database/IDatabaseContextFactory.cs b/osu.Game/Database/IDatabaseContextFactory.cs deleted file mode 100644 index 72b70a208f..0000000000 --- a/osu.Game/Database/IDatabaseContextFactory.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. - -namespace osu.Game.Database -{ - public interface IDatabaseContextFactory - { - /// - /// Get a context for read-only usage. - /// - OsuDbContext Get(); - - /// - /// Request a context for write usage. Can be consumed in a nested fashion (and will return the same underlying context). - /// This method may block if a write is already active on a different thread. - /// - /// Whether to start a transaction for this write. - /// A usage containing a usable context. - DatabaseWriteUsage GetForWrite(bool withTransaction = true); - } -} diff --git a/osu.Game/Database/IHasFiles.cs b/osu.Game/Database/IHasFiles.cs index 3f6531832f..9f8ce05218 100644 --- a/osu.Game/Database/IHasFiles.cs +++ b/osu.Game/Database/IHasFiles.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using JetBrains.Annotations; diff --git a/osu.Game/Database/IHasGuidPrimaryKey.cs b/osu.Game/Database/IHasGuidPrimaryKey.cs index f52dc5c8ef..9cf7cf0683 100644 --- a/osu.Game/Database/IHasGuidPrimaryKey.cs +++ b/osu.Game/Database/IHasGuidPrimaryKey.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using Newtonsoft.Json; using Realms; diff --git a/osu.Game/Database/IHasNamedFiles.cs b/osu.Game/Database/IHasNamedFiles.cs index 08906aaa08..3524eb4c99 100644 --- a/osu.Game/Database/IHasNamedFiles.cs +++ b/osu.Game/Database/IHasNamedFiles.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; namespace osu.Game.Database diff --git a/osu.Game/Database/IHasOnlineID.cs b/osu.Game/Database/IHasOnlineID.cs index 7a720989cd..e703ce58b8 100644 --- a/osu.Game/Database/IHasOnlineID.cs +++ b/osu.Game/Database/IHasOnlineID.cs @@ -3,8 +3,6 @@ using System; -#nullable enable - namespace osu.Game.Database { public interface IHasOnlineID diff --git a/osu.Game/Database/IHasPrimaryKey.cs b/osu.Game/Database/IHasPrimaryKey.cs index 51a49948fe..84709ccd26 100644 --- a/osu.Game/Database/IHasPrimaryKey.cs +++ b/osu.Game/Database/IHasPrimaryKey.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel.DataAnnotations.Schema; using Newtonsoft.Json; diff --git a/osu.Game/Database/IHasRealmFiles.cs b/osu.Game/Database/IHasRealmFiles.cs index 024d9f2a89..79ea719583 100644 --- a/osu.Game/Database/IHasRealmFiles.cs +++ b/osu.Game/Database/IHasRealmFiles.cs @@ -2,10 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using osu.Game.Beatmaps; using osu.Game.Models; -#nullable enable - namespace osu.Game.Database { /// @@ -13,8 +12,16 @@ namespace osu.Game.Database /// public interface IHasRealmFiles { + /// + /// Available files in this model, with locally filenames. + /// When performing lookups, consider using or to do case-insensitive lookups. + /// IList Files { get; } + /// + /// A combined hash representing the model, based on the files it contains. + /// Implementation specific. + /// string Hash { get; set; } } } diff --git a/osu.Game/Database/IModelDownloader.cs b/osu.Game/Database/IModelDownloader.cs index 81fba14244..6f30dd207e 100644 --- a/osu.Game/Database/IModelDownloader.cs +++ b/osu.Game/Database/IModelDownloader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Game.Online.API; diff --git a/osu.Game/Database/IModelFileManager.cs b/osu.Game/Database/IModelFileManager.cs index 390be4a69d..c40b57f663 100644 --- a/osu.Game/Database/IModelFileManager.cs +++ b/osu.Game/Database/IModelFileManager.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.IO; namespace osu.Game.Database diff --git a/osu.Game/Database/IModelImporter.cs b/osu.Game/Database/IModelImporter.cs index 90df13477e..dcbbad0d35 100644 --- a/osu.Game/Database/IModelImporter.cs +++ b/osu.Game/Database/IModelImporter.cs @@ -1,55 +1,47 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; -using System.Threading; using System.Threading.Tasks; -using osu.Game.IO.Archives; using osu.Game.Overlays.Notifications; -#nullable enable - namespace osu.Game.Database { /// /// A class which handles importing of associated models to the game store. /// /// The model type. - public interface IModelImporter : IPostNotifications, IPostImports, ICanAcceptFiles + public interface IModelImporter : IPostNotifications, ICanAcceptFiles where TModel : class, IHasGuidPrimaryKey { - Task>> Import(ProgressNotification notification, params ImportTask[] tasks); + /// + /// Process multiple import tasks, updating a tracking notification with progress. + /// + /// The notification to update. + /// The import tasks. + /// Parameters to further configure the import process. + /// The imported models. + Task>> Import(ProgressNotification notification, ImportTask[] tasks, ImportParameters parameters = default); /// - /// Import one from the filesystem and delete the file on success. - /// Note that this bypasses the UI flow and should only be used for special cases or testing. + /// Process a single import as an update for an existing model. + /// This will still run a full import, but perform any post-processing required to make it feel like an update to the user. /// - /// The containing data about the to import. - /// Whether this is a low priority import. - /// An optional cancellation token. - /// The imported model, if successful. - Task?> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default); - - /// - /// Silently import an item from an . - /// - /// The archive to be imported. - /// Whether this is a low priority import. - /// An optional cancellation token. - Task?> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default); - - /// - /// Silently import an item from a . - /// - /// The model to be imported. - /// An optional archive to use for model population. - /// Whether this is a low priority import. - /// An optional cancellation token. - Live? Import(TModel item, ArchiveReader? archive = null, bool lowPriority = false, CancellationToken cancellationToken = default); + /// The notification to update. + /// The import task. + /// The original model which is being updated. + /// The imported model. + Task?> ImportAsUpdate(ProgressNotification notification, ImportTask task, TModel original); /// /// A user displayable name for the model type associated with this manager. /// - string HumanisedModelName => $"{typeof(TModel).Name.Replace(@"Info", "").ToLower()}"; + string HumanisedModelName => $"{typeof(TModel).Name.Replace(@"Info", "").ToLowerInvariant()}"; + + /// + /// Fired when the user requests to view the resulting import. + /// + public Action>>? PresentImport { set; } } } diff --git a/osu.Game/Database/IModelManager.cs b/osu.Game/Database/IModelManager.cs index 187ac86a59..988178818d 100644 --- a/osu.Game/Database/IModelManager.cs +++ b/osu.Game/Database/IModelManager.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; namespace osu.Game.Database diff --git a/osu.Game/Database/INamedFile.cs b/osu.Game/Database/INamedFile.cs index 2bd45d4e42..5378d15a75 100644 --- a/osu.Game/Database/INamedFile.cs +++ b/osu.Game/Database/INamedFile.cs @@ -3,8 +3,6 @@ using osu.Game.Models; -#nullable enable - namespace osu.Game.Database { /// diff --git a/osu.Game/Database/INamedFileInfo.cs b/osu.Game/Database/INamedFileInfo.cs index d95f228440..9df4a0869c 100644 --- a/osu.Game/Database/INamedFileInfo.cs +++ b/osu.Game/Database/INamedFileInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.IO; namespace osu.Game.Database diff --git a/osu.Game/Database/INamedFileUsage.cs b/osu.Game/Database/INamedFileUsage.cs index e558ffe0fb..e2721f17bf 100644 --- a/osu.Game/Database/INamedFileUsage.cs +++ b/osu.Game/Database/INamedFileUsage.cs @@ -3,8 +3,6 @@ using osu.Game.IO; -#nullable enable - namespace osu.Game.Database { /// diff --git a/osu.Game/Database/IPostImports.cs b/osu.Game/Database/IPostImports.cs deleted file mode 100644 index 6f047098da..0000000000 --- a/osu.Game/Database/IPostImports.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; - -#nullable enable - -namespace osu.Game.Database -{ - public interface IPostImports - where TModel : class, IHasGuidPrimaryKey - { - /// - /// Fired when the user requests to view the resulting import. - /// - public Action>>? PostImport { set; } - } -} diff --git a/osu.Game/Database/IPostNotifications.cs b/osu.Game/Database/IPostNotifications.cs index d4fd64e79e..8bb2c54945 100644 --- a/osu.Game/Database/IPostNotifications.cs +++ b/osu.Game/Database/IPostNotifications.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Game.Overlays.Notifications; diff --git a/osu.Game/Database/ISoftDelete.cs b/osu.Game/Database/ISoftDelete.cs index afa42c2002..b07c8db2de 100644 --- a/osu.Game/Database/ISoftDelete.cs +++ b/osu.Game/Database/ISoftDelete.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Database { /// diff --git a/osu.Game/Database/ImportParameters.cs b/osu.Game/Database/ImportParameters.cs new file mode 100644 index 0000000000..83ca0ac694 --- /dev/null +++ b/osu.Game/Database/ImportParameters.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Database +{ + public struct ImportParameters + { + /// + /// Whether this import is part of a larger batch. + /// + /// + /// May skip intensive pre-import checks in favour of faster processing. + /// + /// More specifically, imports will be skipped before they begin, given an existing model matches on hash and filenames. Should generally only be used for large batch imports, as it may defy user expectations when updating an existing model. + /// + /// Will also change scheduling behaviour to run at a lower priority. + /// + public bool Batch { get; set; } + + /// + /// Whether this import should use hard links rather than file copy operations if available. + /// + public bool PreferHardLinks { get; set; } + } +} diff --git a/osu.Game/Database/ImportProgressNotification.cs b/osu.Game/Database/ImportProgressNotification.cs index aaee3e117f..762918f376 100644 --- a/osu.Game/Database/ImportProgressNotification.cs +++ b/osu.Game/Database/ImportProgressNotification.cs @@ -5,7 +5,7 @@ using osu.Game.Overlays.Notifications; namespace osu.Game.Database { - public class ImportProgressNotification : ProgressNotification + public partial class ImportProgressNotification : ProgressNotification { public ImportProgressNotification() { diff --git a/osu.Game/Database/ImportTask.cs b/osu.Game/Database/ImportTask.cs index d75c1a73e6..def20bc1fb 100644 --- a/osu.Game/Database/ImportTask.cs +++ b/osu.Game/Database/ImportTask.cs @@ -1,19 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - using System.IO; using osu.Framework.Extensions; using osu.Game.IO.Archives; -using osu.Game.Stores; using osu.Game.Utils; using SharpCompress.Common; namespace osu.Game.Database { /// - /// An encapsulated import task to be imported to an . + /// An encapsulated import task to be imported to an . /// public class ImportTask { @@ -36,7 +33,7 @@ namespace osu.Game.Database } /// - /// Construct a new import task from a stream. + /// Construct a new import task from a stream. The provided stream will be disposed after reading. /// public ImportTask(Stream stream, string filename) { @@ -54,6 +51,15 @@ namespace osu.Game.Database : getReaderFrom(Path); } + /// + /// Deletes the file that is encapsulated by this . + /// + public virtual void DeleteFile() + { + if (File.Exists(Path)) + File.Delete(Path); + } + /// /// Creates an from a stream. /// @@ -65,6 +71,7 @@ namespace osu.Game.Database { // This isn't used in any current path. May need to reconsider for performance reasons (ie. if we don't expect the incoming stream to be copied out). memoryStream = new MemoryStream(stream.ReadAllBytesToArray()); + stream.Dispose(); } if (ZipUtils.IsZipArchive(memoryStream)) diff --git a/osu.Game/Database/LegacyBeatmapExporter.cs b/osu.Game/Database/LegacyBeatmapExporter.cs index fb8ee8f5f5..d064b9ed58 100644 --- a/osu.Game/Database/LegacyBeatmapExporter.cs +++ b/osu.Game/Database/LegacyBeatmapExporter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Platform; using osu.Game.Beatmaps; diff --git a/osu.Game/Database/LegacyBeatmapImporter.cs b/osu.Game/Database/LegacyBeatmapImporter.cs index 97f6eba6c2..20add54949 100644 --- a/osu.Game/Database/LegacyBeatmapImporter.cs +++ b/osu.Game/Database/LegacyBeatmapImporter.cs @@ -1,6 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.IO.Stores; +using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Beatmaps; using osu.Game.IO; @@ -13,6 +18,48 @@ namespace osu.Game.Database protected override Storage PrepareStableStorage(StableStorage stableStorage) => stableStorage.GetSongStorage(); + protected override IEnumerable GetStableImportPaths(Storage storage) + { + // make sure the directory exists + if (!storage.ExistsDirectory(string.Empty)) + return Array.Empty(); + + List paths = new List(); + + try + { + foreach (string directory in storage.GetDirectories(string.Empty)) + { + var directoryStorage = storage.GetStorageForDirectory(directory); + + try + { + if (!directoryStorage.GetFiles(string.Empty).ExcludeSystemFileNames().Any()) + { + // if a directory doesn't contain files, attempt looking for beatmaps inside of that directory. + // this is a special behaviour in stable for beatmaps only, see https://github.com/ppy/osu/issues/18615. + foreach (string subDirectory in GetStableImportPaths(directoryStorage)) + paths.Add(subDirectory); + } + else + paths.Add(storage.GetFullPath(directory)); + } + catch (Exception e) + { + // Catch any errors when enumerating files + Logger.Log($"Error when enumerating files in {directoryStorage.GetFullPath(string.Empty)}: {e}"); + } + } + } + catch (Exception e) + { + // Catch any errors when enumerating directories + Logger.Log($"Error when enumerating directories in {storage.GetFullPath(string.Empty)}: {e}"); + } + + return paths; + } + public LegacyBeatmapImporter(IModelImporter importer) : base(importer) { diff --git a/osu.Game/Database/LegacyCollectionImporter.cs b/osu.Game/Database/LegacyCollectionImporter.cs new file mode 100644 index 0000000000..6d3e3fb76a --- /dev/null +++ b/osu.Game/Database/LegacyCollectionImporter.cs @@ -0,0 +1,169 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.Collections; +using osu.Game.IO.Legacy; +using osu.Game.Overlays.Notifications; + +namespace osu.Game.Database +{ + public class LegacyCollectionImporter + { + public Action? PostNotification { protected get; set; } + + private readonly RealmAccess realm; + + private const string database_name = "collection.db"; + + public LegacyCollectionImporter(RealmAccess realm) + { + this.realm = realm; + } + + public Task GetAvailableCount(Storage storage) + { + if (!storage.Exists(database_name)) + return Task.FromResult(0); + + return Task.Run(() => + { + using (var stream = storage.GetStream(database_name)) + return readCollections(stream).Count; + }); + } + + /// + /// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future. + /// + public Task ImportFromStorage(Storage storage) + { + if (!storage.Exists(database_name)) + { + // This handles situations like when the user does not have a collections.db file + Logger.Log($"No {database_name} available in osu!stable installation", LoggingTarget.Information, LogLevel.Error); + return Task.CompletedTask; + } + + return Task.Run(async () => + { + using (var stream = storage.GetStream(database_name)) + await Import(stream).ConfigureAwait(false); + }); + } + + public async Task Import(Stream stream) + { + var notification = new ProgressNotification + { + State = ProgressNotificationState.Active, + Text = "Collections import is initialising..." + }; + + PostNotification?.Invoke(notification); + + var importedCollections = readCollections(stream, notification); + await importCollections(importedCollections).ConfigureAwait(false); + + notification.CompletionText = $"Imported {importedCollections.Count} collections"; + notification.State = ProgressNotificationState.Completed; + } + + private Task importCollections(List newCollections) + { + var tcs = new TaskCompletionSource(); + + try + { + realm.Write(r => + { + foreach (var collection in newCollections) + { + var existing = r.All().FirstOrDefault(c => c.Name == collection.Name); + + if (existing != null) + { + foreach (string newBeatmap in collection.BeatmapMD5Hashes) + { + if (!existing.BeatmapMD5Hashes.Contains(newBeatmap)) + existing.BeatmapMD5Hashes.Add(newBeatmap); + } + } + else + r.Add(collection); + } + }); + + tcs.SetResult(true); + } + catch (Exception e) + { + Logger.Error(e, "Failed to import collection."); + tcs.SetException(e); + } + + return tcs.Task; + } + + private List readCollections(Stream stream, ProgressNotification? notification = null) + { + if (notification != null) + { + notification.Text = "Reading collections..."; + notification.Progress = 0; + } + + var result = new List(); + + try + { + using (var sr = new SerializationReader(stream)) + { + sr.ReadInt32(); // Version + + int collectionCount = sr.ReadInt32(); + result.Capacity = collectionCount; + + for (int i = 0; i < collectionCount; i++) + { + if (notification?.CancellationToken.IsCancellationRequested == true) + return result; + + var collection = new BeatmapCollection(sr.ReadString()); + int mapCount = sr.ReadInt32(); + + for (int j = 0; j < mapCount; j++) + { + if (notification?.CancellationToken.IsCancellationRequested == true) + return result; + + string checksum = sr.ReadString(); + + collection.BeatmapMD5Hashes.Add(checksum); + } + + if (notification != null) + { + notification.Text = $"Imported {i + 1} of {collectionCount} collections"; + notification.Progress = (float)(i + 1) / collectionCount; + } + + result.Add(collection); + } + } + } + catch (Exception e) + { + Logger.Error(e, "Failed to read collection database."); + } + + return result; + } + } +} diff --git a/osu.Game/Database/LegacyExporter.cs b/osu.Game/Database/LegacyExporter.cs index 992d1854e7..8da285daa3 100644 --- a/osu.Game/Database/LegacyExporter.cs +++ b/osu.Game/Database/LegacyExporter.cs @@ -1,9 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using osu.Framework.Platform; using osu.Game.Extensions; +using osu.Game.Utils; using SharpCompress.Archives.Zip; namespace osu.Game.Database @@ -14,6 +20,19 @@ namespace osu.Game.Database public abstract class LegacyExporter where TModel : class, IHasNamedFiles { + /// + /// Max length of filename (including extension). + /// + /// + /// + /// The filename limit for most OSs is 255. This actual usable length is smaller because adds an additional "_" to the end of the path. + /// + /// + /// For more information see file specification syntax, file systems limitations + /// + /// + public const int MAX_FILENAME_LENGTH = 255 - (32 + 4 + 2 + 5); //max path - (Guid + Guid "D" format chars + Storage.CreateFileSafely chars + account for ' (99)' suffix) + /// /// The file extension for exports (including the leading '.'). /// @@ -29,13 +48,25 @@ namespace osu.Game.Database UserFileStorage = storage.GetStorageForDirectory(@"files"); } + protected virtual string GetFilename(TModel item) => item.GetDisplayString(); + /// /// Exports an item to a legacy (.zip based) package. /// /// The item to export. public void Export(TModel item) { - string filename = $"{item.GetDisplayString().GetValidArchiveContentFilename()}{FileExtension}"; + string itemFilename = GetFilename(item).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)); + + string filename = NamingUtils.GetNextBestFilename(existingExports, $"{itemFilename}{FileExtension}"); using (var stream = exportStorage.CreateFileSafely(filename)) ExportModelTo(item, stream); diff --git a/osu.Game/Database/LegacyImportManager.cs b/osu.Game/Database/LegacyImportManager.cs index af9db1b6ec..20738f859e 100644 --- a/osu.Game/Database/LegacyImportManager.cs +++ b/osu.Game/Database/LegacyImportManager.cs @@ -11,7 +11,6 @@ using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Platform; using osu.Game.Beatmaps; -using osu.Game.Collections; using osu.Game.IO; using osu.Game.Overlays; using osu.Game.Overlays.Settings.Sections.Maintenance; @@ -23,34 +22,50 @@ namespace osu.Game.Database /// /// Handles migration of legacy user data from osu-stable. /// - public class LegacyImportManager : Component + public partial class LegacyImportManager : Component { [Resolved] - private SkinManager skins { get; set; } + private SkinManager skins { get; set; } = null!; [Resolved] - private BeatmapManager beatmaps { get; set; } + private BeatmapManager beatmaps { get; set; } = null!; [Resolved] - private ScoreManager scores { get; set; } + private ScoreManager scores { get; set; } = null!; [Resolved] - private CollectionManager collections { get; set; } - - [Resolved(canBeNull: true)] - private OsuGame game { get; set; } + private OsuGame? game { get; set; } [Resolved] - private IDialogOverlay dialogOverlay { get; set; } + private IDialogOverlay dialogOverlay { get; set; } = null!; - [Resolved(canBeNull: true)] - private DesktopGameHost desktopGameHost { get; set; } + [Resolved] + private RealmAccess realmAccess { get; set; } = null!; - private StableStorage cachedStorage; + [Resolved] + private GameHost gameHost { get; set; } = null!; + + [Resolved] + private INotificationOverlay? notifications { get; set; } + + private StableStorage? cachedStorage; public bool SupportsImportFromStable => RuntimeInfo.IsDesktop; - public void UpdateStorage(string stablePath) => cachedStorage = new StableStorage(stablePath, desktopGameHost); + public void UpdateStorage(string stablePath) => cachedStorage = new StableStorage(stablePath, gameHost as DesktopGameHost); + + public bool CheckSongsFolderHardLinkAvailability() + { + var stableStorage = GetCurrentStableStorage(); + + if (stableStorage == null || gameHost is not DesktopGameHost desktopGameHost) + return false; + + string testExistingPath = stableStorage.GetSongStorage().GetFullPath(string.Empty); + string testDestinationPath = desktopGameHost.Storage.GetFullPath(string.Empty); + + return HardLinkHelper.CheckAvailability(testDestinationPath, testExistingPath); + } public virtual async Task GetImportCount(StableContent content, CancellationToken cancellationToken) { @@ -64,16 +79,16 @@ namespace osu.Game.Database switch (content) { case StableContent.Beatmaps: - return await new LegacyBeatmapImporter(beatmaps).GetAvailableCount(stableStorage); + return await new LegacyBeatmapImporter(beatmaps).GetAvailableCount(stableStorage).ConfigureAwait(false); case StableContent.Skins: - return await new LegacySkinImporter(skins).GetAvailableCount(stableStorage); + return await new LegacySkinImporter(skins).GetAvailableCount(stableStorage).ConfigureAwait(false); case StableContent.Collections: - return await collections.GetAvailableCount(stableStorage); + return await new LegacyCollectionImporter(realmAccess).GetAvailableCount(stableStorage).ConfigureAwait(false); case StableContent.Scores: - return await new LegacyScoreImporter(scores).GetAvailableCount(stableStorage); + return await new LegacyScoreImporter(scores).GetAvailableCount(stableStorage).ConfigureAwait(false); default: throw new ArgumentException($"Only one {nameof(StableContent)} flag should be specified."); @@ -97,6 +112,9 @@ namespace osu.Game.Database stableStorage = GetCurrentStableStorage(); } + if (stableStorage == null) + return; + var importTasks = new List(); Task beatmapImportTask = Task.CompletedTask; @@ -107,7 +125,14 @@ namespace osu.Game.Database importTasks.Add(new LegacySkinImporter(skins).ImportFromStableAsync(stableStorage)); if (content.HasFlagFast(StableContent.Collections)) - importTasks.Add(beatmapImportTask.ContinueWith(_ => collections.ImportFromStableAsync(stableStorage), TaskContinuationOptions.OnlyOnRanToCompletion)); + { + importTasks.Add(beatmapImportTask.ContinueWith(_ => new LegacyCollectionImporter(realmAccess) + { + // Other legacy importers import via model managers which handle the posting of notifications. + // Collections are an exception. + PostNotification = n => notifications?.Post(n) + }.ImportFromStorage(stableStorage), TaskContinuationOptions.OnlyOnRanToCompletion)); + } if (content.HasFlagFast(StableContent.Scores)) importTasks.Add(beatmapImportTask.ContinueWith(_ => new LegacyScoreImporter(scores).ImportFromStableAsync(stableStorage), TaskContinuationOptions.OnlyOnRanToCompletion)); @@ -115,7 +140,7 @@ namespace osu.Game.Database await Task.WhenAll(importTasks.ToArray()).ConfigureAwait(false); } - public StableStorage GetCurrentStableStorage() + public StableStorage? GetCurrentStableStorage() { if (cachedStorage != null) return cachedStorage; diff --git a/osu.Game/Database/LegacyModelImporter.cs b/osu.Game/Database/LegacyModelImporter.cs index 9b2a54dada..29386a1103 100644 --- a/osu.Game/Database/LegacyModelImporter.cs +++ b/osu.Game/Database/LegacyModelImporter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -55,7 +57,12 @@ namespace osu.Game.Database return Task.CompletedTask; } - return Task.Run(async () => await Importer.Import(GetStableImportPaths(storage).ToArray()).ConfigureAwait(false)); + return Task.Run(async () => + { + var tasks = GetStableImportPaths(storage).Select(p => new ImportTask(p)).ToArray(); + + await Importer.Import(tasks, new ImportParameters { Batch = true, PreferHardLinks = true }).ConfigureAwait(false); + }); } /// diff --git a/osu.Game/Database/LegacyScoreExporter.cs b/osu.Game/Database/LegacyScoreExporter.cs index 336f50bc3d..01f9afdc86 100644 --- a/osu.Game/Database/LegacyScoreExporter.cs +++ b/osu.Game/Database/LegacyScoreExporter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.IO; using System.Linq; using osu.Framework.Platform; @@ -18,6 +20,14 @@ namespace osu.Game.Database { } + protected override string GetFilename(ScoreInfo score) + { + string scoreString = score.GetDisplayString(); + string filename = $"{scoreString} ({score.Date.LocalDateTime:yyyy-MM-dd_HH-mm})"; + + return filename; + } + public override void ExportModelTo(ScoreInfo model, Stream outputStream) { var file = model.Files.SingleOrDefault(); diff --git a/osu.Game/Database/LegacyScoreImporter.cs b/osu.Game/Database/LegacyScoreImporter.cs index 131b4ffb0e..f61241141e 100644 --- a/osu.Game/Database/LegacyScoreImporter.cs +++ b/osu.Game/Database/LegacyScoreImporter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.IO; diff --git a/osu.Game/Database/LegacySkinExporter.cs b/osu.Game/Database/LegacySkinExporter.cs index 9432a1b5fc..1d5364fb8d 100644 --- a/osu.Game/Database/LegacySkinExporter.cs +++ b/osu.Game/Database/LegacySkinExporter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Platform; using osu.Game.Skinning; diff --git a/osu.Game/Database/LegacySkinImporter.cs b/osu.Game/Database/LegacySkinImporter.cs index 2f05ccae45..42b2f2e1d8 100644 --- a/osu.Game/Database/LegacySkinImporter.cs +++ b/osu.Game/Database/LegacySkinImporter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Skinning; namespace osu.Game.Database diff --git a/osu.Game/Database/Live.cs b/osu.Game/Database/Live.cs index 6256902e17..2df6f3f508 100644 --- a/osu.Game/Database/Live.cs +++ b/osu.Game/Database/Live.cs @@ -2,8 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; - -#nullable enable +using JetBrains.Annotations; namespace osu.Game.Database { @@ -20,19 +19,19 @@ namespace osu.Game.Database /// Perform a read operation on this live object. /// /// The action to perform. - public abstract void PerformRead(Action perform); + public abstract void PerformRead([InstantHandle] Action perform); /// /// Perform a read operation on this live object. /// /// The action to perform. - public abstract TReturn PerformRead(Func perform); + public abstract TReturn PerformRead([InstantHandle] Func perform); /// /// Perform a write operation on this live object. /// /// The action to perform. - public abstract void PerformWrite(Action perform); + public abstract void PerformWrite([InstantHandle] Action perform); /// /// Whether this instance is tracking data which is managed by the database backing. @@ -52,8 +51,16 @@ namespace osu.Game.Database ID = id; } - public bool Equals(Live? other) => ID == other?.ID; + public bool Equals(Live? other) + { + if (ReferenceEquals(this, other)) return true; + if (other == null) return false; - public override string ToString() => PerformRead(i => i.ToString()); + return ID == other.ID; + } + + public override int GetHashCode() => HashCode.Combine(ID); + + public override string? ToString() => PerformRead(i => i.ToString()); } } diff --git a/osu.Game/Database/MemoryCachingComponent.cs b/osu.Game/Database/MemoryCachingComponent.cs index a1a1279d71..5d1a381f09 100644 --- a/osu.Game/Database/MemoryCachingComponent.cs +++ b/osu.Game/Database/MemoryCachingComponent.cs @@ -1,11 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System; using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; +using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; +using osu.Framework.Statistics; namespace osu.Game.Database { @@ -13,12 +18,20 @@ namespace osu.Game.Database /// A component which performs lookups (or calculations) and caches the results. /// Currently not persisted between game sessions. /// - public abstract class MemoryCachingComponent : Component + public abstract partial class MemoryCachingComponent : Component { private readonly ConcurrentDictionary cache = new ConcurrentDictionary(); + private readonly GlobalStatistic statistics; + protected virtual bool CacheNullValues => true; + protected MemoryCachingComponent() + { + statistics = GlobalStatistics.Get(nameof(MemoryCachingComponent), GetType().ReadableName()); + statistics.Value = new MemoryCachingStatistics(); + } + /// /// Retrieve the cached value for the given lookup. /// @@ -27,16 +40,39 @@ namespace osu.Game.Database protected async Task GetAsync([NotNull] TLookup lookup, CancellationToken token = default) { if (CheckExists(lookup, out TValue performance)) + { + statistics.Value.HitCount++; return performance; + } var computed = await ComputeValueAsync(lookup, token).ConfigureAwait(false); + statistics.Value.MissCount++; + if (computed != null || CacheNullValues) + { cache[lookup] = computed; + statistics.Value.Usage = cache.Count; + } return computed; } + /// + /// Invalidate all entries matching a provided predicate. + /// + /// The predicate to decide which keys should be invalidated. + protected void Invalidate(Func matchKeyPredicate) + { + foreach (var kvp in cache) + { + if (matchKeyPredicate(kvp.Key)) + cache.TryRemove(kvp.Key, out _); + } + + statistics.Value.Usage = cache.Count; + } + protected bool CheckExists([NotNull] TLookup lookup, out TValue value) => cache.TryGetValue(lookup, out value); @@ -47,5 +83,31 @@ namespace osu.Game.Database /// An optional to cancel the operation. /// The computed value. protected abstract Task ComputeValueAsync(TLookup lookup, CancellationToken token = default); + + private class MemoryCachingStatistics + { + /// + /// Total number of cache hits. + /// + public int HitCount; + + /// + /// Total number of cache misses. + /// + public int MissCount; + + /// + /// Total number of cached entities. + /// + public int Usage; + + public override string ToString() + { + int totalAccesses = HitCount + MissCount; + double hitRate = totalAccesses == 0 ? 0 : (double)HitCount / totalAccesses; + + return $"i:{Usage} h:{HitCount} m:{MissCount} {hitRate:0%}"; + } + } } } diff --git a/osu.Game/Database/ModelDownloader.cs b/osu.Game/Database/ModelDownloader.cs index 2fa3357b06..8aece748a8 100644 --- a/osu.Game/Database/ModelDownloader.cs +++ b/osu.Game/Database/ModelDownloader.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Threading.Tasks; using Humanizer; using osu.Framework.Logging; @@ -13,22 +14,22 @@ using osu.Game.Overlays.Notifications; namespace osu.Game.Database { - public abstract class ModelDownloader : IModelDownloader + public abstract partial class ModelDownloader : IModelDownloader where TModel : class, IHasGuidPrimaryKey, ISoftDelete, IEquatable, T where T : class { - public Action PostNotification { protected get; set; } + public Action? PostNotification { protected get; set; } - public event Action> DownloadBegan; + public event Action>? DownloadBegan; - public event Action> DownloadFailed; + public event Action>? DownloadFailed; private readonly IModelImporter importer; - private readonly IAPIProvider api; + private readonly IAPIProvider? api; protected readonly List> CurrentDownloads = new List>(); - protected ModelDownloader(IModelImporter importer, IAPIProvider api) + protected ModelDownloader(IModelImporter importer, IAPIProvider? api) { this.importer = importer; this.api = api; @@ -42,7 +43,11 @@ namespace osu.Game.Database /// The request object. protected abstract ArchiveDownloadRequest CreateDownloadRequest(T model, bool minimiseDownloadSize); - public bool Download(T model, bool minimiseDownloadSize = false) + public bool Download(T model, bool minimiseDownloadSize = false) => Download(model, minimiseDownloadSize, null); + + public void DownloadAsUpdate(TModel originalModel, bool minimiseDownloadSize) => Download(originalModel, minimiseDownloadSize, originalModel); + + protected bool Download(T model, bool minimiseDownloadSize, TModel? originalModel) { if (!canDownload(model)) return false; @@ -63,11 +68,15 @@ namespace osu.Game.Database { Task.Factory.StartNew(async () => { - // This gets scheduled back to the update thread, but we want the import to run in the background. - var imported = await importer.Import(notification, new ImportTask(filename)).ConfigureAwait(false); + bool importSuccessful; + + if (originalModel != null) + importSuccessful = (await importer.ImportAsUpdate(notification, new ImportTask(filename), originalModel).ConfigureAwait(false)) != null; + else + importSuccessful = (await importer.Import(notification, new[] { new ImportTask(filename) }).ConfigureAwait(false)).Any(); // for now a failed import will be marked as a failed download for simplicity. - if (!imported.Any()) + if (!importSuccessful) DownloadFailed?.Invoke(request); CurrentDownloads.Remove(request); @@ -85,7 +94,7 @@ namespace osu.Game.Database CurrentDownloads.Add(request); PostNotification?.Invoke(notification); - api.PerformAsync(request); + api?.PerformAsync(request); DownloadBegan?.Invoke(request); return true; @@ -99,15 +108,23 @@ namespace osu.Game.Database notification.State = ProgressNotificationState.Cancelled; if (!(error is OperationCanceledException)) - Logger.Error(error, $"{importer.HumanisedModelName.Titleize()} download failed!"); + { + if (error is WebException webException && webException.Message == @"TooManyRequests") + { + notification.Close(false); + PostNotification?.Invoke(new TooManyDownloadsNotification()); + } + else + Logger.Error(error, $"{importer.HumanisedModelName.Titleize()} download failed!"); + } } } - public abstract ArchiveDownloadRequest GetExistingDownload(T model); + public abstract ArchiveDownloadRequest? GetExistingDownload(T model); private bool canDownload(T model) => GetExistingDownload(model) == null && api != null; - private class DownloadNotification : ProgressNotification + private partial class DownloadNotification : ProgressNotification { public override bool IsImportant => false; @@ -117,7 +134,7 @@ namespace osu.Game.Database Text = CompletionText }; - private class SilencedProgressCompletionNotification : ProgressCompletionNotification + private partial class SilencedProgressCompletionNotification : ProgressCompletionNotification { public override bool IsImportant => false; } diff --git a/osu.Game/Stores/RealmArchiveModelManager.cs b/osu.Game/Database/ModelManager.cs similarity index 84% rename from osu.Game/Stores/RealmArchiveModelManager.cs rename to osu.Game/Database/ModelManager.cs index cc8229b436..7d1dc5239a 100644 --- a/osu.Game/Stores/RealmArchiveModelManager.cs +++ b/osu.Game/Database/ModelManager.cs @@ -3,31 +3,34 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using osu.Framework.Platform; -using osu.Game.Database; +using osu.Game.Beatmaps; using osu.Game.Extensions; using osu.Game.Models; using osu.Game.Overlays.Notifications; using Realms; -#nullable enable - -namespace osu.Game.Stores +namespace osu.Game.Database { - /// - /// Class which adds all the missing pieces bridging the gap between and (legacy) ArchiveModelManager. - /// - public abstract class RealmArchiveModelManager : RealmArchiveModelImporter, IModelManager, IModelFileManager + public class ModelManager : IModelManager, IModelFileManager where TModel : RealmObject, IHasRealmFiles, IHasGuidPrimaryKey, ISoftDelete { + /// + /// Temporarily pause imports to avoid performance overheads affecting gameplay scenarios. + /// + public virtual bool PauseImports { get; set; } + + protected RealmAccess Realm { get; } + private readonly RealmFileStore realmFileStore; - protected RealmArchiveModelManager(Storage storage, RealmAccess realm) - : base(storage, realm) + public ModelManager(Storage storage, RealmAccess realm) { realmFileStore = new RealmFileStore(realm, storage); + Realm = realm; } public void DeleteFile(TModel item, RealmNamedFileUsage file) => @@ -50,6 +53,7 @@ namespace osu.Game.Stores Realm.Realm.Write(realm => { var managed = realm.Find(item.ID); + Debug.Assert(managed != null); operation(managed); item.Files.Clear(); @@ -63,7 +67,7 @@ namespace osu.Game.Stores /// /// Delete a file from within an ongoing realm transaction. /// - protected void DeleteFile(TModel item, RealmNamedFileUsage file, Realm realm) + public void DeleteFile(TModel item, RealmNamedFileUsage file, Realm realm) { item.Files.Remove(file); } @@ -71,7 +75,7 @@ namespace osu.Game.Stores /// /// Replace a file from within an ongoing realm transaction. /// - protected void ReplaceFile(RealmNamedFileUsage file, Stream contents, Realm realm) + public void ReplaceFile(RealmNamedFileUsage file, Stream contents, Realm realm) { file.File = realmFileStore.Add(contents, realm); } @@ -79,9 +83,9 @@ namespace osu.Game.Stores /// /// Add a file from within an ongoing realm transaction. If the file already exists, it is overwritten. /// - protected void AddFile(TModel item, Stream contents, string filename, Realm realm) + public void AddFile(TModel item, Stream contents, string filename, Realm realm) { - var existing = item.Files.FirstOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase)); + var existing = item.GetFile(filename); if (existing != null) { @@ -201,6 +205,10 @@ namespace osu.Game.Stores }); } - public abstract bool IsAvailableLocally(TModel model); + public virtual bool IsAvailableLocally(TModel model) => true; + + public Action? PostNotification { get; set; } + + public virtual string HumanisedModelName => $"{typeof(TModel).Name.Replace(@"Info", "").ToLowerInvariant()}"; } } diff --git a/osu.Game/Database/OnlineLookupCache.cs b/osu.Game/Database/OnlineLookupCache.cs index 506103a2c0..d9b37e2f29 100644 --- a/osu.Game/Database/OnlineLookupCache.cs +++ b/osu.Game/Database/OnlineLookupCache.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -13,7 +15,7 @@ using osu.Game.Online.API; namespace osu.Game.Database { - public abstract class OnlineLookupCache : MemoryCachingComponent + public abstract partial class OnlineLookupCache : MemoryCachingComponent where TLookup : IEquatable where TValue : class, IHasOnlineID where TRequest : APIRequest diff --git a/osu.Game/Database/OsuDbContext.cs b/osu.Game/Database/OsuDbContext.cs deleted file mode 100644 index 79183b6f0e..0000000000 --- a/osu.Game/Database/OsuDbContext.cs +++ /dev/null @@ -1,212 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using osu.Framework.Logging; -using osu.Framework.Statistics; -using osu.Game.Beatmaps; -using osu.Game.Configuration; -using osu.Game.IO; -using osu.Game.Rulesets; -using osu.Game.Scoring; -using osu.Game.Skinning; -using SQLitePCL; -using LogLevel = Microsoft.Extensions.Logging.LogLevel; - -namespace osu.Game.Database -{ - public class OsuDbContext : DbContext - { - public DbSet EFBeatmapInfo { get; set; } - public DbSet BeatmapDifficulty { get; set; } - public DbSet BeatmapMetadata { get; set; } - public DbSet EFBeatmapSetInfo { get; set; } - public DbSet FileInfo { get; set; } - public DbSet RulesetInfo { get; set; } - public DbSet SkinInfo { get; set; } - public DbSet ScoreInfo { get; set; } - - // migrated to realm - public DbSet DatabasedSetting { get; set; } - - private readonly string connectionString; - - private static readonly Lazy logger = new Lazy(() => new OsuDbLoggerFactory()); - - private static readonly GlobalStatistic contexts = GlobalStatistics.Get("Database", "Contexts"); - - static OsuDbContext() - { - // required to initialise native SQLite libraries on some platforms. - Batteries_V2.Init(); - - // https://github.com/aspnet/EntityFrameworkCore/issues/9994#issuecomment-508588678 - raw.sqlite3_config(2 /*SQLITE_CONFIG_MULTITHREAD*/); - } - - /// - /// Create a new in-memory OsuDbContext instance. - /// - public OsuDbContext() - : this("DataSource=:memory:") - { - // required for tooling (see https://wildermuth.com/2017/07/06/Program-cs-in-ASP-NET-Core-2-0). - - Migrate(); - } - - /// - /// Create a new OsuDbContext instance. - /// - /// A valid SQLite connection string. - public OsuDbContext(string connectionString) - { - this.connectionString = connectionString; - - var connection = Database.GetDbConnection(); - - try - { - connection.Open(); - - using (var cmd = connection.CreateCommand()) - { - cmd.CommandText = "PRAGMA journal_mode=WAL;"; - cmd.ExecuteNonQuery(); - - cmd.CommandText = "PRAGMA foreign_keys=OFF;"; - cmd.ExecuteNonQuery(); - } - } - catch - { - connection.Close(); - throw; - } - - contexts.Value++; - } - - ~OsuDbContext() - { - // DbContext does not contain a finalizer (https://github.com/aspnet/EntityFrameworkCore/issues/8872) - // This is used to clean up previous contexts when fresh contexts are exposed via DatabaseContextFactory - Dispose(); - } - - private bool isDisposed; - - public override void Dispose() - { - if (isDisposed) return; - - isDisposed = true; - - base.Dispose(); - - contexts.Value--; - GC.SuppressFinalize(this); - } - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - base.OnConfiguring(optionsBuilder); - optionsBuilder - // this is required for the time being due to the way we are querying in places like BeatmapStore. - // if we ever move to having consumers file their own .Includes, or get eager loading support, this could be re-enabled. - .UseSqlite(connectionString, sqliteOptions => sqliteOptions.CommandTimeout(10)) - .UseLoggerFactory(logger.Value); - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder.Entity().HasIndex(b => b.OnlineID).IsUnique(); - modelBuilder.Entity().HasIndex(b => b.MD5Hash); - modelBuilder.Entity().HasIndex(b => b.Hash); - - modelBuilder.Entity().HasIndex(b => b.OnlineID).IsUnique(); - modelBuilder.Entity().HasIndex(b => b.DeletePending); - modelBuilder.Entity().HasIndex(b => b.Hash).IsUnique(); - - modelBuilder.Entity().HasIndex(b => b.Hash).IsUnique(); - modelBuilder.Entity().HasIndex(b => b.DeletePending); - modelBuilder.Entity().HasMany(s => s.Files).WithOne(f => f.SkinInfo); - - modelBuilder.Entity().HasIndex(b => new { b.RulesetID, b.Variant }); - - modelBuilder.Entity().HasIndex(b => b.Hash).IsUnique(); - modelBuilder.Entity().HasIndex(b => b.ReferenceCount); - - modelBuilder.Entity().HasIndex(b => b.Available); - modelBuilder.Entity().HasIndex(b => b.ShortName).IsUnique(); - - modelBuilder.Entity().HasOne(b => b.BaseDifficulty); - - modelBuilder.Entity().HasIndex(b => b.OnlineID).IsUnique(); - } - - private class OsuDbLoggerFactory : ILoggerFactory - { - #region Disposal - - public void Dispose() - { - } - - #endregion - - public ILogger CreateLogger(string categoryName) => new OsuDbLogger(); - - public void AddProvider(ILoggerProvider provider) - { - // no-op. called by tooling. - } - - private class OsuDbLogger : ILogger - { - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) - { - if (logLevel < LogLevel.Information) - return; - - Framework.Logging.LogLevel frameworkLogLevel; - - switch (logLevel) - { - default: - frameworkLogLevel = Framework.Logging.LogLevel.Debug; - break; - - case LogLevel.Warning: - frameworkLogLevel = Framework.Logging.LogLevel.Important; - break; - - case LogLevel.Error: - case LogLevel.Critical: - frameworkLogLevel = Framework.Logging.LogLevel.Error; - break; - } - - Logger.Log(formatter(state, exception), LoggingTarget.Database, frameworkLogLevel); - } - - public bool IsEnabled(LogLevel logLevel) - { -#if DEBUG_DATABASE - return logLevel > LogLevel.Debug; -#else - return logLevel > LogLevel.Information; -#endif - } - - public IDisposable BeginScope(TState state) => null; - } - } - - public void Migrate() => Database.Migrate(); - } -} diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index 086ec52d80..831e328439 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.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 enable - using System; using System.Collections.Generic; using System.ComponentModel; @@ -16,6 +14,7 @@ using System.Threading.Tasks; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Development; +using osu.Framework.Extensions; using osu.Framework.Input.Bindings; using osu.Framework.Logging; using osu.Framework.Platform; @@ -25,10 +24,11 @@ using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Input.Bindings; using osu.Game.Models; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Skinning; -using osu.Game.Stores; using Realms; using Realms.Exceptions; @@ -46,8 +46,6 @@ namespace osu.Game.Database /// public readonly string Filename; - private readonly IDatabaseContextFactory? efContextFactory; - private readonly SynchronizationContext? updateThreadSyncContext; /// @@ -61,15 +59,30 @@ namespace osu.Game.Database /// 12 2021-11-24 Add Status to RealmBeatmapSet. /// 13 2022-01-13 Final migration of beatmaps and scores to realm (multiple new storage fields). /// 14 2022-03-01 Added BeatmapUserSettings to BeatmapInfo. + /// 15 2022-07-13 Added LastPlayed to BeatmapInfo. + /// 16 2022-07-15 Removed HasReplay from ScoreInfo. + /// 17 2022-07-16 Added CountryCode to RealmUser. + /// 18 2022-07-19 Added OnlineMD5Hash and LastOnlineUpdate to BeatmapInfo. + /// 19 2022-07-19 Added DateSubmitted and DateRanked to BeatmapSetInfo. + /// 20 2022-07-21 Added LastAppliedDifficultyVersion to RulesetInfo, changed default value of BeatmapInfo.StarRating to -1. + /// 21 2022-07-27 Migrate collections to realm (BeatmapCollection). + /// 22 2022-07-31 Added ModPreset. + /// 23 2022-08-01 Added LastLocalUpdate to BeatmapInfo. + /// 24 2022-08-22 Added MaximumStatistics to ScoreInfo. + /// 25 2022-09-18 Remove skins to add with new naming. + /// 26 2023-02-05 Added BeatmapHash to ScoreInfo. /// - private const int schema_version = 14; + private const int schema_version = 26; /// /// Lock object which is held during sections, blocking realm retrieval during blocking periods. /// private readonly SemaphoreSlim realmRetrievalLock = new SemaphoreSlim(1); - private readonly ThreadLocal currentThreadCanCreateRealmInstances = new ThreadLocal(); + /// + /// true when the current thread has already entered the . + /// + private readonly ThreadLocal currentThreadHasRealmRetrievalLock = new ThreadLocal(); /// /// Holds a map of functions registered via and and a coinciding action which when triggered, @@ -101,10 +114,14 @@ namespace osu.Game.Database private static readonly GlobalStatistic total_writes_async = GlobalStatistics.Get(@"Realm", @"Writes (Async)"); - private readonly object realmLock = new object(); - private Realm? updateRealm; + /// + /// Tracks whether a realm was ever fetched from this instance. + /// After a fetch occurs, blocking operations will be guaranteed to restore any subscriptions. + /// + private bool hasInitialisedOnce; + private bool isSendingNotificationResetEvents; public Realm Realm => ensureUpdateRealm(); @@ -119,23 +136,21 @@ namespace osu.Game.Database if (!ThreadSafety.IsUpdateThread) throw new InvalidOperationException(@$"Use {nameof(getRealmInstance)} when performing realm operations from a non-update thread"); - lock (realmLock) + if (updateRealm == null) { - if (updateRealm == null) - { - updateRealm = getRealmInstance(); + updateRealm = getRealmInstance(); + hasInitialisedOnce = true; - Logger.Log(@$"Opened realm ""{updateRealm.Config.DatabasePath}"" at version {updateRealm.Config.SchemaVersion}"); + Logger.Log(@$"Opened realm ""{updateRealm.Config.DatabasePath}"" at version {updateRealm.Config.SchemaVersion}"); - // Resubscribe any subscriptions - foreach (var action in customSubscriptionsResetMap.Keys) - registerSubscription(action); - } - - Debug.Assert(updateRealm != null); - - return updateRealm; + // Resubscribe any subscriptions + foreach (var action in customSubscriptionsResetMap.Keys.ToArray()) + registerSubscription(action); } + + Debug.Assert(updateRealm != null); + + return updateRealm; } internal static bool CurrentThreadSubscriptionsAllowed => current_thread_subscriptions_allowed.Value; @@ -148,11 +163,9 @@ namespace osu.Game.Database /// The game storage which will be used to create the realm backing file. /// The filename to use for the realm backing file. A ".realm" extension will be added automatically if not specified. /// The game update thread, used to post realm operations into a thread-safe context. - /// An EF factory used only for migration purposes. - public RealmAccess(Storage storage, string filename, GameThread? updateThread = null, IDatabaseContextFactory? efContextFactory = null) + public RealmAccess(Storage storage, string filename, GameThread? updateThread = null) { this.storage = storage; - this.efContextFactory = efContextFactory; updateThreadSyncContext = updateThread?.SynchronizationContext ?? SynchronizationContext.Current; @@ -161,6 +174,11 @@ namespace osu.Game.Database if (!Filename.EndsWith(realm_extension, StringComparison.Ordinal)) Filename += realm_extension; +#if DEBUG + if (!DebugUtils.IsNUnitRunning) + 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. @@ -185,14 +203,14 @@ namespace osu.Game.Database // 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); + 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}"); + createBackup($"{Filename.Replace(realm_extension, string.Empty)}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}_corrupt{realm_extension}"); storage.Delete(Filename); } @@ -200,6 +218,51 @@ namespace osu.Game.Database } } + /// + /// Some developers may be annoyed if a newer version migration (ie. caused by testing a pull request) + /// cause their test database to be unusable with previous versions. + /// To get around this, store development databases against their realm version. + /// Note that this means changes made on newer realm versions will disappear. + /// + private void applyFilenameSchemaSuffix(ref string filename) + { + string originalFilename = filename; + + filename = getVersionedFilename(schema_version); + + // First check if the current realm version already exists... + if (storage.Exists(filename)) + return; + + // Check for a previous version we can use as a base database to migrate from... + for (int i = schema_version - 1; i >= 0; i--) + { + string previousFilename = getVersionedFilename(i); + + if (storage.Exists(previousFilename)) + { + copyPreviousVersion(previousFilename, filename); + return; + } + } + + // Finally, check for a non-versioned file exists (aka before this method was added)... + if (storage.Exists(originalFilename)) + copyPreviousVersion(originalFilename, filename); + + void copyPreviousVersion(string previousFilename, string newFilename) + { + using (var previous = storage.GetStream(previousFilename)) + using (var current = storage.CreateFileSafely(newFilename)) + { + Logger.Log(@$"Copying previous realm database {previousFilename} to {newFilename} for migration to schema version {schema_version}"); + previous.CopyTo(current); + } + } + + string getVersionedFilename(int version) => originalFilename.Replace(realm_extension, $"_{version}{realm_extension}"); + } + private void attemptRecoverFromFile(string recoveryFilename) { Logger.Log($@"Performing recovery from {recoveryFilename}", LoggingTarget.Database); @@ -237,7 +300,7 @@ namespace osu.Game.Database } // For extra safety, also store the temporarily-used database which we are about to replace. - CreateBackup($"{Filename.Replace(realm_extension, string.Empty)}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}_newer_version_before_recovery{realm_extension}"); + createBackup($"{Filename.Replace(realm_extension, string.Empty)}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}_newer_version_before_recovery{realm_extension}"); storage.Delete(Filename); @@ -270,7 +333,6 @@ namespace osu.Game.Database realm.Remove(score); realm.Remove(beatmap.Metadata); - realm.Remove(beatmap); } @@ -282,6 +344,11 @@ namespace osu.Game.Database 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(); } @@ -384,15 +451,43 @@ namespace osu.Game.Database } } + private readonly CountdownEvent pendingAsyncWrites = new CountdownEvent(0); + /// /// Write changes to realm asynchronously, guaranteeing order of execution. /// /// The work to run. - public async Task WriteAsync(Action action) + public Task WriteAsync(Action action) { - total_writes_async.Value++; - using (var realm = getRealmInstance()) - await realm.WriteAsync(() => action(realm)); + if (isDisposed) + throw new ObjectDisposedException(nameof(RealmAccess)); + + // Required to ensure the write is tracked and accounted for before disposal. + // Can potentially be avoided if we have a need to do so in the future. + if (!ThreadSafety.IsUpdateThread) + throw new InvalidOperationException(@$"{nameof(WriteAsync)} must be called from the update thread."); + + // CountdownEvent will fail if already at zero. + if (!pendingAsyncWrites.TryAddCount()) + pendingAsyncWrites.Reset(1); + + // Regardless of calling Realm.GetInstance or Realm.GetInstanceAsync, there is a blocking overhead on retrieval. + // Adding a forced Task.Run resolves this. + var writeTask = Task.Run(async () => + { + total_writes_async.Value++; + + // Not attempting to use Realm.GetInstanceAsync as there's seemingly no benefit to us (for now) and it adds complexity due to locking + // concerns in getRealmInstance(). On a quick check, it looks to be more suited to cases where realm is connecting to an online sync + // server, which we don't use. May want to report upstream or revisit in the future. + using (var realm = getRealmInstance()) + // ReSharper disable once AccessToDisposedClosure (WriteAsync should be marked as [InstantHandle]). + await realm.WriteAsync(() => action(realm)).ConfigureAwait(false); + + pendingAsyncWrites.Signal(); + }); + + return writeTask; } /// @@ -417,14 +512,15 @@ namespace osu.Game.Database public IDisposable RegisterForNotifications(Func> query, NotificationCallbackDelegate callback) where T : RealmObjectBase { - lock (realmLock) - { - Func action = realm => query(realm).QueryAsyncWithNotifications(callback); + Func action = realm => query(realm).QueryAsyncWithNotifications(callback); + lock (notificationsResetMap) + { // Store an action which is used when blocking to ensure consumers don't use results of a stale changeset firing. notificationsResetMap.Add(action, () => callback(new EmptyRealmSet(), null, null)); - return RegisterCustomSubscription(action); } + + return RegisterCustomSubscription(action); } /// @@ -446,7 +542,7 @@ namespace osu.Game.Database public IDisposable SubscribeToPropertyChanged(Func modelAccessor, Expression> propertyLookup, Action onChanged) where TModel : RealmObjectBase { - return RegisterCustomSubscription(r => + return RegisterCustomSubscription(_ => { string propertyName = getMemberName(propertyLookup); @@ -463,7 +559,7 @@ namespace osu.Game.Database return new InvokeOnDisposal(() => model.PropertyChanged -= onPropertyChanged); - void onPropertyChanged(object sender, PropertyChangedEventArgs args) + void onPropertyChanged(object? sender, PropertyChangedEventArgs args) { if (args.PropertyName == propertyName) onChanged(propLookupCompiled(model)); @@ -515,15 +611,17 @@ namespace osu.Game.Database void unsubscribe() { - lock (realmLock) + if (customSubscriptionsResetMap.TryGetValue(action, out var unsubscriptionAction)) { - if (customSubscriptionsResetMap.TryGetValue(action, out var unsubscriptionAction)) + unsubscriptionAction?.Dispose(); + customSubscriptionsResetMap.Remove(action); + + lock (notificationsResetMap) { - unsubscriptionAction?.Dispose(); - customSubscriptionsResetMap.Remove(action); notificationsResetMap.Remove(action); - total_subscriptions.Value--; } + + total_subscriptions.Value--; } } }); @@ -533,19 +631,16 @@ namespace osu.Game.Database { Debug.Assert(ThreadSafety.IsUpdateThread); - lock (realmLock) - { - // Retrieve realm instance outside of flag update to ensure that the instance is retrieved, - // as attempting to access it inside the subscription if it's not constructed would lead to - // cyclic invocations of the subscription callback. - var realm = Realm; + // Retrieve realm instance outside of flag update to ensure that the instance is retrieved, + // as attempting to access it inside the subscription if it's not constructed would lead to + // cyclic invocations of the subscription callback. + var realm = Realm; - Debug.Assert(!customSubscriptionsResetMap.TryGetValue(action, out var found) || found == null); + Debug.Assert(!customSubscriptionsResetMap.TryGetValue(action, out var found) || found == null); - current_thread_subscriptions_allowed.Value = true; - customSubscriptionsResetMap[action] = action(realm); - current_thread_subscriptions_allowed.Value = false; - } + current_thread_subscriptions_allowed.Value = true; + customSubscriptionsResetMap[action] = action(realm); + current_thread_subscriptions_allowed.Value = false; } private Realm getRealmInstance() @@ -557,10 +652,11 @@ namespace osu.Game.Database try { - if (!currentThreadCanCreateRealmInstances.Value) + // Ensure that the thread that currently has the `realmRetrievalLock` can retrieve nested contexts and not deadlock on itself. + if (!currentThreadHasRealmRetrievalLock.Value) { realmRetrievalLock.Wait(); - currentThreadCanCreateRealmInstances.Value = true; + currentThreadHasRealmRetrievalLock.Value = true; tookSemaphoreLock = true; } else @@ -584,7 +680,7 @@ namespace osu.Game.Database if (tookSemaphoreLock) { realmRetrievalLock.Release(); - currentThreadCanCreateRealmInstances.Value = false; + currentThreadHasRealmRetrievalLock.Value = false; } } } @@ -744,35 +840,95 @@ namespace osu.Game.Database case 14: foreach (var beatmap in migration.NewRealm.All()) beatmap.UserSettings = new BeatmapUserSettings(); + + break; + + case 20: + // As we now have versioned difficulty calculations, let's reset + // all star ratings and have `BackgroundBeatmapProcessor` recalculate them. + foreach (var beatmap in migration.NewRealm.All()) + beatmap.StarRating = -1; + + break; + + case 21: + // Migrate collections from external file to inside realm. + // We use the "legacy" importer because that is how things were actually being saved out until now. + var legacyCollectionImporter = new LegacyCollectionImporter(this); + + if (legacyCollectionImporter.GetAvailableCount(storage).GetResultSafely() > 0) + { + legacyCollectionImporter.ImportFromStorage(storage).ContinueWith(_ => storage.Move("collection.db", "collection.db.migrated")); + } + + break; + + case 25: + // Remove the default skins so they can be added back by SkinManager with updated naming. + migration.NewRealm.RemoveRange(migration.NewRealm.All().Where(s => s.Protected)); + break; + + case 26: + // Add ScoreInfo.BeatmapHash property to ensure scores correspond to the correct version of beatmap. + var scores = migration.NewRealm.All(); + + foreach (var score in scores) + score.BeatmapHash = score.BeatmapInfo.Hash; + break; } } - private string? getRulesetShortNameFromLegacyID(long rulesetId) => - efContextFactory?.Get().RulesetInfo.FirstOrDefault(r => r.ID == rulesetId)?.ShortName; + private string? getRulesetShortNameFromLegacyID(long rulesetId) + { + try + { + return new APIBeatmap.APIRuleset { OnlineID = (int)rulesetId }.ShortName; + } + catch + { + return null; + } + } + /// + /// Create a full realm backup. + /// + /// The filename for the backup. public void CreateBackup(string backupFilename) { - using (BlockAllOperations()) + if (realmRetrievalLock.CurrentCount != 0) + throw new InvalidOperationException($"Call {nameof(BlockAllOperations)} before creating a backup."); + + createBackup(backupFilename); + } + + private void createBackup(string backupFilename) + { + Logger.Log($"Creating full realm database backup at {backupFilename}", LoggingTarget.Database); + + int attempts = 10; + + while (attempts-- > 0) { - Logger.Log($"Creating full realm database backup at {backupFilename}", LoggingTarget.Database); - - int attempts = 10; - - while (attempts-- > 0) + try { - try + using (var source = storage.GetStream(Filename, mode: FileMode.Open)) { - using (var source = storage.GetStream(Filename)) + // source may not exist. + if (source == null) + return; + using (var destination = storage.GetStream(backupFilename, FileAccess.Write, FileMode.CreateNew)) source.CopyTo(destination); - return; - } - catch (IOException) - { - // file may be locked during use. - Thread.Sleep(500); } + + return; + } + catch (IOException) + { + // file may be locked during use. + Thread.Sleep(500); } } } @@ -784,9 +940,15 @@ namespace osu.Game.Database /// This should be used in places we need to ensure no ongoing reads/writes are occurring with realm. /// ie. to move the realm backing file to a new location. /// + /// The reason for blocking. Used for logging purposes. /// An which should be disposed to end the blocking section. - public IDisposable BlockAllOperations() + public IDisposable BlockAllOperations(string reason) { + Logger.Log($@"Attempting to block all realm operations for {reason}.", LoggingTarget.Database); + + if (!ThreadSafety.IsUpdateThread) + throw new InvalidOperationException(@$"{nameof(BlockAllOperations)} must be called from the update thread."); + if (isDisposed) throw new ObjectDisposedException(nameof(RealmAccess)); @@ -796,39 +958,27 @@ namespace osu.Game.Database { realmRetrievalLock.Wait(); - lock (realmLock) + if (hasInitialisedOnce) { - if (updateRealm == null) + syncContext = SynchronizationContext.Current; + + // Before disposing the update context, clean up all subscriptions. + // Note that in the case of realm notification subscriptions, this is not really required (they will be cleaned up by disposal). + // In the case of custom subscriptions, we want them to fire before the update realm is disposed in case they do any follow-up work. + foreach (var action in customSubscriptionsResetMap.ToArray()) { - // null realm means the update thread has not yet retrieved its instance. - // we don't need to worry about reviving the update instance in this case, so don't bother with the SynchronizationContext. - Debug.Assert(!ThreadSafety.IsUpdateThread); + action.Value?.Dispose(); + customSubscriptionsResetMap[action.Key] = null; } - else - { - if (!ThreadSafety.IsUpdateThread) - throw new InvalidOperationException(@$"{nameof(BlockAllOperations)} must be called from the update thread."); - - syncContext = SynchronizationContext.Current; - - // Before disposing the update context, clean up all subscriptions. - // Note that in the case of realm notification subscriptions, this is not really required (they will be cleaned up by disposal). - // In the case of custom subscriptions, we want them to fire before the update realm is disposed in case they do any follow-up work. - foreach (var action in customSubscriptionsResetMap) - { - action.Value?.Dispose(); - customSubscriptionsResetMap[action.Key] = null; - } - } - - Logger.Log(@"Blocking realm operations.", LoggingTarget.Database); updateRealm?.Dispose(); updateRealm = null; } + Logger.Log(@"Lock acquired for blocking operations", LoggingTarget.Database); + const int sleep_length = 200; - int timeout = 5000; + int timeSpent = 0; try { @@ -836,10 +986,10 @@ namespace osu.Game.Database while (!Compact()) { Thread.Sleep(sleep_length); - timeout -= sleep_length; + timeSpent += sleep_length; - if (timeout < 0) - throw new TimeoutException(@"Took too long to acquire lock"); + if (timeSpent > 5000) + throw new TimeoutException($@"Realm compact failed after {timeSpent / sleep_length} attempts over {timeSpent / 1000} seconds"); } } catch (RealmException e) @@ -849,6 +999,8 @@ namespace osu.Game.Database Logger.Log($"Realm compact failed with error {e}", LoggingTarget.Database); } + Logger.Log(@"Realm usage isolated via compact", LoggingTarget.Database); + // In order to ensure events arrive in the correct order, these *must* be fired post disposal of the update realm, // and must be posted to the synchronization context. // This is because realm may fire event callbacks between the `unregisterAllSubscriptions` and `updateRealm.Dispose` @@ -862,8 +1014,11 @@ namespace osu.Game.Database try { - foreach (var action in notificationsResetMap.Values) - action(); + lock (notificationsResetMap) + { + foreach (var action in notificationsResetMap.Values) + action(); + } } finally { @@ -881,16 +1036,39 @@ namespace osu.Game.Database void restoreOperation() { + // Release of lock needs to happen here rather than on the update thread, as there may be another + // operation already blocking the update thread waiting for the blocking operation to complete. Logger.Log(@"Restoring realm operations.", LoggingTarget.Database); realmRetrievalLock.Release(); + if (syncContext == null) return; + + ManualResetEventSlim updateRealmReestablished = new ManualResetEventSlim(); + // Post back to the update thread to revive any subscriptions. // In the case we are on the update thread, let's also require this to run synchronously. // This requirement is mostly due to test coverage, but shouldn't cause any harm. if (ThreadSafety.IsUpdateThread) - syncContext?.Send(_ => ensureUpdateRealm(), null); + { + syncContext.Send(_ => + { + ensureUpdateRealm(); + updateRealmReestablished.Set(); + }, null); + } else - syncContext?.Post(_ => ensureUpdateRealm(), null); + { + syncContext.Post(_ => + { + ensureUpdateRealm(); + updateRealmReestablished.Set(); + }, null); + } + + // Wait for the post to complete to ensure a second `Migrate` operation doesn't start in the mean time. + // This is important to ensure `ensureUpdateRealm` is run before another blocking migration operation starts. + if (!updateRealmReestablished.Wait(10000)) + throw new TimeoutException(@"Reestablishing update realm after block took too long"); } } @@ -901,10 +1079,10 @@ namespace osu.Game.Database public void Dispose() { - lock (realmLock) - { - updateRealm?.Dispose(); - } + if (!pendingAsyncWrites.Wait(10000)) + Logger.Log("Realm took too long waiting on pending async writes", level: LogLevel.Error); + + updateRealm?.Dispose(); if (!isDisposed) { diff --git a/osu.Game/Stores/RealmArchiveModelImporter.cs b/osu.Game/Database/RealmArchiveModelImporter.cs similarity index 62% rename from osu.Game/Stores/RealmArchiveModelImporter.cs rename to osu.Game/Database/RealmArchiveModelImporter.cs index 6d1449a4b4..9d06c14b4b 100644 --- a/osu.Game/Stores/RealmArchiveModelImporter.cs +++ b/osu.Game/Database/RealmArchiveModelImporter.cs @@ -13,16 +13,13 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Threading; -using osu.Game.Database; using osu.Game.Extensions; using osu.Game.IO.Archives; using osu.Game.Models; using osu.Game.Overlays.Notifications; using Realms; -#nullable enable - -namespace osu.Game.Stores +namespace osu.Game.Database { /// /// Encapsulates a model store class to give it import functionality. @@ -32,12 +29,16 @@ namespace osu.Game.Stores public abstract class RealmArchiveModelImporter : IModelImporter where TModel : RealmObject, IHasRealmFiles, IHasGuidPrimaryKey, ISoftDelete { + /// + /// The maximum number of concurrent imports to run per import scheduler. + /// private const int import_queue_request_concurrency = 1; /// - /// The size of a batch import operation before considering it a lower priority operation. + /// The minimum number of items in a single import call in order for the import to be processed as a batch. + /// Batch imports will apply optimisations preferring speed over consistency when detecting changes in already-imported items. /// - private const int low_priority_import_batch_size = 1; + private const int minimum_items_considered_batch_import = 10; /// /// A singleton scheduler shared by all . @@ -49,13 +50,18 @@ namespace osu.Game.Stores private static readonly ThreadedTaskScheduler import_scheduler = new ThreadedTaskScheduler(import_queue_request_concurrency, nameof(RealmArchiveModelImporter)); /// - /// A second scheduler for lower priority imports. + /// A second scheduler for batch imports. /// For simplicity, these will just run in parallel with normal priority imports, but a future refactor would see this implemented via a custom scheduler/queue. /// See https://gist.github.com/peppy/f0e118a14751fc832ca30dd48ba3876b for an incomplete version of this. /// - private static readonly ThreadedTaskScheduler import_scheduler_low_priority = new ThreadedTaskScheduler(import_queue_request_concurrency, nameof(RealmArchiveModelImporter)); + private static readonly ThreadedTaskScheduler import_scheduler_batch = new ThreadedTaskScheduler(import_queue_request_concurrency, nameof(RealmArchiveModelImporter)); - public virtual IEnumerable HandledExtensions => new[] { @".zip" }; + /// + /// Temporarily pause imports to avoid performance overheads affecting gameplay scenarios. + /// + public bool PauseImports { get; set; } + + public abstract IEnumerable HandledExtensions { get; } protected readonly RealmFileStore Files; @@ -64,12 +70,12 @@ namespace osu.Game.Stores /// /// Fired when the user requests to view the resulting import. /// - public Action>>? PostImport { get; set; } + public Action>>? PresentImport { get; set; } /// /// Set an endpoint for notifications to be posted to. /// - public Action? PostNotification { protected get; set; } + public Action? PostNotification { get; set; } protected RealmArchiveModelImporter(Storage storage, RealmAccess realm) { @@ -78,33 +84,18 @@ namespace osu.Game.Stores Files = new RealmFileStore(realm, storage); } - /// - /// Import one or more items from filesystem . - /// - /// - /// This will be treated as a low priority import if more than one path is specified; use to always import at standard priority. - /// This will post notifications tracking progress. - /// - /// One or more archive locations on disk. - public Task Import(params string[] paths) + public Task Import(params string[] paths) => Import(paths.Select(p => new ImportTask(p)).ToArray()); + + public Task Import(ImportTask[] tasks, ImportParameters parameters = default) { var notification = new ProgressNotification { State = ProgressNotificationState.Active }; PostNotification?.Invoke(notification); - return Import(notification, paths.Select(p => new ImportTask(p)).ToArray()); + return Import(notification, tasks, parameters); } - public Task Import(params ImportTask[] tasks) - { - var notification = new ProgressNotification { State = ProgressNotificationState.Active }; - - PostNotification?.Invoke(notification); - - return Import(notification, tasks); - } - - public async Task>> Import(ProgressNotification notification, params ImportTask[] tasks) + public async Task>> Import(ProgressNotification notification, ImportTask[] tasks, ImportParameters parameters = default) { if (tasks.Length == 0) { @@ -120,64 +111,62 @@ namespace osu.Game.Stores var imported = new List>(); - bool isLowPriorityImport = tasks.Length > low_priority_import_batch_size; + parameters.Batch |= tasks.Length >= minimum_items_considered_batch_import; - try + await Task.WhenAll(tasks.Select(async task => { - await Task.WhenAll(tasks.Select(async task => + if (notification.CancellationToken.IsCancellationRequested) + return; + + try { - notification.CancellationToken.ThrowIfCancellationRequested(); + var model = await Import(task, parameters, notification.CancellationToken).ConfigureAwait(false); - try + lock (imported) { - var model = await Import(task, isLowPriorityImport, notification.CancellationToken).ConfigureAwait(false); + if (model != null) + imported.Add(model); + current++; - lock (imported) - { - if (model != null) - imported.Add(model); - current++; + notification.Text = $"Imported {current} of {tasks.Length} {HumanisedModelName}s"; + notification.Progress = (float)current / tasks.Length; + } + } + catch (OperationCanceledException) + { + } + catch (Exception e) + { + Logger.Error(e, $@"Could not import ({task})", LoggingTarget.Database); + } + })).ConfigureAwait(false); - notification.Text = $"Imported {current} of {tasks.Length} {HumanisedModelName}s"; - notification.Progress = (float)current / tasks.Length; - } - } - catch (TaskCanceledException) - { - throw; - } - catch (Exception e) - { - Logger.Error(e, $@"Could not import ({task})", LoggingTarget.Database); - } - })).ConfigureAwait(false); - } - catch (OperationCanceledException) + if (imported.Count == 0) { - if (imported.Count == 0) + if (notification.CancellationToken.IsCancellationRequested) { notification.State = ProgressNotificationState.Cancelled; return imported; } - } - if (imported.Count == 0) - { notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import failed!"; notification.State = ProgressNotificationState.Cancelled; } else { - notification.CompletionText = imported.Count == 1 - ? $"Imported {imported.First().GetDisplayString()}!" - : $"Imported {imported.Count} {HumanisedModelName}s!"; + if (tasks.Length > imported.Count) + notification.CompletionText = $"Imported {imported.Count} of {tasks.Length} {HumanisedModelName}s."; + else if (imported.Count > 1) + notification.CompletionText = $"Imported {imported.Count} {HumanisedModelName}s!"; + else + notification.CompletionText = $"Imported {imported.First().GetDisplayString()}!"; - if (imported.Count > 0 && PostImport != null) + if (imported.Count > 0 && PresentImport != null) { notification.CompletionText += " Click to view."; notification.CompletionClickAction = () => { - PostImport?.Invoke(imported); + PresentImport?.Invoke(imported); return true; }; } @@ -188,21 +177,23 @@ namespace osu.Game.Stores return imported; } + public virtual Task?> ImportAsUpdate(ProgressNotification notification, ImportTask task, TModel original) => throw new NotImplementedException(); + /// /// Import one from the filesystem and delete the file on success. /// Note that this bypasses the UI flow and should only be used for special cases or testing. /// /// The containing data about the to import. - /// Whether this is a low priority import. + /// Parameters to further configure the import process. /// An optional cancellation token. /// The imported model, if successful. - public async Task?> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default) + public async Task?> Import(ImportTask task, ImportParameters parameters = default, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); Live? import; using (ArchiveReader reader = task.GetReader()) - import = await Import(reader, lowPriority, cancellationToken).ConfigureAwait(false); + import = await importFromArchive(reader, parameters, cancellationToken).ConfigureAwait(false); // We may or may not want to delete the file depending on where it is stored. // e.g. reconstructing/repairing database with items from default storage. @@ -210,8 +201,8 @@ namespace osu.Game.Stores // TODO: Add a check to prevent files from storage to be deleted. try { - if (import != null && File.Exists(task.Path) && ShouldDeleteArchive(task.Path)) - File.Delete(task.Path); + if (import != null && ShouldDeleteArchive(task.Path)) + task.DeleteFile(); } catch (Exception e) { @@ -222,12 +213,15 @@ namespace osu.Game.Stores } /// - /// Silently import an item from an . + /// Create and import a model based off the provided . /// + /// + /// This method also handled queueing the import task on a relevant import thread pool. + /// /// The archive to be imported. - /// Whether this is a low priority import. + /// Parameters to further configure the import process. /// An optional cancellation token. - public async Task?> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default) + private async Task?> importFromArchive(ArchiveReader archive, ImportParameters parameters = default, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -250,14 +244,146 @@ namespace osu.Game.Stores return null; } - var scheduledImport = Task.Factory.StartNew(() => Import(model, archive, lowPriority, cancellationToken), + var scheduledImport = Task.Factory.StartNew(() => ImportModel(model, archive, parameters, cancellationToken), cancellationToken, TaskCreationOptions.HideScheduler, - lowPriority ? import_scheduler_low_priority : import_scheduler); + parameters.Batch ? import_scheduler_batch : import_scheduler); return await scheduledImport.ConfigureAwait(false); } + /// + /// Silently import an item from a . + /// + /// The model to be imported. + /// An optional archive to use for model population. + /// Parameters to further configure the import process. + /// An optional cancellation token. + public virtual Live? ImportModel(TModel item, ArchiveReader? archive = null, ImportParameters parameters = default, CancellationToken cancellationToken = default) => Realm.Run(realm => + { + pauseIfNecessary(cancellationToken); + + TModel? existing; + + if (parameters.Batch && archive != null) + { + // this is a fast bail condition to improve large import performance. + item.Hash = computeHashFast(archive); + + existing = CheckForExisting(item, realm); + + if (existing != null) + { + // bare minimum comparisons + // + // note that this should really be checking filesizes on disk (of existing files) for some degree of sanity. + // or alternatively doing a faster hash check. either of these require database changes and reprocessing of existing files. + if (CanSkipImport(existing, item) && + getFilenames(existing.Files).SequenceEqual(getShortenedFilenames(archive).Select(p => p.shortened).OrderBy(f => f)) && + checkAllFilesExist(existing)) + { + LogForModel(item, @$"Found existing (optimised) {HumanisedModelName} for {item} (ID {existing.ID}) – skipping import."); + + using (var transaction = realm.BeginWrite()) + { + UndeleteForReuse(existing); + transaction.Commit(); + } + + return existing.ToLive(Realm); + } + + LogForModel(item, @"Found existing (optimised) but failed pre-check."); + } + } + + try + { + // Log output here will be missing a valid hash in non-batch imports. + LogForModel(item, $@"Beginning import from {archive?.Name ?? "unknown"}..."); + + List files = new List(); + + if (archive != null) + { + // Import files to the disk store. + // We intentionally delay adding to realm to avoid blocking on a write during disk operations. + foreach (var filenames in getShortenedFilenames(archive)) + { + using (Stream s = archive.GetStream(filenames.original)) + files.Add(new RealmNamedFileUsage(Files.Add(s, realm, false, parameters.PreferHardLinks), filenames.shortened)); + } + } + + using (var transaction = realm.BeginWrite()) + { + // Add all files to realm in one go. + // This is done ahead of the main transaction to ensure we can correctly cleanup the files, even if the import fails. + foreach (var file in files) + { + if (!file.File.IsManaged) + realm.Add(file.File, true); + } + + transaction.Commit(); + } + + item.Files.AddRange(files); + item.Hash = ComputeHash(item); + + // TODO: do we want to make the transaction this local? not 100% sure, will need further investigation. + using (var transaction = realm.BeginWrite()) + { + // TODO: we may want to run this outside of the transaction. + Populate(item, archive, realm, cancellationToken); + + // Populate() may have adjusted file content (see SkinImporter.updateSkinIniMetadata), so regardless of whether a fast check was done earlier, let's + // check for existing items a second time. + // + // If this is ever a performance issue, the fast-check hash can be compared and trigger a skip of this second check if it still matches. + // I don't think it is a huge deal doing a second indexed check, though. + existing = CheckForExisting(item, realm); + + if (existing != null) + { + if (CanReuseExisting(existing, item)) + { + LogForModel(item, @$"Found existing {HumanisedModelName} for {item} (ID {existing.ID}) – skipping import."); + + UndeleteForReuse(existing); + transaction.Commit(); + + return existing.ToLive(Realm); + } + + LogForModel(item, @"Found existing but failed re-use check."); + + existing.DeletePending = true; + } + + PreImport(item, realm); + + // import to store + realm.Add(item); + + PostImport(item, realm, parameters); + + transaction.Commit(); + } + + LogForModel(item, @"Import successfully completed!"); + } + catch (Exception e) + { + if (!(e is TaskCanceledException)) + LogForModel(item, @"Database import or population failed and has been rolled back.", e); + + throw; + } + + return (Live?)item.ToLive(Realm); + }); + /// /// Any file extensions which should be included in hash creation. /// Generally should include all file types which determine the file's uniqueness. @@ -284,19 +410,13 @@ namespace osu.Game.Stores Logger.Log($"{prefix} {message}", LoggingTarget.Database); } - /// - /// Whether the implementation overrides with a custom implementation. - /// Custom hash implementations must bypass the early exit in the import flow (see usage). - /// - protected virtual bool HasCustomHashFunction => false; - /// /// Create a SHA-2 hash from the provided archive based on file content of all files matching . /// /// /// In the case of no matching files, a hash will be generated from the passed archive's . /// - protected virtual string ComputeHash(TModel item) + public string ComputeHash(TModel item) { // for now, concatenate all hashable files in the set to create a unique hash. MemoryStream hashable = new MemoryStream(); @@ -313,113 +433,6 @@ namespace osu.Game.Stores return item.Hash; } - /// - /// Silently import an item from a . - /// - /// The model to be imported. - /// An optional archive to use for model population. - /// Whether this is a low priority import. - /// An optional cancellation token. - public virtual Live? Import(TModel item, ArchiveReader? archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) - { - return Realm.Run(realm => - { - cancellationToken.ThrowIfCancellationRequested(); - - bool checkedExisting = false; - TModel? existing = null; - - if (archive != null && !HasCustomHashFunction) - { - // this is a fast bail condition to improve large import performance. - item.Hash = computeHashFast(archive); - - checkedExisting = true; - existing = CheckForExisting(item, realm); - - if (existing != null) - { - // bare minimum comparisons - // - // note that this should really be checking filesizes on disk (of existing files) for some degree of sanity. - // or alternatively doing a faster hash check. either of these require database changes and reprocessing of existing files. - if (CanSkipImport(existing, item) && - getFilenames(existing.Files).SequenceEqual(getShortenedFilenames(archive).Select(p => p.shortened).OrderBy(f => f)) && - checkAllFilesExist(existing)) - { - LogForModel(item, @$"Found existing (optimised) {HumanisedModelName} for {item} (ID {existing.ID}) – skipping import."); - - using (var transaction = realm.BeginWrite()) - { - UndeleteForReuse(existing); - transaction.Commit(); - } - - return existing.ToLive(Realm); - } - - LogForModel(item, @"Found existing (optimised) but failed pre-check."); - } - } - - try - { - LogForModel(item, @"Beginning import..."); - - // TODO: do we want to make the transaction this local? not 100% sure, will need further investigation. - using (var transaction = realm.BeginWrite()) - { - if (archive != null) - // TODO: look into rollback of file additions (or delayed commit). - item.Files.AddRange(createFileInfos(archive, Files, realm)); - - item.Hash = ComputeHash(item); - - // TODO: we may want to run this outside of the transaction. - Populate(item, archive, realm, cancellationToken); - - if (!checkedExisting) - existing = CheckForExisting(item, realm); - - if (existing != null) - { - if (CanReuseExisting(existing, item)) - { - LogForModel(item, @$"Found existing {HumanisedModelName} for {item} (ID {existing.ID}) – skipping import."); - - UndeleteForReuse(existing); - transaction.Commit(); - - return existing.ToLive(Realm); - } - - LogForModel(item, @"Found existing but failed re-use check."); - - existing.DeletePending = true; - } - - PreImport(item, realm); - - // import to store - realm.Add(item); - - transaction.Commit(); - } - - LogForModel(item, @"Import successfully completed!"); - } - catch (Exception e) - { - if (!(e is TaskCanceledException)) - LogForModel(item, @"Database import or population failed and has been rolled back.", e); - - throw; - } - - return (Live?)item.ToLive(Realm); - }); - } - private string computeHashFast(ArchiveReader reader) { MemoryStream hashable = new MemoryStream(); @@ -443,16 +456,6 @@ namespace osu.Game.Stores { var fileInfos = new List(); - // import files to manager - foreach (var filenames in getShortenedFilenames(reader)) - { - using (Stream s = reader.GetStream(filenames.original)) - { - var item = new RealmNamedFileUsage(files.Add(s, realm), filenames.shortened); - fileInfos.Add(item); - } - } - return fileInfos; } @@ -493,6 +496,16 @@ namespace osu.Game.Stores { } + /// + /// Perform any final actions before the import has been committed to the database. + /// + /// The model prepared for import. + /// The current realm context. + /// Parameters to further configure the import process. + protected virtual void PostImport(TModel model, Realm realm, ImportParameters parameters) + { + } + /// /// Check whether an existing model already exists for a new import item. /// @@ -521,8 +534,7 @@ namespace osu.Game.Stores // for the best or worst, we copy and import files of a new import before checking whether // it is a duplicate. so to check if anything has changed, we can just compare all File IDs. getIDs(existing.Files).SequenceEqual(getIDs(import.Files)) && - getFilenames(existing.Files).SequenceEqual(getFilenames(import.Files)) && - checkAllFilesExist(existing); + getFilenames(existing.Files).SequenceEqual(getFilenames(import.Files)); private bool checkAllFilesExist(TModel model) => model.Files.All(f => Files.Storage.Exists(f.File.GetStoragePath())); @@ -547,6 +559,23 @@ namespace osu.Game.Stores /// Whether to perform deletion. protected virtual bool ShouldDeleteArchive(string path) => false; + private void pauseIfNecessary(CancellationToken cancellationToken) + { + if (!PauseImports) + return; + + Logger.Log($@"{GetType().Name} is being paused."); + + while (PauseImports) + { + cancellationToken.ThrowIfCancellationRequested(); + Thread.Sleep(500); + } + + cancellationToken.ThrowIfCancellationRequested(); + Logger.Log($@"{GetType().Name} is being resumed."); + } + private IEnumerable getIDs(IEnumerable files) { foreach (var f in files.OrderBy(f => f.Filename)) @@ -559,6 +588,6 @@ namespace osu.Game.Stores yield return f.Filename; } - public virtual string HumanisedModelName => $"{typeof(TModel).Name.Replace(@"Info", "").ToLower()}"; + public virtual string HumanisedModelName => $"{typeof(TModel).Name.Replace(@"Info", "").ToLowerInvariant()}"; } } diff --git a/osu.Game/Database/RealmExtensions.cs b/osu.Game/Database/RealmExtensions.cs index 551b84f7b6..13c4defb83 100644 --- a/osu.Game/Database/RealmExtensions.cs +++ b/osu.Game/Database/RealmExtensions.cs @@ -4,25 +4,64 @@ using System; using Realms; -#nullable enable - namespace osu.Game.Database { public static class RealmExtensions { + /// + /// Perform a write operation against the provided realm instance. + /// + /// + /// This will automatically start a transaction if not already in one. + /// + /// The realm to operate on. + /// The write operation to run. public static void Write(this Realm realm, Action function) { - using var transaction = realm.BeginWrite(); - function(realm); - transaction.Commit(); + Transaction? transaction = null; + + try + { + if (!realm.IsInTransaction) + transaction = realm.BeginWrite(); + + function(realm); + + transaction?.Commit(); + } + finally + { + transaction?.Dispose(); + } } + /// + /// Perform a write operation against the provided realm instance. + /// + /// + /// This will automatically start a transaction if not already in one. + /// + /// The realm to operate on. + /// The write operation to run. public static T Write(this Realm realm, Func function) { - using var transaction = realm.BeginWrite(); - var result = function(realm); - transaction.Commit(); - return result; + Transaction? transaction = null; + + try + { + if (!realm.IsInTransaction) + transaction = realm.BeginWrite(); + + var result = function(realm); + + transaction?.Commit(); + + return result; + } + finally + { + transaction?.Dispose(); + } } /// diff --git a/osu.Game/Stores/RealmFileStore.cs b/osu.Game/Database/RealmFileStore.cs similarity index 78% rename from osu.Game/Stores/RealmFileStore.cs rename to osu.Game/Database/RealmFileStore.cs index 457d70f29a..f75d3be725 100644 --- a/osu.Game/Stores/RealmFileStore.cs +++ b/osu.Game/Database/RealmFileStore.cs @@ -9,14 +9,12 @@ using osu.Framework.IO.Stores; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Testing; -using osu.Game.Database; using osu.Game.Extensions; +using osu.Game.IO; using osu.Game.Models; using Realms; -#nullable enable - -namespace osu.Game.Stores +namespace osu.Game.Database { /// /// Handles the storing of files to the file system (and database) backing. @@ -43,8 +41,9 @@ namespace osu.Game.Stores /// /// The file data stream. /// The realm instance to add to. Should already be in a transaction. - /// - public RealmFile Add(Stream data, Realm realm) + /// Whether the should immediately be added to the underlying realm. If false is provided here, the instance must be manually added. + /// Whether this import should use hard links rather than file copy operations if available. + public RealmFile Add(Stream data, Realm realm, bool addToRealm = true, bool preferHardLinks = false) { string hash = data.ComputeSHA2Hash(); @@ -53,16 +52,23 @@ namespace osu.Game.Stores var file = existing ?? new RealmFile { Hash = hash }; if (!checkFileExistsAndMatchesHash(file)) - copyToStore(file, data); + copyToStore(file, data, preferHardLinks); - if (!file.IsManaged) + if (addToRealm && !file.IsManaged) realm.Add(file); return file; } - private void copyToStore(RealmFile file, Stream data) + private void copyToStore(RealmFile file, Stream data, bool preferHardLinks) { + if (data is FileStream fs && preferHardLinks) + { + // attempt to do a fast hard link rather than copy. + if (HardLinkHelper.TryCreateHardLink(Storage.GetFullPath(file.GetStoragePath(), true), fs.Name)) + return; + } + data.Seek(0, SeekOrigin.Begin); using (var output = Storage.CreateFileSafely(file.GetStoragePath())) diff --git a/osu.Game/Database/RealmLive.cs b/osu.Game/Database/RealmLive.cs index ecfececaa4..9c871a3929 100644 --- a/osu.Game/Database/RealmLive.cs +++ b/osu.Game/Database/RealmLive.cs @@ -7,8 +7,6 @@ using osu.Framework.Development; using osu.Framework.Statistics; using Realms; -#nullable enable - namespace osu.Game.Database { /// @@ -106,9 +104,12 @@ namespace osu.Game.Database PerformRead(t => { - var transaction = t.Realm.BeginWrite(); - perform(t); - transaction.Commit(); + using (var transaction = t.Realm.BeginWrite()) + { + perform(t); + transaction.Commit(); + } + RealmLiveStatistics.WRITES.Value++; }); } diff --git a/osu.Game/Database/RealmLiveUnmanaged.cs b/osu.Game/Database/RealmLiveUnmanaged.cs index 1080f3b8c7..5ab66cac7b 100644 --- a/osu.Game/Database/RealmLiveUnmanaged.cs +++ b/osu.Game/Database/RealmLiveUnmanaged.cs @@ -4,8 +4,6 @@ using System; using Realms; -#nullable enable - namespace osu.Game.Database { /// diff --git a/osu.Game/Database/RealmObjectExtensions.cs b/osu.Game/Database/RealmObjectExtensions.cs index 6dc18df9e0..a771aa04df 100644 --- a/osu.Game/Database/RealmObjectExtensions.cs +++ b/osu.Game/Database/RealmObjectExtensions.cs @@ -14,15 +14,13 @@ using osu.Game.Rulesets; using osu.Game.Scoring; using Realms; -#nullable enable - namespace osu.Game.Database { public static class RealmObjectExtensions { private static readonly IMapper write_mapper = new MapperConfiguration(c => { - c.ShouldMapField = fi => false; + c.ShouldMapField = _ => false; c.ShouldMapProperty = pi => pi.SetMethod?.IsPublic == true; c.CreateMap() @@ -72,7 +70,7 @@ namespace osu.Game.Database } }); - c.Internal().ForAllMaps((typeMap, expression) => + c.Internal().ForAllMaps((_, expression) => { expression.ForAllMembers(m => { @@ -89,7 +87,7 @@ namespace osu.Game.Database c.CreateMap() .ConstructUsing(_ => new BeatmapSetInfo(null)) .MaxDepth(2) - .AfterMap((s, d) => + .AfterMap((_, d) => { foreach (var beatmap in d.Beatmaps) beatmap.BeatmapSet = d; @@ -99,7 +97,7 @@ namespace osu.Game.Database // Only hasn't been done yet as we detach at the point of BeatmapInfo less often. c.CreateMap() .MaxDepth(2) - .AfterMap((s, d) => + .AfterMap((_, d) => { for (int i = 0; i < d.BeatmapSet?.Beatmaps.Count; i++) { @@ -123,7 +121,7 @@ namespace osu.Game.Database .ConstructUsing(_ => new BeatmapSetInfo(null)) .MaxDepth(2) .ForMember(b => b.Files, cc => cc.Ignore()) - .AfterMap((s, d) => + .AfterMap((_, d) => { foreach (var beatmap in d.Beatmaps) beatmap.BeatmapSet = d; @@ -137,14 +135,14 @@ namespace osu.Game.Database private static void applyCommonConfiguration(IMapperConfigurationExpression c) { - c.ShouldMapField = fi => false; + c.ShouldMapField = _ => false; // This is specifically to avoid mapping explicit interface implementations. // If we want to limit this further, we can avoid mapping properties with no setter that are not IList<>. // Takes a bit of effort to determine whether this is the case though, see https://stackoverflow.com/questions/951536/how-do-i-tell-whether-a-type-implements-ilist c.ShouldMapProperty = pi => pi.GetMethod?.IsPublic == true; - c.Internal().ForAllMaps((typeMap, expression) => + c.Internal().ForAllMaps((_, expression) => { expression.ForAllMembers(m => { diff --git a/osu.Game/Database/TooManyDownloadsNotification.cs b/osu.Game/Database/TooManyDownloadsNotification.cs new file mode 100644 index 0000000000..92e9a73026 --- /dev/null +++ b/osu.Game/Database/TooManyDownloadsNotification.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Overlays.Notifications; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Database +{ + public partial class TooManyDownloadsNotification : SimpleNotification + { + public TooManyDownloadsNotification() + { + Text = BeatmapsetsStrings.DownloadLimitExceeded; + Icon = FontAwesome.Solid.ExclamationCircle; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + IconContent.Colour = colours.RedDark; + } + } +} diff --git a/osu.Game/Database/UserLookupCache.cs b/osu.Game/Database/UserLookupCache.cs index 5fdd80892d..b1609fbf7b 100644 --- a/osu.Game/Database/UserLookupCache.cs +++ b/osu.Game/Database/UserLookupCache.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using System.Threading; @@ -11,7 +13,7 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Database { - public class UserLookupCache : OnlineLookupCache + public partial class UserLookupCache : OnlineLookupCache { /// /// Perform an API lookup on the specified user, populating a model. diff --git a/osu.Game/Extensions/DrawableExtensions.cs b/osu.Game/Extensions/DrawableExtensions.cs index d1aba2bfe3..915a2292a2 100644 --- a/osu.Game/Extensions/DrawableExtensions.cs +++ b/osu.Game/Extensions/DrawableExtensions.cs @@ -1,13 +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 Humanizer; -using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Configuration; -using osu.Game.Screens.Play.HUD; -using osu.Game.Skinning; using osuTK; namespace osu.Game.Extensions @@ -49,36 +43,5 @@ namespace osu.Game.Extensions /// The delta vector in Parent's coordinates. public static Vector2 ScreenSpaceDeltaToParentSpace(this Drawable drawable, Vector2 delta) => drawable.Parent.ToLocalSpace(drawable.Parent.ToScreenSpace(Vector2.Zero) + delta); - - public static SkinnableInfo CreateSkinnableInfo(this Drawable component) => new SkinnableInfo(component); - - public static void ApplySkinnableInfo(this Drawable component, SkinnableInfo info) - { - // todo: can probably make this better via deserialisation directly using a common interface. - component.Position = info.Position; - component.Rotation = info.Rotation; - component.Scale = info.Scale; - component.Anchor = info.Anchor; - component.Origin = info.Origin; - - if (component is ISkinnableDrawable skinnable) - { - skinnable.UsesFixedAnchor = info.UsesFixedAnchor; - - foreach (var (_, property) in component.GetSettingsSourceProperties()) - { - if (!info.Settings.TryGetValue(property.Name.Underscore(), out object settingValue)) - continue; - - skinnable.CopyAdjustedSetting((IBindable)property.GetValue(component), settingValue); - } - } - - if (component is Container container) - { - foreach (var child in info.Children) - container.Add(child.CreateInstance()); - } - } } } diff --git a/osu.Game/Extensions/LanguageExtensions.cs b/osu.Game/Extensions/LanguageExtensions.cs index b67e7fb6fc..04231c384c 100644 --- a/osu.Game/Extensions/LanguageExtensions.cs +++ b/osu.Game/Extensions/LanguageExtensions.cs @@ -3,6 +3,8 @@ using System; using System.Globalization; +using osu.Framework.Configuration; +using osu.Framework.Localisation; using osu.Game.Localisation; namespace osu.Game.Extensions @@ -29,5 +31,28 @@ namespace osu.Game.Extensions /// Whether the parsing succeeded. public static bool TryParseCultureCode(string cultureCode, out Language language) => Enum.TryParse(cultureCode.Replace("-", "_"), out language); + + /// + /// Parses the that is specified in , + /// or if that is not valid, the language of the current as exposed by . + /// + /// The current . + /// The current of the . + /// The parsed language. + public static Language GetLanguageFor(string frameworkLocale, LocalisationParameters localisationParameters) + { + // the usual case when the user has changed the language + if (TryParseCultureCode(frameworkLocale, out var language)) + return language; + + if (localisationParameters.Store != null) + { + // startup case, locale not explicitly set, or the set language was removed in an update + if (TryParseCultureCode(localisationParameters.Store.EffectiveCulture.Name, out language)) + return language; + } + + return Language.en; + } } } diff --git a/osu.Game/Extensions/ModelExtensions.cs b/osu.Game/Extensions/ModelExtensions.cs index 13c25e45c8..efb3c4d633 100644 --- a/osu.Game/Extensions/ModelExtensions.cs +++ b/osu.Game/Extensions/ModelExtensions.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.IO; -using System.Linq; +using System.Text.RegularExpressions; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.IO; @@ -11,12 +11,12 @@ using osu.Game.Rulesets; using osu.Game.Scoring; using osu.Game.Users; -#nullable enable - namespace osu.Game.Extensions { public static class ModelExtensions { + private static readonly Regex invalid_filename_chars = new Regex(@"(?!$)[^A-Za-z0-9_()[\]. \-]", RegexOptions.Compiled); + /// /// Get the relative path in osu! storage for this file. /// @@ -139,20 +139,14 @@ namespace osu.Game.Extensions return instance.OnlineID.Equals(other.OnlineID); } - private static readonly char[] invalid_filename_characters = Path.GetInvalidFileNameChars() - // Backslash is added to avoid issues when exporting to zip. - // See SharpCompress filename normalisation https://github.com/adamhathcock/sharpcompress/blob/a1e7c0068db814c9aa78d86a94ccd1c761af74bd/src/SharpCompress/Writers/Zip/ZipWriter.cs#L143. - .Append('\\') - .ToArray(); - /// - /// Get a valid filename for use inside a zip file. Avoids backslashes being incorrectly converted to directories. + /// Create a valid filename which should work across all platforms. /// - public static string GetValidArchiveContentFilename(this string filename) - { - foreach (char c in invalid_filename_characters) - filename = filename.Replace(c, '_'); - return filename; - } + /// + /// This function replaces all characters not included in a very pessimistic list which should be compatible + /// across all operating systems. We are using this in place of as + /// that function does not have per-platform considerations (and is only made to work on windows). + /// + public static string GetValidFilename(this string filename) => invalid_filename_chars.Replace(filename, "_"); } } diff --git a/osu.Game/Extensions/StringDehumanizeExtensions.cs b/osu.Game/Extensions/StringDehumanizeExtensions.cs new file mode 100644 index 0000000000..6f0d7622d3 --- /dev/null +++ b/osu.Game/Extensions/StringDehumanizeExtensions.cs @@ -0,0 +1,94 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +// Based on code from the Humanizer library (https://github.com/Humanizr/Humanizer/blob/606e958cb83afc9be5b36716ac40d4daa9fa73a7/src/Humanizer/InflectorExtensions.cs) +// +// Humanizer is licenced under the MIT License (MIT) +// +// Copyright (c) .NET Foundation and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +using System.Text.RegularExpressions; + +namespace osu.Game.Extensions +{ + /// + /// Class with extension methods used to turn human-readable strings to casing conventions frequently used in code. + /// Often used for communicating with other systems (web API, spectator server). + /// All of the operations in this class are intentionally culture-invariant. + /// + public static class StringDehumanizeExtensions + { + /// + /// Converts the string to "Pascal case" (also known as "upper camel case"). + /// + /// + /// + /// "this is a test string".ToPascalCase() == "ThisIsATestString" + /// + /// + public static string ToPascalCase(this string input) + { + return Regex.Replace(input, "(?:^|_|-| +)(.)", match => match.Groups[1].Value.ToUpperInvariant()); + } + + /// + /// Converts the string to (lower) "camel case". + /// + /// + /// + /// "this is a test string".ToCamelCase() == "thisIsATestString" + /// + /// + public static string ToCamelCase(this string input) + { + string word = input.ToPascalCase(); + return word.Length > 0 ? word.Substring(0, 1).ToLowerInvariant() + word.Substring(1) : word; + } + + /// + /// Converts the string to "snake case". + /// + /// + /// + /// "this is a test string".ToSnakeCase() == "this_is_a_test_string" + /// + /// + public static string ToSnakeCase(this string input) + { + return Regex.Replace( + Regex.Replace( + Regex.Replace(input, @"([\p{Lu}]+)([\p{Lu}][\p{Ll}])", "$1_$2"), @"([\p{Ll}\d])([\p{Lu}])", "$1_$2"), @"[-\s]", "_").ToLowerInvariant(); + } + + /// + /// Converts the string to "kebab case". + /// + /// + /// + /// "this is a test string".ToKebabCase() == "this-is-a-test-string" + /// + /// + public static string ToKebabCase(this string input) + { + return ToSnakeCase(input).Replace('_', '-'); + } + } +} diff --git a/osu.Game/Extensions/TaskExtensions.cs b/osu.Game/Extensions/TaskExtensions.cs index 17f1a491f8..43abb59042 100644 --- a/osu.Game/Extensions/TaskExtensions.cs +++ b/osu.Game/Extensions/TaskExtensions.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 enable - using System; using System.Threading; using System.Threading.Tasks; @@ -33,13 +31,13 @@ namespace osu.Game.Extensions { var tcs = new TaskCompletionSource(); - task.ContinueWith(t => + task.ContinueWith(_ => { // the previous task has finished execution or been cancelled, so we can run the provided continuation. if (cancellationToken.IsCancellationRequested) { - tcs.SetCanceled(); + tcs.SetCanceled(cancellationToken); } else { diff --git a/osu.Game/Extensions/TypeExtensions.cs b/osu.Game/Extensions/TypeExtensions.cs index 2e93c81758..072b18b0ba 100644 --- a/osu.Game/Extensions/TypeExtensions.cs +++ b/osu.Game/Extensions/TypeExtensions.cs @@ -21,7 +21,7 @@ namespace osu.Game.Extensions /// internal static string GetInvariantInstantiationInfo(this Type type) { - string assemblyQualifiedName = type.AssemblyQualifiedName; + string? assemblyQualifiedName = type.AssemblyQualifiedName; if (assemblyQualifiedName == null) throw new ArgumentException($"{type}'s assembly-qualified name is null. Ensure that it is a concrete type and not a generic type parameter.", nameof(type)); diff --git a/osu.Game/Extensions/WebRequestExtensions.cs b/osu.Game/Extensions/WebRequestExtensions.cs index 50837a648d..a80b79f259 100644 --- a/osu.Game/Extensions/WebRequestExtensions.cs +++ b/osu.Game/Extensions/WebRequestExtensions.cs @@ -16,7 +16,7 @@ namespace osu.Game.Extensions /// public static void AddCursor(this WebRequest webRequest, Cursor cursor) { - cursor?.Properties.ForEach(x => + cursor.Properties.ForEach(x => { webRequest.AddParameter("cursor[" + x.Key + "]", (x.Value as JValue)?.ToString(CultureInfo.InvariantCulture) ?? x.Value.ToString()); }); diff --git a/osu.Game/Graphics/Backgrounds/Background.cs b/osu.Game/Graphics/Backgrounds/Background.cs index b09ec1d9b9..bc2ad81fef 100644 --- a/osu.Game/Graphics/Backgrounds/Background.cs +++ b/osu.Game/Graphics/Backgrounds/Background.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using osu.Framework.Allocation; @@ -16,7 +18,7 @@ namespace osu.Game.Graphics.Backgrounds /// /// A background which offers blurring via a on demand. /// - public class Background : CompositeDrawable, IEquatable + public partial class Background : CompositeDrawable, IEquatable { public readonly Sprite Sprite; @@ -55,7 +57,7 @@ namespace osu.Game.Graphics.Backgrounds { if (bufferedContainer == null && newBlurSigma != Vector2.Zero) { - RemoveInternal(Sprite); + RemoveInternal(Sprite, false); AddInternal(bufferedContainer = new BufferedContainer(cachedFrameBuffer: true) { diff --git a/osu.Game/Graphics/Backgrounds/BeatmapBackground.cs b/osu.Game/Graphics/Backgrounds/BeatmapBackground.cs index e0c15dd52a..b79eb4927f 100644 --- a/osu.Game/Graphics/Backgrounds/BeatmapBackground.cs +++ b/osu.Game/Graphics/Backgrounds/BeatmapBackground.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; namespace osu.Game.Graphics.Backgrounds { - public class BeatmapBackground : Background + public partial class BeatmapBackground : Background { public readonly WorkingBeatmap Beatmap; diff --git a/osu.Game/Graphics/Backgrounds/BeatmapBackgroundWithStoryboard.cs b/osu.Game/Graphics/Backgrounds/BeatmapBackgroundWithStoryboard.cs index 7aed442800..9c0d109ce4 100644 --- a/osu.Game/Graphics/Backgrounds/BeatmapBackgroundWithStoryboard.cs +++ b/osu.Game/Graphics/Backgrounds/BeatmapBackgroundWithStoryboard.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 enable - using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -16,7 +14,7 @@ using osu.Game.Storyboards.Drawables; namespace osu.Game.Graphics.Backgrounds { - public class BeatmapBackgroundWithStoryboard : BeatmapBackground + public partial class BeatmapBackgroundWithStoryboard : BeatmapBackground { private readonly InterpolatingFramedClock storyboardClock; diff --git a/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs b/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs index f01a26a3a8..6f6febb646 100644 --- a/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs +++ b/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using osu.Framework.Allocation; @@ -15,7 +17,7 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Graphics.Backgrounds { - public class SeasonalBackgroundLoader : Component + public partial class SeasonalBackgroundLoader : Component { /// /// Fired when background should be changed due to receiving backgrounds from API @@ -39,7 +41,11 @@ namespace osu.Game.Graphics.Backgrounds seasonalBackgroundMode.BindValueChanged(_ => SeasonalBackgroundChanged?.Invoke()); seasonalBackgrounds = sessionStatics.GetBindable(Static.SeasonalBackgrounds); - seasonalBackgrounds.BindValueChanged(_ => SeasonalBackgroundChanged?.Invoke()); + seasonalBackgrounds.BindValueChanged(_ => + { + if (shouldShowSeasonal) + SeasonalBackgroundChanged?.Invoke(); + }); apiState.BindTo(api.State); apiState.BindValueChanged(fetchSeasonalBackgrounds, true); @@ -62,15 +68,10 @@ namespace osu.Game.Graphics.Backgrounds public SeasonalBackground LoadNextBackground() { - if (seasonalBackgroundMode.Value == SeasonalBackgroundMode.Never - || (seasonalBackgroundMode.Value == SeasonalBackgroundMode.Sometimes && !isInSeason)) - { + if (!shouldShowSeasonal) return null; - } - var backgrounds = seasonalBackgrounds.Value?.Backgrounds; - if (backgrounds == null || !backgrounds.Any()) - return null; + var backgrounds = seasonalBackgrounds.Value.Backgrounds; current = (current + 1) % backgrounds.Count; string url = backgrounds[current].Url; @@ -78,11 +79,25 @@ namespace osu.Game.Graphics.Backgrounds return new SeasonalBackground(url); } + private bool shouldShowSeasonal + { + get + { + if (seasonalBackgroundMode.Value == SeasonalBackgroundMode.Never) + return false; + + if (seasonalBackgroundMode.Value == SeasonalBackgroundMode.Sometimes && !isInSeason) + return false; + + return seasonalBackgrounds.Value?.Backgrounds?.Any() == true; + } + } + private bool isInSeason => seasonalBackgrounds.Value != null && DateTimeOffset.Now < seasonalBackgrounds.Value.EndDate; } [LongRunningLoad] - public class SeasonalBackground : Background + public partial class SeasonalBackground : Background { private readonly string url; private const string fallback_texture_name = @"Backgrounds/bg1"; @@ -96,8 +111,6 @@ namespace osu.Game.Graphics.Backgrounds private void load(LargeTextureStore textures) { Sprite.Texture = textures.Get(url) ?? textures.Get(fallback_texture_name); - // ensure we're not loading in without a transition. - this.FadeInFromZero(200, Easing.InOutSine); } public override bool Equals(Background other) diff --git a/osu.Game/Graphics/Backgrounds/SkinBackground.cs b/osu.Game/Graphics/Backgrounds/SkinBackground.cs index 9266e7b17b..e30bb961a0 100644 --- a/osu.Game/Graphics/Backgrounds/SkinBackground.cs +++ b/osu.Game/Graphics/Backgrounds/SkinBackground.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Game.Skinning; namespace osu.Game.Graphics.Backgrounds { - internal class SkinBackground : Background + internal partial class SkinBackground : Background { private readonly Skin skin; diff --git a/osu.Game/Graphics/Backgrounds/TriangleBorderData.cs b/osu.Game/Graphics/Backgrounds/TriangleBorderData.cs new file mode 100644 index 0000000000..f4d327dc8e --- /dev/null +++ b/osu.Game/Graphics/Backgrounds/TriangleBorderData.cs @@ -0,0 +1,16 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Runtime.InteropServices; +using osu.Framework.Graphics.Shaders.Types; + +namespace osu.Game.Graphics.Backgrounds +{ + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public record struct TriangleBorderData + { + public UniformFloat Thickness; + public UniformFloat TexelSize; + private readonly UniformPadding8 pad1; + } +} diff --git a/osu.Game/Graphics/Backgrounds/Triangles.cs b/osu.Game/Graphics/Backgrounds/Triangles.cs index 450c93f37c..0ee42c69d5 100644 --- a/osu.Game/Graphics/Backgrounds/Triangles.cs +++ b/osu.Game/Graphics/Backgrounds/Triangles.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Utils; using osuTK; @@ -12,23 +14,22 @@ using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Primitives; using osu.Framework.Allocation; using System.Collections.Generic; -using osu.Framework.Graphics.Batches; -using osu.Framework.Graphics.OpenGL.Buffers; -using osu.Framework.Graphics.OpenGL.Vertices; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Rendering.Vertices; using osu.Framework.Lists; +using osu.Framework.Bindables; namespace osu.Game.Graphics.Backgrounds { - public class Triangles : Drawable + public partial class Triangles : Drawable { private const float triangle_size = 100; private const float base_velocity = 50; /// - /// How many screen-space pixels are smoothed over. - /// Same behavior as Sprite's EdgeSmoothness. + /// sqrt(3) / 2 /// - private const float edge_smoothness = 1; + private const float equilateral_triangle_ratio = 0.866f; private Color4 colourLight = Color4.White; @@ -68,7 +69,19 @@ namespace osu.Game.Graphics.Backgrounds /// protected virtual float SpawnRatio => 1; - private float triangleScale = 1; + private readonly BindableFloat triangleScale = new BindableFloat(1f); + + public float TriangleScale + { + get => triangleScale.Value; + set => triangleScale.Value = value; + } + + /// + /// If enabled, only the portion of triangles that falls within this 's + /// shape is drawn to the screen. + /// + public bool Masking { get; set; } /// /// Whether we should drop-off alpha values of triangles more quickly to improve @@ -86,7 +99,7 @@ namespace osu.Game.Graphics.Backgrounds private Random stableRandom; private IShader shader; - private readonly Texture texture; + private Texture texture; /// /// Construct a new triangle visualisation. @@ -96,37 +109,19 @@ namespace osu.Game.Graphics.Backgrounds { if (seed != null) stableRandom = new Random(seed.Value); - - texture = Texture.WhitePixel; } [BackgroundDependencyLoader] - private void load(ShaderManager shaders) + private void load(IRenderer renderer, ShaderManager shaders) { - shader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE_ROUNDED); + texture = renderer.WhitePixel; + shader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, "TriangleBorder"); } protected override void LoadComplete() { base.LoadComplete(); - addTriangles(true); - } - - public float TriangleScale - { - get => triangleScale; - set - { - float change = value / triangleScale; - triangleScale = value; - - for (int i = 0; i < parts.Count; i++) - { - TriangleParticle newParticle = parts[i]; - newParticle.Scale *= change; - parts[i] = newParticle; - } - } + triangleScale.BindValueChanged(_ => Reset(), true); } protected override void Update() @@ -147,7 +142,7 @@ namespace osu.Game.Graphics.Backgrounds // Since position is relative, the velocity needs to scale inversely with DrawHeight. // Since we will later multiply by the scale of individual triangles we normalize by // dividing by triangleScale. - float movedDistance = -elapsedSeconds * Velocity * base_velocity / (DrawHeight * triangleScale); + float movedDistance = -elapsedSeconds * Velocity * base_velocity / (DrawHeight * TriangleScale); for (int i = 0; i < parts.Count; i++) { @@ -159,7 +154,7 @@ namespace osu.Game.Graphics.Backgrounds parts[i] = newParticle; - float bottomPos = parts[i].Position.Y + triangle_size * parts[i].Scale * 0.866f / DrawHeight; + float bottomPos = parts[i].Position.Y + triangle_size * parts[i].Scale * equilateral_triangle_ratio / DrawHeight; if (bottomPos < 0) parts.RemoveAt(i); } @@ -182,12 +177,14 @@ namespace osu.Game.Graphics.Backgrounds private void addTriangles(bool randomY) { - // limited by the maximum size of QuadVertexBuffer for safety. - const int max_triangles = QuadVertexBuffer.MAX_QUADS; + // Limited by the maximum size of QuadVertexBuffer for safety. + const int max_triangles = ushort.MaxValue / (IRenderer.VERTICES_PER_QUAD + 2); - AimCount = (int)Math.Min(max_triangles, (DrawWidth * DrawHeight * 0.002f / (triangleScale * triangleScale) * SpawnRatio)); + AimCount = (int)Math.Min(max_triangles, DrawWidth * DrawHeight * 0.002f / (TriangleScale * TriangleScale) * SpawnRatio); - for (int i = 0; i < AimCount - parts.Count; i++) + int currentCount = parts.Count; + + for (int i = 0; i < AimCount - currentCount; i++) parts.Add(createTriangle(randomY)); } @@ -195,13 +192,27 @@ namespace osu.Game.Graphics.Backgrounds { TriangleParticle particle = CreateTriangle(); - particle.Position = new Vector2(nextRandom(), randomY ? nextRandom() : 1); + particle.Position = getRandomPosition(randomY, particle.Scale); particle.ColourShade = nextRandom(); particle.Colour = CreateTriangleShade(particle.ColourShade); return particle; } + private Vector2 getRandomPosition(bool randomY, float scale) + { + float y = 1; + + if (randomY) + { + // since triangles are drawn from the top - allow them to be positioned a bit above the screen + float maxOffset = triangle_size * scale * equilateral_triangle_ratio / DrawHeight; + y = Interpolation.ValueAt(nextRandom(), -maxOffset, 1f, 0f, 1f); + } + + return new Vector2(nextRandom(), y); + } + /// /// Creates a triangle particle with a random scale. /// @@ -214,7 +225,7 @@ namespace osu.Game.Graphics.Backgrounds float u1 = 1 - nextRandom(); //uniform(0,1] random floats float u2 = 1 - nextRandom(); float randStdNormal = (float)(Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Sin(2.0 * Math.PI * u2)); // random normal(0,1) - float scale = Math.Max(triangleScale * (mean + std_dev * randStdNormal), 0.1f); // random normal(mean,stdDev^2) + float scale = Math.Max(TriangleScale * (mean + std_dev * randStdNormal), 0.1f); // random normal(mean,stdDev^2) return new TriangleParticle { Scale = scale }; } @@ -241,15 +252,19 @@ namespace osu.Game.Graphics.Backgrounds private class TrianglesDrawNode : DrawNode { + private const float fill = 1f; + protected new Triangles Source => (Triangles)base.Source; private IShader shader; private Texture texture; + private bool masking; private readonly List parts = new List(); - private Vector2 size; + private readonly Vector2 triangleSize = new Vector2(1f, equilateral_triangle_ratio) * triangle_size; - private QuadBatch vertexBatch; + private Vector2 size; + private IVertexBatch vertexBatch; public TrianglesDrawNode(Triangles source) : base(source) @@ -263,55 +278,86 @@ namespace osu.Game.Graphics.Backgrounds shader = Source.shader; texture = Source.texture; size = Source.DrawSize; + masking = Source.Masking; parts.Clear(); parts.AddRange(Source.parts); } - public override void Draw(Action vertexAction) + private IUniformBuffer borderDataBuffer; + + public override void Draw(IRenderer renderer) { - base.Draw(vertexAction); + base.Draw(renderer); if (Source.AimCount > 0 && (vertexBatch == null || vertexBatch.Size != Source.AimCount)) { vertexBatch?.Dispose(); - vertexBatch = new QuadBatch(Source.AimCount, 1); + vertexBatch = renderer.CreateQuadBatch(Source.AimCount, 1); } - shader.Bind(); + borderDataBuffer ??= renderer.CreateUniformBuffer(); + borderDataBuffer.Data = borderDataBuffer.Data with + { + Thickness = fill, + // Due to triangles having various sizes we would need to set a different "TexelSize" value for each of them, which is insanely expensive, thus we should use one single value. + // TexelSize computed for an average triangle (size 100) will result in big triangles becoming blurry, so we may just use 0 for all of them. + TexelSize = 0 + }; - Vector2 localInflationAmount = edge_smoothness * DrawInfo.MatrixInverse.ExtractScale().Xy; + shader.Bind(); + shader.BindUniformBlock(@"m_BorderData", borderDataBuffer); foreach (TriangleParticle particle in parts) { - var offset = triangle_size * new Vector2(particle.Scale * 0.5f, particle.Scale * 0.866f); + Vector2 relativeSize = Vector2.Divide(triangleSize * particle.Scale, size); - var triangle = new Triangle( - Vector2Extensions.Transform(particle.Position * size, DrawInfo.Matrix), - Vector2Extensions.Transform(particle.Position * size + offset, DrawInfo.Matrix), - Vector2Extensions.Transform(particle.Position * size + new Vector2(-offset.X, offset.Y), DrawInfo.Matrix) + Vector2 topLeft = particle.Position - new Vector2(relativeSize.X * 0.5f, 0f); + + Quad triangleQuad = masking ? clampToDrawable(topLeft, relativeSize) : new Quad(topLeft.X, topLeft.Y, relativeSize.X, relativeSize.Y); + + var drawQuad = new Quad( + Vector2Extensions.Transform(triangleQuad.TopLeft * size, DrawInfo.Matrix), + Vector2Extensions.Transform(triangleQuad.TopRight * size, DrawInfo.Matrix), + Vector2Extensions.Transform(triangleQuad.BottomLeft * size, DrawInfo.Matrix), + Vector2Extensions.Transform(triangleQuad.BottomRight * size, DrawInfo.Matrix) ); ColourInfo colourInfo = DrawColourInfo.Colour; colourInfo.ApplyChild(particle.Colour); - DrawTriangle( - texture, - triangle, - colourInfo, - null, - vertexBatch.AddAction, - Vector2.Divide(localInflationAmount, new Vector2(2 * offset.X, offset.Y))); + RectangleF textureCoords = new RectangleF( + triangleQuad.TopLeft.X - topLeft.X, + triangleQuad.TopLeft.Y - topLeft.Y, + triangleQuad.Width, + triangleQuad.Height + ) / relativeSize; + + renderer.DrawQuad(texture, drawQuad, colourInfo, new RectangleF(0, 0, 1, 1), vertexBatch.AddAction, textureCoords: textureCoords); } shader.Unbind(); } + private static Quad clampToDrawable(Vector2 topLeft, Vector2 size) + { + float leftClamped = Math.Clamp(topLeft.X, 0f, 1f); + float topClamped = Math.Clamp(topLeft.Y, 0f, 1f); + + return new Quad( + leftClamped, + topClamped, + Math.Clamp(topLeft.X + size.X, 0f, 1f) - leftClamped, + Math.Clamp(topLeft.Y + size.Y, 0f, 1f) - topClamped + ); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); vertexBatch?.Dispose(); + borderDataBuffer?.Dispose(); } } diff --git a/osu.Game/Graphics/Backgrounds/TrianglesV2.cs b/osu.Game/Graphics/Backgrounds/TrianglesV2.cs new file mode 100644 index 0000000000..750e96440d --- /dev/null +++ b/osu.Game/Graphics/Backgrounds/TrianglesV2.cs @@ -0,0 +1,317 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Utils; +using osuTK; +using System; +using osu.Framework.Graphics.Shaders; +using osu.Framework.Graphics.Textures; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Allocation; +using System.Collections.Generic; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Rendering.Vertices; +using osu.Framework.Bindables; +using osu.Framework.Graphics; + +namespace osu.Game.Graphics.Backgrounds +{ + public partial class TrianglesV2 : Drawable + { + private const float triangle_size = 100; + private const float base_velocity = 50; + + /// + /// sqrt(3) / 2 + /// + private const float equilateral_triangle_ratio = 0.866f; + + public float Thickness { get; set; } = 0.02f; // No need for invalidation since it's happening in Update() + + /// + /// Whether we should create new triangles as others expire. + /// + protected virtual bool CreateNewTriangles => true; + + /// + /// If enabled, only the portion of triangles that falls within this 's + /// shape is drawn to the screen. + /// + public bool Masking { get; set; } + + private readonly BindableFloat spawnRatio = new BindableFloat(1f); + + /// + /// The amount of triangles we want compared to the default distribution. + /// + public float SpawnRatio + { + get => spawnRatio.Value; + set => spawnRatio.Value = value; + } + + /// + /// The relative velocity of the triangles. Default is 1. + /// + public float Velocity = 1; + + private readonly List parts = new List(); + + private Random? stableRandom; + + private IShader shader = null!; + private Texture texture = null!; + + /// + /// Construct a new triangle visualisation. + /// + /// An optional seed to stabilise random positions / attributes. Note that this does not guarantee stable playback when seeking in time. + public TrianglesV2(int? seed = null) + { + if (seed != null) + stableRandom = new Random(seed.Value); + } + + [BackgroundDependencyLoader] + private void load(ShaderManager shaders, IRenderer renderer) + { + shader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, "TriangleBorder"); + texture = renderer.WhitePixel; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + spawnRatio.BindValueChanged(_ => Reset(), true); + } + + protected override void Update() + { + base.Update(); + + Invalidate(Invalidation.DrawNode); + + if (CreateNewTriangles) + addTriangles(false); + + float elapsedSeconds = (float)Time.Elapsed / 1000; + // Since position is relative, the velocity needs to scale inversely with DrawHeight. + float movedDistance = -elapsedSeconds * Velocity * base_velocity / DrawHeight; + + for (int i = 0; i < parts.Count; i++) + { + TriangleParticle newParticle = parts[i]; + + newParticle.Position.Y += Math.Max(0.5f, parts[i].SpeedMultiplier) * movedDistance; + + parts[i] = newParticle; + + float bottomPos = parts[i].Position.Y + triangle_size * equilateral_triangle_ratio / DrawHeight; + if (bottomPos < 0) + parts.RemoveAt(i); + } + } + + /// + /// Clears and re-initialises triangles according to a given seed. + /// + /// An optional seed to stabilise random positions / attributes. Note that this does not guarantee stable playback when seeking in time. + public void Reset(int? seed = null) + { + if (seed != null) + stableRandom = new Random(seed.Value); + + parts.Clear(); + addTriangles(true); + } + + protected int AimCount { get; private set; } + + private void addTriangles(bool randomY) + { + // Limited by the maximum size of QuadVertexBuffer for safety. + const int max_triangles = ushort.MaxValue / (IRenderer.VERTICES_PER_QUAD + 2); + + AimCount = (int)Math.Clamp(DrawWidth * 0.02f * SpawnRatio, 1, max_triangles); + + int currentCount = parts.Count; + + for (int i = 0; i < AimCount - currentCount; i++) + parts.Add(createTriangle(randomY)); + } + + private TriangleParticle createTriangle(bool randomY) + { + TriangleParticle particle = CreateTriangle(); + + float y = 1; + + if (randomY) + { + // since triangles are drawn from the top - allow them to be positioned a bit above the screen + float maxOffset = triangle_size * equilateral_triangle_ratio / DrawHeight; + y = Interpolation.ValueAt(nextRandom(), -maxOffset, 1f, 0f, 1f); + } + + particle.Position = new Vector2(nextRandom(), y); + + return particle; + } + + /// + /// Creates a triangle particle with a random speed multiplier. + /// + /// The triangle particle. + protected virtual TriangleParticle CreateTriangle() + { + const float std_dev = 0.16f; + const float mean = 0.5f; + + float u1 = 1 - nextRandom(); //uniform(0,1] random floats + float u2 = 1 - nextRandom(); + float randStdNormal = (float)(Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Sin(2.0 * Math.PI * u2)); // random normal(0,1) + float speedMultiplier = Math.Max(mean + std_dev * randStdNormal, 0.1f); // random normal(mean,stdDev^2) + + return new TriangleParticle { SpeedMultiplier = speedMultiplier }; + } + + private float nextRandom() => (float)(stableRandom?.NextDouble() ?? RNG.NextSingle()); + + protected override DrawNode CreateDrawNode() => new TrianglesDrawNode(this); + + private class TrianglesDrawNode : DrawNode + { + protected new TrianglesV2 Source => (TrianglesV2)base.Source; + + private IShader shader = null!; + private Texture texture = null!; + + private readonly List parts = new List(); + + private readonly Vector2 triangleSize = new Vector2(1f, equilateral_triangle_ratio) * triangle_size; + + private Vector2 size; + private float thickness; + private float texelSize; + private bool masking; + + private IVertexBatch? vertexBatch; + + public TrianglesDrawNode(TrianglesV2 source) + : base(source) + { + } + + public override void ApplyState() + { + base.ApplyState(); + + shader = Source.shader; + texture = Source.texture; + size = Source.DrawSize; + thickness = Source.Thickness; + masking = Source.Masking; + + Quad triangleQuad = new Quad( + Vector2Extensions.Transform(Vector2.Zero, DrawInfo.Matrix), + Vector2Extensions.Transform(new Vector2(triangle_size, 0f), DrawInfo.Matrix), + Vector2Extensions.Transform(new Vector2(0f, triangleSize.Y), DrawInfo.Matrix), + Vector2Extensions.Transform(triangleSize, DrawInfo.Matrix) + ); + + texelSize = 1.5f / triangleQuad.Height; + + parts.Clear(); + parts.AddRange(Source.parts); + } + + private IUniformBuffer? borderDataBuffer; + + public override void Draw(IRenderer renderer) + { + base.Draw(renderer); + + if (Source.AimCount == 0 || thickness == 0) + return; + + if (vertexBatch == null || vertexBatch.Size != Source.AimCount) + { + vertexBatch?.Dispose(); + vertexBatch = renderer.CreateQuadBatch(Source.AimCount, 1); + } + + borderDataBuffer ??= renderer.CreateUniformBuffer(); + borderDataBuffer.Data = borderDataBuffer.Data with + { + Thickness = thickness, + TexelSize = texelSize + }; + + shader.Bind(); + shader.BindUniformBlock(@"m_BorderData", borderDataBuffer); + + Vector2 relativeSize = Vector2.Divide(triangleSize, size); + + foreach (TriangleParticle particle in parts) + { + Vector2 topLeft = particle.Position - new Vector2(relativeSize.X * 0.5f, 0f); + + Quad triangleQuad = masking ? clampToDrawable(topLeft, relativeSize) : new Quad(topLeft.X, topLeft.Y, relativeSize.X, relativeSize.Y); + + var drawQuad = new Quad( + Vector2Extensions.Transform(triangleQuad.TopLeft * size, DrawInfo.Matrix), + Vector2Extensions.Transform(triangleQuad.TopRight * size, DrawInfo.Matrix), + Vector2Extensions.Transform(triangleQuad.BottomLeft * size, DrawInfo.Matrix), + Vector2Extensions.Transform(triangleQuad.BottomRight * size, DrawInfo.Matrix) + ); + + RectangleF textureCoords = new RectangleF( + triangleQuad.TopLeft.X - topLeft.X, + triangleQuad.TopLeft.Y - topLeft.Y, + triangleQuad.Width, + triangleQuad.Height + ) / relativeSize; + + renderer.DrawQuad(texture, drawQuad, DrawColourInfo.Colour.Interpolate(triangleQuad), new RectangleF(0, 0, 1, 1), vertexBatch.AddAction, textureCoords: textureCoords); + } + + shader.Unbind(); + } + + private static Quad clampToDrawable(Vector2 topLeft, Vector2 size) + { + float leftClamped = Math.Clamp(topLeft.X, 0f, 1f); + float topClamped = Math.Clamp(topLeft.Y, 0f, 1f); + + return new Quad( + leftClamped, + topClamped, + Math.Clamp(topLeft.X + size.X, 0f, 1f) - leftClamped, + Math.Clamp(topLeft.Y + size.Y, 0f, 1f) - topClamped + ); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + vertexBatch?.Dispose(); + borderDataBuffer?.Dispose(); + } + } + + protected struct TriangleParticle + { + /// + /// The position of the top vertex of the triangle. + /// + public Vector2 Position; + + /// + /// The speed multiplier of the triangle. + /// + public float SpeedMultiplier; + } + } +} diff --git a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs b/osu.Game/Graphics/Containers/BeatSyncedContainer.cs index 953731244d..42b30f9d18 100644 --- a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs +++ b/osu.Game/Graphics/Containers/BeatSyncedContainer.cs @@ -8,13 +8,12 @@ using osu.Framework.Audio.Track; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Screens.Play; namespace osu.Game.Graphics.Containers { /// /// A container which fires a callback when a new beat is reached. - /// Consumes a parent or (whichever is first available). + /// Consumes a parent . /// /// /// This container does not set its own clock to the source used for beat matching. @@ -23,10 +22,13 @@ namespace osu.Game.Graphics.Containers /// /// This container will also trigger beat events when the beat matching clock is paused at 's BPM. /// - public class BeatSyncedContainer : Container + public partial class BeatSyncedContainer : Container { private int lastBeat; - private TimingControlPoint lastTimingPoint; + + private TimingControlPoint? lastTimingPoint { get; set; } + + protected bool IsKiaiTime { get; private set; } /// /// The amount of time before a beat we should fire . @@ -68,12 +70,12 @@ namespace osu.Game.Graphics.Containers public double MinimumBeatLength { get; set; } /// - /// Whether this container is currently tracking a beatmap's timing data. + /// Whether this container is currently tracking a beat sync provider. /// protected bool IsBeatSyncedWithTrack { get; private set; } [Resolved] - protected IBeatSyncProvider BeatSyncSource { get; private set; } + protected IBeatSyncProvider BeatSyncSource { get; private set; } = null!; protected virtual void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { @@ -84,19 +86,18 @@ namespace osu.Game.Graphics.Containers TimingControlPoint timingPoint; EffectControlPoint effectPoint; - IsBeatSyncedWithTrack = BeatSyncSource.Clock?.IsRunning == true; + IsBeatSyncedWithTrack = BeatSyncSource.CheckBeatSyncAvailable() && BeatSyncSource.Clock?.IsRunning == true; double currentTrackTime; if (IsBeatSyncedWithTrack) { - Debug.Assert(BeatSyncSource.ControlPoints != null); Debug.Assert(BeatSyncSource.Clock != null); currentTrackTime = BeatSyncSource.Clock.CurrentTime + EarlyActivationMilliseconds; - timingPoint = BeatSyncSource.ControlPoints.TimingPointAt(currentTrackTime); - effectPoint = BeatSyncSource.ControlPoints.EffectPointAt(currentTrackTime); + timingPoint = BeatSyncSource.ControlPoints?.TimingPointAt(currentTrackTime) ?? TimingControlPoint.DEFAULT; + effectPoint = BeatSyncSource.ControlPoints?.EffectPointAt(currentTrackTime) ?? EffectControlPoint.DEFAULT; } else { @@ -113,7 +114,7 @@ namespace osu.Game.Graphics.Containers while (beatLength < MinimumBeatLength) beatLength *= 2; - int beatIndex = (int)((currentTrackTime - timingPoint.Time) / beatLength) - (effectPoint.OmitFirstBarLine ? 1 : 0); + int beatIndex = (int)((currentTrackTime - timingPoint.Time) / beatLength) - (timingPoint.OmitFirstBarLine ? 1 : 0); // The beats before the start of the first control point are off by 1, this should do the trick if (currentTrackTime < timingPoint.Time) @@ -125,7 +126,7 @@ namespace osu.Game.Graphics.Containers TimeSinceLastBeat = beatLength - TimeUntilNextBeat; - if (timingPoint == lastTimingPoint && beatIndex == lastBeat) + if (ReferenceEquals(timingPoint, lastTimingPoint) && beatIndex == lastBeat) return; // as this event is sometimes used for sound triggers where `BeginDelayedSequence` has no effect, avoid firing it if too far away from the beat. @@ -133,11 +134,13 @@ namespace osu.Game.Graphics.Containers if (AllowMistimedEventFiring || Math.Abs(TimeSinceLastBeat) < MISTIMED_ALLOWANCE) { using (BeginDelayedSequence(-TimeSinceLastBeat)) - OnNewBeat(beatIndex, timingPoint, effectPoint, BeatSyncSource.Amplitudes ?? ChannelAmplitudes.Empty); + OnNewBeat(beatIndex, timingPoint, effectPoint, BeatSyncSource.CurrentAmplitudes); } lastBeat = beatIndex; lastTimingPoint = timingPoint; + + IsKiaiTime = effectPoint.KiaiMode; } } } diff --git a/osu.Game/Graphics/Containers/ConstrainedIconContainer.cs b/osu.Game/Graphics/Containers/ConstrainedIconContainer.cs index 9e1af1944c..55160e14af 100644 --- a/osu.Game/Graphics/Containers/ConstrainedIconContainer.cs +++ b/osu.Game/Graphics/Containers/ConstrainedIconContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -12,7 +14,7 @@ namespace osu.Game.Graphics.Containers /// /// Display an icon that is forced to scale to the size of this container. /// - public class ConstrainedIconContainer : CompositeDrawable + public partial class ConstrainedIconContainer : CompositeDrawable { public Drawable Icon { diff --git a/osu.Game/Graphics/Containers/ExpandingButtonContainer.cs b/osu.Game/Graphics/Containers/ExpandingButtonContainer.cs index 859850e771..a06af61125 100644 --- a/osu.Game/Graphics/Containers/ExpandingButtonContainer.cs +++ b/osu.Game/Graphics/Containers/ExpandingButtonContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Graphics.Containers { /// @@ -9,7 +11,7 @@ namespace osu.Game.Graphics.Containers /// /// Mostly used for buttons with explanatory labels, in which the label would display after a "long hover". /// - public class ExpandingButtonContainer : ExpandingContainer + public partial class ExpandingButtonContainer : ExpandingContainer { protected ExpandingButtonContainer(float contractedWidth, float expandedWidth) : base(contractedWidth, expandedWidth) diff --git a/osu.Game/Graphics/Containers/ExpandingContainer.cs b/osu.Game/Graphics/Containers/ExpandingContainer.cs index b50e008362..60b9e6a167 100644 --- a/osu.Game/Graphics/Containers/ExpandingContainer.cs +++ b/osu.Game/Graphics/Containers/ExpandingContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -12,7 +14,7 @@ namespace osu.Game.Graphics.Containers /// /// Represents a with the ability to expand/contract on hover. /// - public class ExpandingContainer : Container, IExpandingContainer + public partial class ExpandingContainer : Container, IExpandingContainer { private readonly float contractedWidth; private readonly float expandedWidth; diff --git a/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs b/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs index 28afd082c3..9f21512825 100644 --- a/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs +++ b/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -20,7 +22,7 @@ namespace osu.Game.Graphics.Containers /// The is exposed as a transforming bindable which smoothly tracks the progress of a hold operation. /// It can be used for animating and displaying progress directly. /// - public abstract class HoldToConfirmContainer : Container + public abstract partial class HoldToConfirmContainer : Container { public const double DANGEROUS_HOLD_ACTIVATION_DELAY = 500; @@ -98,9 +100,9 @@ namespace osu.Game.Graphics.Containers /// /// Abort any ongoing confirmation. Should be called when the container's interaction is no longer valid (ie. the user releases a key). /// - protected void AbortConfirm() + protected virtual void AbortConfirm() { - if (!AllowMultipleFires && Fired) return; + if (!confirming || (!AllowMultipleFires && Fired)) return; confirming = false; Fired = false; diff --git a/osu.Game/Graphics/Containers/IExpandable.cs b/osu.Game/Graphics/Containers/IExpandable.cs index 593564a2f9..05d569775c 100644 --- a/osu.Game/Graphics/Containers/IExpandable.cs +++ b/osu.Game/Graphics/Containers/IExpandable.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Graphics; diff --git a/osu.Game/Graphics/Containers/IExpandingContainer.cs b/osu.Game/Graphics/Containers/IExpandingContainer.cs index eb186c96a8..dbd9274ae7 100644 --- a/osu.Game/Graphics/Containers/IExpandingContainer.cs +++ b/osu.Game/Graphics/Containers/IExpandingContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; diff --git a/osu.Game/Graphics/Containers/LinkFlowContainer.cs b/osu.Game/Graphics/Containers/LinkFlowContainer.cs index 1d286d3487..2d27ce906b 100644 --- a/osu.Game/Graphics/Containers/LinkFlowContainer.cs +++ b/osu.Game/Graphics/Containers/LinkFlowContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Online.Chat; using System; using System.Linq; @@ -11,11 +13,12 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; using osu.Framework.Platform; +using osu.Game.Online; using osu.Game.Users; namespace osu.Game.Graphics.Containers { - public class LinkFlowContainer : OsuTextFlowContainer + public partial class LinkFlowContainer : OsuTextFlowContainer { public LinkFlowContainer(Action defaultCreationParameters = null) : base(defaultCreationParameters) @@ -23,7 +26,7 @@ namespace osu.Game.Graphics.Containers } [Resolved(CanBeNull = true)] - private OsuGame game { get; set; } + private ILinkHandler linkHandler { get; set; } [Resolved] private GameHost host { get; set; } @@ -79,8 +82,8 @@ namespace osu.Game.Graphics.Containers { if (action != null) action(); - else if (game != null) - game.HandleLink(link); + else if (linkHandler != null) + linkHandler.HandleLink(link); // fallback to handle cases where OsuGame is not available, ie. tournament client. else if (link.Action == LinkAction.External) host.OpenUrlExternally(link.Argument.ToString()); diff --git a/osu.Game/Graphics/Containers/LogoTrackingContainer.cs b/osu.Game/Graphics/Containers/LogoTrackingContainer.cs index f89f3a5e76..984d60d35e 100644 --- a/osu.Game/Graphics/Containers/LogoTrackingContainer.cs +++ b/osu.Game/Graphics/Containers/LogoTrackingContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -13,7 +15,7 @@ namespace osu.Game.Graphics.Containers /// /// A container that handles tracking of an through different layout scenarios. /// - public class LogoTrackingContainer : Container + public partial class LogoTrackingContainer : Container { public Facade LogoFacade => facade; @@ -34,8 +36,7 @@ namespace osu.Game.Graphics.Containers /// The easing type of the initial transform. public void StartTracking(OsuLogo logo, double duration = 0, Easing easing = Easing.None) { - if (logo == null) - throw new ArgumentNullException(nameof(logo)); + ArgumentNullException.ThrowIfNull(logo); if (logo.IsTracking && Logo == null) throw new InvalidOperationException($"Cannot track an instance of {typeof(OsuLogo)} to multiple {typeof(LogoTrackingContainer)}s"); @@ -129,7 +130,7 @@ namespace osu.Game.Graphics.Containers base.Dispose(isDisposing); } - private class InternalFacade : Facade + private partial class InternalFacade : Facade { public new void SetSize(Vector2 size) { @@ -140,7 +141,7 @@ namespace osu.Game.Graphics.Containers /// /// A dummy object used to denote another object's location. /// - public abstract class Facade : Drawable + public abstract partial class Facade : Drawable { public override Vector2 Size { diff --git a/osu.Game/Graphics/Containers/Markdown/Extensions/BlockAttributeExtension.cs b/osu.Game/Graphics/Containers/Markdown/Extensions/BlockAttributeExtension.cs new file mode 100644 index 0000000000..caed4b26b9 --- /dev/null +++ b/osu.Game/Graphics/Containers/Markdown/Extensions/BlockAttributeExtension.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Markdig; +using Markdig.Extensions.GenericAttributes; +using Markdig.Renderers; +using Markdig.Syntax; + +namespace osu.Game.Graphics.Containers.Markdown.Extensions +{ + /// + /// A variant of + /// which only handles generic attributes in the current markdown and ignores inline generic attributes. + /// + /// + /// For rationale, see implementation of . + /// + public class BlockAttributeExtension : IMarkdownExtension + { + private readonly GenericAttributesExtension genericAttributesExtension = new GenericAttributesExtension(); + + public void Setup(MarkdownPipelineBuilder pipeline) + { + genericAttributesExtension.Setup(pipeline); + + // GenericAttributesExtension registers a GenericAttributesParser in pipeline.InlineParsers. + // this conflicts with the CustomContainerExtension, leading to some custom containers (e.g. flags) not displaying. + // as a workaround, remove the inline parser here before it can do damage. + pipeline.InlineParsers.RemoveAll(parser => parser is GenericAttributesParser); + } + + public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) => genericAttributesExtension.Setup(pipeline, renderer); + } +} diff --git a/osu.Game/Graphics/Containers/Markdown/Extensions/OsuMarkdownExtensions.cs b/osu.Game/Graphics/Containers/Markdown/Extensions/OsuMarkdownExtensions.cs new file mode 100644 index 0000000000..10542abe71 --- /dev/null +++ b/osu.Game/Graphics/Containers/Markdown/Extensions/OsuMarkdownExtensions.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Markdig; + +namespace osu.Game.Graphics.Containers.Markdown.Extensions +{ + public static class OsuMarkdownExtensions + { + /// + /// Uses the block attributes extension. + /// + /// The pipeline. + /// The modified pipeline. + public static MarkdownPipelineBuilder UseBlockAttributes(this MarkdownPipelineBuilder pipeline) + { + pipeline.Extensions.AddIfNotAlready(); + return pipeline; + } + } +} diff --git a/osu.Game/Graphics/Containers/Markdown/Footnotes/OsuMarkdownFootnote.cs b/osu.Game/Graphics/Containers/Markdown/Footnotes/OsuMarkdownFootnote.cs new file mode 100644 index 0000000000..e92d866eed --- /dev/null +++ b/osu.Game/Graphics/Containers/Markdown/Footnotes/OsuMarkdownFootnote.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Markdig.Extensions.Footnotes; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Framework.Graphics.Containers.Markdown.Footnotes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; + +namespace osu.Game.Graphics.Containers.Markdown.Footnotes +{ + public partial class OsuMarkdownFootnote : MarkdownFootnote + { + public OsuMarkdownFootnote(Footnote footnote) + : base(footnote) + { + } + + public override SpriteText CreateOrderMarker(int order) => CreateSpriteText().With(marker => + { + marker.Text = LocalisableString.Format("{0}.", order); + }); + + public override MarkdownTextFlowContainer CreateTextFlow() => base.CreateTextFlow().With(textFlow => + { + textFlow.Margin = new MarginPadding { Left = 30 }; + }); + } +} diff --git a/osu.Game/Graphics/Containers/Markdown/Footnotes/OsuMarkdownFootnoteBacklink.cs b/osu.Game/Graphics/Containers/Markdown/Footnotes/OsuMarkdownFootnoteBacklink.cs new file mode 100644 index 0000000000..22c02ea720 --- /dev/null +++ b/osu.Game/Graphics/Containers/Markdown/Footnotes/OsuMarkdownFootnoteBacklink.cs @@ -0,0 +1,62 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using Markdig.Extensions.Footnotes; +using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Graphics.Containers.Markdown.Footnotes +{ + public partial class OsuMarkdownFootnoteBacklink : OsuHoverContainer + { + private readonly FootnoteLink backlink; + + private SpriteIcon spriteIcon = null!; + + [Resolved] + private IMarkdownTextComponent parentTextComponent { get; set; } = null!; + + protected override IEnumerable EffectTargets => spriteIcon.Yield(); + + public OsuMarkdownFootnoteBacklink(FootnoteLink backlink) + { + this.backlink = backlink; + } + + [BackgroundDependencyLoader(true)] + private void load(OverlayColourProvider colourProvider, OsuMarkdownContainer markdownContainer, OverlayScrollContainer? scrollContainer) + { + float fontSize = parentTextComponent.CreateSpriteText().Font.Size; + Size = new Vector2(fontSize); + + IdleColour = colourProvider.Light2; + HoverColour = colourProvider.Light1; + + Add(spriteIcon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding { Left = 5 }, + Size = new Vector2(fontSize / 2), + Icon = FontAwesome.Solid.ArrowUp, + }); + + if (scrollContainer != null) + { + Action = () => + { + var footnoteLink = markdownContainer.ChildrenOfType().Single(footnoteLink => footnoteLink.FootnoteLink.Index == backlink.Index); + scrollContainer.ScrollIntoView(footnoteLink); + }; + } + } + } +} diff --git a/osu.Game/Graphics/Containers/Markdown/Footnotes/OsuMarkdownFootnoteLink.cs b/osu.Game/Graphics/Containers/Markdown/Footnotes/OsuMarkdownFootnoteLink.cs new file mode 100644 index 0000000000..c9bd408e9e --- /dev/null +++ b/osu.Game/Graphics/Containers/Markdown/Footnotes/OsuMarkdownFootnoteLink.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.Collections.Generic; +using System.Linq; +using Markdig.Extensions.Footnotes; +using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Framework.Testing; +using osu.Game.Overlays; + +namespace osu.Game.Graphics.Containers.Markdown.Footnotes +{ + public partial class OsuMarkdownFootnoteLink : OsuHoverContainer, IHasCustomTooltip + { + public readonly FootnoteLink FootnoteLink; + + private SpriteText spriteText = null!; + + [Resolved] + private IMarkdownTextComponent parentTextComponent { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private OsuMarkdownContainer markdownContainer { get; set; } = null!; + + protected override IEnumerable EffectTargets => spriteText.Yield(); + + public OsuMarkdownFootnoteLink(FootnoteLink footnoteLink) + { + FootnoteLink = footnoteLink; + + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader(true)] + private void load(OsuMarkdownContainer markdownContainer, OverlayScrollContainer? scrollContainer) + { + IdleColour = colourProvider.Light2; + HoverColour = colourProvider.Light1; + + spriteText = parentTextComponent.CreateSpriteText(); + + Add(spriteText.With(t => + { + float baseSize = t.Font.Size; + t.Font = t.Font.With(size: baseSize * 0.58f); + t.Margin = new MarginPadding { Bottom = 0.33f * baseSize }; + t.Text = LocalisableString.Format("[{0}]", FootnoteLink.Index); + })); + + if (scrollContainer != null) + { + Action = () => + { + var footnote = markdownContainer.ChildrenOfType().Single(footnote => footnote.Footnote.Label == FootnoteLink.Footnote.Label); + scrollContainer.ScrollIntoView(footnote); + }; + } + } + + public object TooltipContent + { + get + { + var span = FootnoteLink.Footnote.LastChild.Span; + return markdownContainer.Text.Substring(span.Start, span.Length); + } + } + + public ITooltip GetCustomTooltip() => new OsuMarkdownFootnoteTooltip(colourProvider); + } +} diff --git a/osu.Game/Graphics/Containers/Markdown/Footnotes/OsuMarkdownFootnoteTooltip.cs b/osu.Game/Graphics/Containers/Markdown/Footnotes/OsuMarkdownFootnoteTooltip.cs new file mode 100644 index 0000000000..af64913212 --- /dev/null +++ b/osu.Game/Graphics/Containers/Markdown/Footnotes/OsuMarkdownFootnoteTooltip.cs @@ -0,0 +1,76 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Markdig.Extensions.Footnotes; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Graphics.Containers.Markdown.Footnotes +{ + public partial class OsuMarkdownFootnoteTooltip : CompositeDrawable, ITooltip + { + private readonly FootnoteMarkdownContainer markdownContainer; + + [Cached] + private OverlayColourProvider colourProvider; + + public OsuMarkdownFootnoteTooltip(OverlayColourProvider colourProvider) + { + this.colourProvider = colourProvider; + + Masking = true; + Width = 200; + AutoSizeAxes = Axes.Y; + CornerRadius = 4; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6 + }, + markdownContainer = new FootnoteMarkdownContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + DocumentMargin = new MarginPadding(), + DocumentPadding = new MarginPadding { Horizontal = 10, Vertical = 5 } + } + }; + } + + public void Move(Vector2 pos) => Position = pos; + + public void SetContent(object content) => markdownContainer.SetContent((string)content); + + private partial class FootnoteMarkdownContainer : OsuMarkdownContainer + { + private string? lastFootnote; + + public void SetContent(string footnote) + { + if (footnote == lastFootnote) + return; + + lastFootnote = Text = footnote; + } + + public override MarkdownTextFlowContainer CreateTextFlow() => new FootnoteMarkdownTextFlowContainer(); + } + + private partial class FootnoteMarkdownTextFlowContainer : OsuMarkdownTextFlowContainer + { + protected override void AddFootnoteBacklink(FootnoteLink footnoteBacklink) + { + // we don't want footnote backlinks to show up in tooltips. + } + } + } +} diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainer.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainer.cs index 296c600771..5b1780a068 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainer.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainer.cs @@ -1,20 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Markdig; -using Markdig.Extensions.AutoIdentifiers; +using Markdig.Extensions.Footnotes; using Markdig.Extensions.Tables; using Markdig.Extensions.Yaml; using Markdig.Syntax; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers.Markdown; +using osu.Framework.Graphics.Containers.Markdown.Footnotes; using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics.Containers.Markdown.Footnotes; using osu.Game.Graphics.Sprites; +using osuTK; namespace osu.Game.Graphics.Containers.Markdown { - public class OsuMarkdownContainer : MarkdownContainer + [Cached] + public partial class OsuMarkdownContainer : MarkdownContainer { public OsuMarkdownContainer() { @@ -25,7 +32,7 @@ namespace osu.Game.Graphics.Containers.Markdown { switch (markdownObject) { - case YamlFrontMatterBlock _: + case YamlFrontMatterBlock: // Don't parse YAML Frontmatter break; @@ -76,10 +83,17 @@ namespace osu.Game.Graphics.Containers.Markdown return new OsuMarkdownUnorderedListItem(level); } - protected override MarkdownPipeline CreateBuilder() - => new MarkdownPipelineBuilder().UseAutoIdentifiers(AutoIdentifierOptions.GitHub) - .UseEmojiAndSmiley() - .UseYamlFrontMatter() - .UseAdvancedExtensions().Build(); + protected override MarkdownFootnoteGroup CreateFootnoteGroup(FootnoteGroup footnoteGroup) => base.CreateFootnoteGroup(footnoteGroup).With(g => g.Spacing = new Vector2(5)); + + protected override MarkdownFootnote CreateFootnote(Footnote footnote) => new OsuMarkdownFootnote(footnote); + + protected sealed override MarkdownPipeline CreateBuilder() + => Options.BuildPipeline(); + + /// + /// Creates a instance which is used to determine + /// which CommonMark/Markdig extensions should be enabled for this . + /// + protected virtual OsuMarkdownContainerOptions Options => new OsuMarkdownContainerOptions(); } } diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainerOptions.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainerOptions.cs new file mode 100644 index 0000000000..1648ffbf90 --- /dev/null +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainerOptions.cs @@ -0,0 +1,71 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Markdig; +using Markdig.Extensions.AutoLinks; +using Markdig.Extensions.CustomContainers; +using Markdig.Extensions.EmphasisExtras; +using Markdig.Extensions.Footnotes; +using osu.Game.Graphics.Containers.Markdown.Extensions; + +namespace osu.Game.Graphics.Containers.Markdown +{ + /// + /// Groups options of customising the set of available extensions to instances. + /// + public class OsuMarkdownContainerOptions + { + /// + /// Allows the to parse and link footnotes. + /// + /// + public bool Footnotes { get; init; } + + /// + /// Allows the container to make URL text clickable. + /// + /// + public bool Autolinks { get; init; } + + /// + /// Allows the to parse custom containers (used for flags and infoboxes). + /// + /// + public bool CustomContainers { get; init; } + + /// + /// Allows the to parse custom attributes in block elements (used e.g. for custom anchor names in the wiki). + /// + /// + public bool BlockAttributes { get; init; } + + /// + /// Returns a prepared according to the options specified by the current instance. + /// + /// + /// Compare: https://github.com/ppy/osu-web/blob/05488a96b25b5a09f2d97c54c06dd2bae59d1dc8/app/Libraries/Markdown/OsuMarkdown.php#L301 + /// + public MarkdownPipeline BuildPipeline() + { + var pipeline = new MarkdownPipelineBuilder() + .UseAutoIdentifiers() + .UsePipeTables() + .UseEmphasisExtras(EmphasisExtraOptions.Strikethrough) + .UseYamlFrontMatter(); + + if (Footnotes) + pipeline = pipeline.UseFootnotes(); + + if (Autolinks) + pipeline = pipeline.UseAutoLinks(); + + if (CustomContainers) + pipeline = pipeline.UseCustomContainers(); + + if (BlockAttributes) + pipeline = pipeline.UseBlockAttributes(); + + return pipeline.Build(); + } + } +} diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownFencedCodeBlock.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownFencedCodeBlock.cs index 0d67849060..b5bbe3e2cc 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownFencedCodeBlock.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownFencedCodeBlock.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Markdig.Syntax; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osu.Game.Overlays; namespace osu.Game.Graphics.Containers.Markdown { - public class OsuMarkdownFencedCodeBlock : MarkdownFencedCodeBlock + public partial class OsuMarkdownFencedCodeBlock : MarkdownFencedCodeBlock { // TODO : change to monospace font for this component public OsuMarkdownFencedCodeBlock(FencedCodeBlock fencedCodeBlock) @@ -22,7 +24,7 @@ namespace osu.Game.Graphics.Containers.Markdown public override MarkdownTextFlowContainer CreateTextFlow() => new CodeBlockTextFlowContainer(); - private class CodeBlockBackground : Box + private partial class CodeBlockBackground : Box { [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) @@ -32,7 +34,7 @@ namespace osu.Game.Graphics.Containers.Markdown } } - private class CodeBlockTextFlowContainer : OsuMarkdownTextFlowContainer + private partial class CodeBlockTextFlowContainer : OsuMarkdownTextFlowContainer { [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownHeading.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownHeading.cs index e4685a2935..800a0e1fc3 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownHeading.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownHeading.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Markdig.Syntax; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers.Markdown; @@ -8,7 +10,7 @@ using osu.Framework.Graphics.Sprites; namespace osu.Game.Graphics.Containers.Markdown { - public class OsuMarkdownHeading : MarkdownHeading + public partial class OsuMarkdownHeading : MarkdownHeading { private readonly int level; @@ -64,7 +66,7 @@ namespace osu.Game.Graphics.Containers.Markdown } } - private class HeadingTextFlowContainer : OsuMarkdownTextFlowContainer + private partial class HeadingTextFlowContainer : OsuMarkdownTextFlowContainer { public float FontSize; public FontWeight FontWeight; diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownImage.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownImage.cs index ce8a9c8f9f..8ccac158eb 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownImage.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownImage.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Markdig.Syntax.Inlines; using osu.Framework.Graphics.Containers.Markdown; using osu.Framework.Graphics.Cursor; @@ -8,7 +10,7 @@ using osu.Framework.Localisation; namespace osu.Game.Graphics.Containers.Markdown { - public class OsuMarkdownImage : MarkdownImage, IHasTooltip + public partial class OsuMarkdownImage : MarkdownImage, IHasTooltip { public LocalisableString TooltipText { get; } diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownLinkText.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownLinkText.cs index 82e556f653..0049feca02 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownLinkText.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownLinkText.cs @@ -1,20 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using Markdig.Syntax.Inlines; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers.Markdown; +using osu.Game.Online; using osu.Game.Online.Chat; using osu.Game.Overlays; namespace osu.Game.Graphics.Containers.Markdown { - public class OsuMarkdownLinkText : MarkdownLinkText + public partial class OsuMarkdownLinkText : MarkdownLinkText { [Resolved(canBeNull: true)] - private OsuGame game { get; set; } + private ILinkHandler linkHandler { get; set; } private readonly string text; private readonly string title; @@ -49,9 +52,9 @@ namespace osu.Game.Graphics.Containers.Markdown }; } - protected override void OnLinkPressed() => game?.HandleLink(Url); + protected override void OnLinkPressed() => linkHandler?.HandleLink(Url); - private class OsuMarkdownLinkCompiler : DrawableLinkCompiler + private partial class OsuMarkdownLinkCompiler : DrawableLinkCompiler { public OsuMarkdownLinkCompiler(IEnumerable parts) : base(parts) diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownListItem.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownListItem.cs index 8c4c3e1da2..2d38e44d32 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownListItem.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownListItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,7 +12,7 @@ using osuTK; namespace osu.Game.Graphics.Containers.Markdown { - public abstract class OsuMarkdownListItem : CompositeDrawable + public abstract partial class OsuMarkdownListItem : CompositeDrawable { [Resolved] private IMarkdownTextComponent parentTextComponent { get; set; } diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownOrderedListItem.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownOrderedListItem.cs index 8fedb189b2..6eac9378ae 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownOrderedListItem.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownOrderedListItem.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; namespace osu.Game.Graphics.Containers.Markdown { - public class OsuMarkdownOrderedListItem : OsuMarkdownListItem + public partial class OsuMarkdownOrderedListItem : OsuMarkdownListItem { private const float left_padding = 30; diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownQuoteBlock.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownQuoteBlock.cs index 9935c81537..447085a48c 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownQuoteBlock.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownQuoteBlock.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Markdig.Syntax; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osu.Game.Overlays; namespace osu.Game.Graphics.Containers.Markdown { - public class OsuMarkdownQuoteBlock : MarkdownQuoteBlock + public partial class OsuMarkdownQuoteBlock : MarkdownQuoteBlock { public OsuMarkdownQuoteBlock(QuoteBlock quoteBlock) : base(quoteBlock) @@ -28,7 +30,7 @@ namespace osu.Game.Graphics.Containers.Markdown }); } - private class QuoteBackground : Box + private partial class QuoteBackground : Box { [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownSeparator.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownSeparator.cs index 28a87c9f21..343a1d1015 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownSeparator.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownSeparator.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers.Markdown; @@ -9,11 +11,11 @@ using osu.Game.Overlays; namespace osu.Game.Graphics.Containers.Markdown { - public class OsuMarkdownSeparator : MarkdownSeparator + public partial class OsuMarkdownSeparator : MarkdownSeparator { protected override Drawable CreateSeparator() => new Separator(); - private class Separator : Box + private partial class Separator : Box { [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTable.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTable.cs index e0a1ab1220..c9c1098e05 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTable.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTable.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Markdig.Extensions.Tables; using osu.Framework.Graphics.Containers.Markdown; namespace osu.Game.Graphics.Containers.Markdown { - public class OsuMarkdownTable : MarkdownTable + public partial class OsuMarkdownTable : MarkdownTable { public OsuMarkdownTable(Table table) : base(table) diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTableCell.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTableCell.cs index ac7d07e283..dbf15a2546 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTableCell.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTableCell.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Markdig.Extensions.Tables; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osu.Game.Overlays; namespace osu.Game.Graphics.Containers.Markdown { - public class OsuMarkdownTableCell : MarkdownTableCell + public partial class OsuMarkdownTableCell : MarkdownTableCell { private readonly bool isHeading; @@ -43,7 +45,7 @@ namespace osu.Game.Graphics.Containers.Markdown return new TableBodyBorder(); } - private class TableHeadBorder : Box + private partial class TableHeadBorder : Box { [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) @@ -56,7 +58,7 @@ namespace osu.Game.Graphics.Containers.Markdown } } - private class TableBodyBorder : Box + private partial class TableBodyBorder : Box { [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) @@ -67,7 +69,7 @@ namespace osu.Game.Graphics.Containers.Markdown } } - private class TableCellTextFlowContainer : OsuMarkdownTextFlowContainer + private partial class TableCellTextFlowContainer : OsuMarkdownTextFlowContainer { public FontWeight Weight { get; set; } diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs index a7cd6b3905..dbc358882c 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs @@ -1,6 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System; +using System.Linq; +using Markdig.Extensions.CustomContainers; +using Markdig.Extensions.Footnotes; using Markdig.Syntax.Inlines; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -8,11 +14,15 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers.Markdown; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics.Containers.Markdown.Footnotes; using osu.Game.Overlays; +using osu.Game.Users; +using osu.Game.Users.Drawables; +using osuTK; namespace osu.Game.Graphics.Containers.Markdown { - public class OsuMarkdownTextFlowContainer : MarkdownTextFlowContainer + public partial class OsuMarkdownTextFlowContainer : MarkdownTextFlowContainer { protected override void AddLinkText(string text, LinkInline linkInline) => AddDrawable(new OsuMarkdownLinkText(text, linkInline)); @@ -28,10 +38,43 @@ namespace osu.Game.Graphics.Containers.Markdown Text = codeInline.Content }); - protected override SpriteText CreateEmphasisedSpriteText(bool bold, bool italic) - => CreateSpriteText().With(t => t.Font = t.Font.With(weight: bold ? FontWeight.Bold : FontWeight.Regular, italics: italic)); + protected override void AddFootnoteLink(FootnoteLink footnoteLink) => AddDrawable(new OsuMarkdownFootnoteLink(footnoteLink)); - private class OsuMarkdownInlineCode : Container + protected override void AddFootnoteBacklink(FootnoteLink footnoteBacklink) => AddDrawable(new OsuMarkdownFootnoteBacklink(footnoteBacklink)); + + protected override void ApplyEmphasisedCreationParameters(SpriteText spriteText, bool bold, bool italic) + { + base.ApplyEmphasisedCreationParameters(spriteText, bold, italic); + + spriteText.Font = spriteText.Font.With(weight: bold ? FontWeight.Bold : FontWeight.Regular, italics: italic); + } + + protected override void AddCustomComponent(CustomContainerInline inline) + { + if (!(inline.FirstChild is LiteralInline literal)) + { + base.AddCustomComponent(inline); + return; + } + + string[] attributes = literal.Content.ToString().Trim(' ', '{', '}').Split(); + string flagAttribute = attributes.SingleOrDefault(a => a.StartsWith(@"flag", StringComparison.Ordinal)); + + if (flagAttribute == null) + { + base.AddCustomComponent(inline); + return; + } + + string flag = flagAttribute.Split('=').Last().Trim('"'); + + if (!Enum.TryParse(flag, out var countryCode)) + countryCode = CountryCode.Unknown; + + AddDrawable(new DrawableFlag(countryCode) { Size = new Vector2(20, 15) }); + } + + private partial class OsuMarkdownInlineCode : Container { [Resolved] private IMarkdownTextComponent parentTextComponent { get; set; } diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownUnorderedListItem.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownUnorderedListItem.cs index 5d1e114781..64e98511c2 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownUnorderedListItem.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownUnorderedListItem.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; namespace osu.Game.Graphics.Containers.Markdown { - public class OsuMarkdownUnorderedListItem : OsuMarkdownListItem + public partial class OsuMarkdownUnorderedListItem : OsuMarkdownListItem { private const float left_padding = 20; diff --git a/osu.Game/Graphics/Containers/OsuClickableContainer.cs b/osu.Game/Graphics/Containers/OsuClickableContainer.cs index bf397e4251..fceee90d06 100644 --- a/osu.Game/Graphics/Containers/OsuClickableContainer.cs +++ b/osu.Game/Graphics/Containers/OsuClickableContainer.cs @@ -1,24 +1,32 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; +using osuTK; namespace osu.Game.Graphics.Containers { - public class OsuClickableContainer : ClickableContainer, IHasTooltip + public partial class OsuClickableContainer : ClickableContainer, IHasTooltip { private readonly HoverSampleSet sampleSet; private readonly Container content = new Container { RelativeSizeAxes = Axes.Both }; + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => + // base call is checked for cases when `OsuClickableContainer` has masking applied to it directly (ie. externally in object initialisation). + base.ReceivePositionalInputAt(screenSpacePos) + // Implementations often apply masking / edge rounding at a content level, so it's imperative to check that as well. + && Content.ReceivePositionalInputAt(screenSpacePos); + protected override Container Content => content; - protected virtual HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverClickSounds(sampleSet); + protected virtual HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverClickSounds(sampleSet) { Enabled = { BindTarget = Enabled } }; public OsuClickableContainer(HoverSampleSet sampleSet = HoverSampleSet.Default) { @@ -36,11 +44,14 @@ namespace osu.Game.Graphics.Containers content.AutoSizeAxes = AutoSizeAxes; } - InternalChildren = new Drawable[] + AddRangeInternal(new Drawable[] { + CreateHoverSounds(sampleSet), content, - CreateHoverSounds(sampleSet) - }; + }); } + + protected override void ClearInternal(bool disposeChildren = true) => + throw new InvalidOperationException($"Clearing {nameof(InternalChildren)} will cause critical failure. Use {nameof(Clear)} instead."); } } diff --git a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs index 512602d120..07b5b53e0e 100644 --- a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs +++ b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -16,15 +18,13 @@ using osu.Game.Overlays; namespace osu.Game.Graphics.Containers { [Cached(typeof(IPreviewTrackOwner))] - public abstract class OsuFocusedOverlayContainer : FocusedOverlayContainer, IPreviewTrackOwner, IKeyBindingHandler + public abstract partial class OsuFocusedOverlayContainer : FocusedOverlayContainer, IPreviewTrackOwner, IKeyBindingHandler { private Sample samplePopIn; private Sample samplePopOut; protected virtual string PopInSampleName => "UI/overlay-pop-in"; protected virtual string PopOutSampleName => "UI/overlay-pop-out"; - protected override bool BlockScrollInput => false; - protected override bool BlockNonPositionalInput => true; /// @@ -88,6 +88,15 @@ namespace osu.Game.Graphics.Containers base.OnMouseUp(e); } + protected override bool OnScroll(ScrollEvent e) + { + // allow for controlling volume when alt is held. + // mostly for compatibility with osu-stable. + if (e.AltPressed) return false; + + return true; + } + public virtual bool OnPressed(KeyBindingPressEvent e) { if (e.Repeat) diff --git a/osu.Game/Graphics/Containers/OsuHoverContainer.cs b/osu.Game/Graphics/Containers/OsuHoverContainer.cs index ac66fd658a..b4b80f7574 100644 --- a/osu.Game/Graphics/Containers/OsuHoverContainer.cs +++ b/osu.Game/Graphics/Containers/OsuHoverContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osu.Game.Graphics.UserInterface; namespace osu.Game.Graphics.Containers { - public class OsuHoverContainer : OsuClickableContainer + public partial class OsuHoverContainer : OsuClickableContainer { protected const float FADE_DURATION = 500; diff --git a/osu.Game/Graphics/Containers/OsuRearrangeableListContainer.cs b/osu.Game/Graphics/Containers/OsuRearrangeableListContainer.cs index 1048fd094c..fcc48d80ea 100644 --- a/osu.Game/Graphics/Containers/OsuRearrangeableListContainer.cs +++ b/osu.Game/Graphics/Containers/OsuRearrangeableListContainer.cs @@ -1,13 +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.Collections.Specialized; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; namespace osu.Game.Graphics.Containers { - public abstract class OsuRearrangeableListContainer : RearrangeableListContainer + public abstract partial class OsuRearrangeableListContainer : RearrangeableListContainer { /// /// Whether any item is currently being dragged. Used to hide other items' drag handles. @@ -16,11 +23,49 @@ namespace osu.Game.Graphics.Containers protected override ScrollContainer CreateScrollContainer() => new OsuScrollContainer(); + private Sample sampleSwap; + private double sampleLastPlaybackTime; + protected sealed override RearrangeableListItem CreateDrawable(TModel item) => CreateOsuDrawable(item).With(d => { d.DragActive.BindTo(DragActive); }); protected abstract OsuRearrangeableListItem CreateOsuDrawable(TModel item); + + protected override void LoadComplete() + { + base.LoadComplete(); + + Items.CollectionChanged += (_, args) => + { + if (args.Action == NotifyCollectionChangedAction.Move) + playSwapSample(); + }; + } + + private void playSwapSample() + { + if (!DragActive.Value) + return; + + if (Time.Current - sampleLastPlaybackTime <= 35) + return; + + var channel = sampleSwap?.GetChannel(); + if (channel == null) + return; + + channel.Frequency.Value = 0.96 + RNG.NextDouble(0.08); + channel.Play(); + sampleLastPlaybackTime = Time.Current; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + sampleSwap = audio.Samples.Get(@"UI/item-swap"); + sampleLastPlaybackTime = Time.Current; + } } } diff --git a/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs b/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs index d43c3a608b..39a3edb82c 100644 --- a/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs +++ b/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -13,7 +15,7 @@ using osuTK.Graphics; namespace osu.Game.Graphics.Containers { - public abstract class OsuRearrangeableListItem : RearrangeableListItem + public abstract partial class OsuRearrangeableListItem : RearrangeableListItem { public const float FADE_DURATION = 100; @@ -127,7 +129,7 @@ namespace osu.Game.Graphics.Containers protected abstract Drawable CreateContent(); - public class PlaylistItemHandle : SpriteIcon + public partial class PlaylistItemHandle : SpriteIcon { public bool HandlingDrag { get; private set; } private bool isHovering; diff --git a/osu.Game/Graphics/Containers/OsuScrollContainer.cs b/osu.Game/Graphics/Containers/OsuScrollContainer.cs index 817b8409e6..e39fd45a16 100644 --- a/osu.Game/Graphics/Containers/OsuScrollContainer.cs +++ b/osu.Game/Graphics/Containers/OsuScrollContainer.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 enable - using System; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -16,7 +14,7 @@ using osuTK.Input; namespace osu.Game.Graphics.Containers { - public class OsuScrollContainer : OsuScrollContainer + public partial class OsuScrollContainer : OsuScrollContainer { public OsuScrollContainer() { @@ -28,7 +26,7 @@ namespace osu.Game.Graphics.Containers } } - public class OsuScrollContainer : ScrollContainer where T : Drawable + public partial class OsuScrollContainer : ScrollContainer where T : Drawable { public const float SCROLL_BAR_HEIGHT = 10; public const float SCROLL_BAR_PADDING = 3; @@ -133,7 +131,7 @@ namespace osu.Game.Graphics.Containers protected override ScrollbarContainer CreateScrollbar(Direction direction) => new OsuScrollbar(direction); - protected class OsuScrollbar : ScrollbarContainer + protected partial class OsuScrollbar : ScrollbarContainer { private Color4 hoverColour; private Color4 defaultColour; diff --git a/osu.Game/Graphics/Containers/OsuTextFlowContainer.cs b/osu.Game/Graphics/Containers/OsuTextFlowContainer.cs index b8237832a3..d3bbc2e80b 100644 --- a/osu.Game/Graphics/Containers/OsuTextFlowContainer.cs +++ b/osu.Game/Graphics/Containers/OsuTextFlowContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osu.Game.Graphics.Sprites; namespace osu.Game.Graphics.Containers { - public class OsuTextFlowContainer : TextFlowContainer + public partial class OsuTextFlowContainer : TextFlowContainer { public OsuTextFlowContainer(Action defaultCreationParameters = null) : base(defaultCreationParameters) diff --git a/osu.Game/Graphics/Containers/ParallaxContainer.cs b/osu.Game/Graphics/Containers/ParallaxContainer.cs index b501e68ba1..3893413f61 100644 --- a/osu.Game/Graphics/Containers/ParallaxContainer.cs +++ b/osu.Game/Graphics/Containers/ParallaxContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics; @@ -13,7 +15,7 @@ using osu.Framework.Utils; namespace osu.Game.Graphics.Containers { - public class ParallaxContainer : Container, IRequireHighFrequencyMousePosition + public partial class ParallaxContainer : Container, IRequireHighFrequencyMousePosition { public const float DEFAULT_PARALLAX_AMOUNT = 0.02f; diff --git a/osu.Game/Graphics/Containers/ReverseChildIDFillFlowContainer.cs b/osu.Game/Graphics/Containers/ReverseChildIDFillFlowContainer.cs index 6360e41bef..e37d23fe97 100644 --- a/osu.Game/Graphics/Containers/ReverseChildIDFillFlowContainer.cs +++ b/osu.Game/Graphics/Containers/ReverseChildIDFillFlowContainer.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; namespace osu.Game.Graphics.Containers { - public class ReverseChildIDFillFlowContainer : FillFlowContainer where T : Drawable + public partial class ReverseChildIDFillFlowContainer : FillFlowContainer where T : Drawable { protected override int Compare(Drawable x, Drawable y) => CompareReverseChildID(x, y); } diff --git a/osu.Game/Graphics/Containers/ScalingContainer.cs b/osu.Game/Graphics/Containers/ScalingContainer.cs index 5c6e315225..fb5c3e3b60 100644 --- a/osu.Game/Graphics/Containers/ScalingContainer.cs +++ b/osu.Game/Graphics/Containers/ScalingContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -19,7 +21,7 @@ namespace osu.Game.Graphics.Containers /// /// Handles user-defined scaling, allowing application at multiple levels defined by . /// - public class ScalingContainer : Container + public partial class ScalingContainer : Container { internal const float TRANSITION_DURATION = 500; @@ -27,6 +29,7 @@ namespace osu.Game.Graphics.Containers private Bindable sizeY; private Bindable posX; private Bindable posY; + private Bindable applySafeAreaPadding; private Bindable safeAreaPadding; @@ -79,7 +82,7 @@ namespace osu.Game.Graphics.Containers }; } - public class ScalingDrawSizePreservingFillContainer : DrawSizePreservingFillContainer + public partial class ScalingDrawSizePreservingFillContainer : DrawSizePreservingFillContainer { private readonly bool applyUIScale; private Bindable uiScale; @@ -130,6 +133,9 @@ namespace osu.Game.Graphics.Containers posY = config.GetBindable(OsuSetting.ScalingPositionY); posY.ValueChanged += _ => Scheduler.AddOnce(updateSize); + applySafeAreaPadding = config.GetBindable(OsuSetting.SafeAreaConsiderations); + applySafeAreaPadding.BindValueChanged(_ => Scheduler.AddOnce(updateSize)); + safeAreaPadding = safeArea.SafeAreaPadding.GetBoundCopy(); safeAreaPadding.BindValueChanged(_ => Scheduler.AddOnce(updateSize)); } @@ -190,7 +196,7 @@ namespace osu.Game.Graphics.Containers bool requiresMasking = targetRect.Size != Vector2.One // For the top level scaling container, for now we apply masking if safe areas are in use. // In the future this can likely be removed as more of the actual UI supports overflowing into the safe areas. - || (targetMode == ScalingMode.Everything && safeAreaPadding.Value.Total != Vector2.Zero); + || (targetMode == ScalingMode.Everything && (applySafeAreaPadding.Value && safeAreaPadding.Value.Total != Vector2.Zero)); if (requiresMasking) sizableContainer.Masking = true; @@ -205,7 +211,7 @@ namespace osu.Game.Graphics.Containers .OnComplete(_ => { sizableContainer.Masking = requiresMasking; }); } - private class ScalingBackgroundScreen : BackgroundScreenDefault + private partial class ScalingBackgroundScreen : BackgroundScreenDefault { protected override bool AllowStoryboardBackground => false; @@ -215,7 +221,7 @@ namespace osu.Game.Graphics.Containers } } - private class SizeableAlwaysInputContainer : Container + private partial class SizeableAlwaysInputContainer : Container { [Resolved] private GameHost host { get; set; } @@ -223,6 +229,9 @@ namespace osu.Game.Graphics.Containers [Resolved] private ISafeArea safeArea { get; set; } + [Resolved] + private OsuConfigManager config { get; set; } + private readonly bool confineHostCursor; private readonly LayoutValue cursorRectCache = new LayoutValue(Invalidation.RequiredParentSizeToFit); @@ -257,8 +266,8 @@ namespace osu.Game.Graphics.Containers { if (host.Window == null) return; - bool coversWholeScreen = Size == Vector2.One && safeArea.SafeAreaPadding.Value.Total == Vector2.Zero; - host.Window.CursorConfineRect = coversWholeScreen ? (RectangleF?)null : ToScreenSpace(DrawRectangle).AABBFloat; + bool coversWholeScreen = Size == Vector2.One && (!config.Get(OsuSetting.SafeAreaConsiderations) || safeArea.SafeAreaPadding.Value.Total == Vector2.Zero); + host.Window.CursorConfineRect = coversWholeScreen ? null : ToScreenSpace(DrawRectangle).AABBFloat; } } } diff --git a/osu.Game/Graphics/Containers/SectionsContainer.cs b/osu.Game/Graphics/Containers/SectionsContainer.cs index 6ad538959e..8dd6eac7bb 100644 --- a/osu.Game/Graphics/Containers/SectionsContainer.cs +++ b/osu.Game/Graphics/Containers/SectionsContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using System.Linq; @@ -18,7 +20,7 @@ namespace osu.Game.Graphics.Containers /// A container that can scroll to each section inside it. /// [Cached] - public class SectionsContainer : Container + public partial class SectionsContainer : Container where T : Drawable { public Bindable SelectedSection { get; } = new Bindable(); @@ -33,7 +35,7 @@ namespace osu.Game.Graphics.Containers if (value == expandableHeader) return; if (expandableHeader != null) - RemoveInternal(expandableHeader); + RemoveInternal(expandableHeader, false); expandableHeader = value; @@ -53,6 +55,7 @@ namespace osu.Game.Graphics.Containers fixedHeader?.Expire(); fixedHeader = value; + if (value == null) return; AddInternal(fixedHeader); @@ -68,8 +71,10 @@ namespace osu.Game.Graphics.Containers if (value == footer) return; if (footer != null) - scrollContainer.Remove(footer); + scrollContainer.Remove(footer, false); + footer = value; + if (value == null) return; footer.Anchor |= Anchor.y2; @@ -235,7 +240,9 @@ namespace osu.Game.Graphics.Containers headerBackgroundContainer.Height = expandableHeaderSize + fixedHeaderSize; headerBackgroundContainer.Y = ExpandableHeader?.Y ?? 0; - float smallestSectionHeight = Children.Count > 0 ? Children.Min(d => d.Height) : 0; + var flowChildren = scrollContentContainer.FlowingChildren.OfType(); + + float smallestSectionHeight = flowChildren.Any() ? flowChildren.Min(d => d.Height) : 0; // scroll offset is our fixed header height if we have it plus 10% of content height // plus 5% to fix floating point errors and to not have a section instantly unselect when scrolling upwards @@ -244,7 +251,7 @@ namespace osu.Game.Graphics.Containers float scrollCentre = fixedHeaderSize + scrollContainer.DisplayableContent * scroll_y_centre + selectionLenienceAboveSection; - var presentChildren = Children.Where(c => c.IsPresent); + var presentChildren = flowChildren.Where(c => c.IsPresent); if (lastClickedSection != null) SelectedSection.Value = lastClickedSection; diff --git a/osu.Game/Graphics/Containers/SelectionCycleFillFlowContainer.cs b/osu.Game/Graphics/Containers/SelectionCycleFillFlowContainer.cs index 4ddaa09be6..62544c6111 100644 --- a/osu.Game/Graphics/Containers/SelectionCycleFillFlowContainer.cs +++ b/osu.Game/Graphics/Containers/SelectionCycleFillFlowContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using osu.Framework; @@ -14,7 +16,7 @@ namespace osu.Game.Graphics.Containers /// A FillFlowContainer that provides functionality to cycle selection between children /// The selection wraps around when overflowing past the first or last child. /// - public class SelectionCycleFillFlowContainer : FillFlowContainer where T : Drawable, IStateful + public partial class SelectionCycleFillFlowContainer : FillFlowContainer where T : Drawable, IStateful { public T Selected => (selectedIndex >= 0 && selectedIndex < Count) ? this[selectedIndex.Value] : null; @@ -57,7 +59,7 @@ namespace osu.Game.Graphics.Containers drawable.StateChanged += state => selectionChanged(drawable, state); } - public override bool Remove(T drawable) + public override bool Remove(T drawable, bool disposeImmediately) => throw new NotSupportedException($"Cannot remove drawables from {nameof(SelectionCycleFillFlowContainer)}"); private void setSelected(int? value) diff --git a/osu.Game/Graphics/Containers/ShakeContainer.cs b/osu.Game/Graphics/Containers/ShakeContainer.cs index 8a0ce287db..9a1ddac40d 100644 --- a/osu.Game/Graphics/Containers/ShakeContainer.cs +++ b/osu.Game/Graphics/Containers/ShakeContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Containers; using osu.Game.Extensions; @@ -9,7 +11,7 @@ namespace osu.Game.Graphics.Containers /// /// A container that adds the ability to shake its contents. /// - public class ShakeContainer : Container + public partial class ShakeContainer : Container { /// /// The length of a single shake. diff --git a/osu.Game/Graphics/Containers/UprightAspectMaintainingContainer.cs b/osu.Game/Graphics/Containers/UprightAspectMaintainingContainer.cs new file mode 100644 index 0000000000..38ab6deb97 --- /dev/null +++ b/osu.Game/Graphics/Containers/UprightAspectMaintainingContainer.cs @@ -0,0 +1,119 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Layout; +using osuTK; + +namespace osu.Game.Graphics.Containers +{ + /// + /// A container that reverts any rotation (and optionally scale) applied by its direct parent. + /// + public partial class UprightAspectMaintainingContainer : Container + { + /// + /// Controls how much this container scales compared to its parent (default is 1.0f). + /// + public float ScalingFactor { get; set; } = 1; + + /// + /// Controls the scaling of this container. + /// + public ScaleMode Scaling { get; set; } = ScaleMode.Vertical; + + private readonly LayoutValue layout = new LayoutValue(Invalidation.DrawInfo, InvalidationSource.Parent); + + public UprightAspectMaintainingContainer() + { + AddLayout(layout); + } + + protected override void Update() + { + base.Update(); + + if (!layout.IsValid) + { + keepUprightAndUnstretched(); + layout.Validate(); + } + } + + /// + /// Keeps the drawable upright and unstretched preventing it from being rotated, sheared, scaled or flipped with its Parent. + /// + private void keepUprightAndUnstretched() + { + // Decomposes the inverse of the parent DrawInfo.Matrix into rotation, shear and scale. + var parentMatrix = Parent.DrawInfo.Matrix; + + // Remove Translation.> + parentMatrix.M31 = 0.0f; + parentMatrix.M32 = 0.0f; + + Matrix3 reversedParent = parentMatrix.Inverted(); + + // Extract the rotation. + float angle = MathF.Atan2(reversedParent.M12, reversedParent.M11); + Rotation = MathHelper.RadiansToDegrees(angle); + + // Remove rotation from the C matrix so that it only contains shear and scale. + Matrix3 m = Matrix3.CreateRotationZ(-angle); + reversedParent *= m; + + // Extract shear. + float alpha = reversedParent.M21 / reversedParent.M22; + Shear = new Vector2(-alpha, 0); + + // Etract scale. + float sx = reversedParent.M11; + float sy = reversedParent.M22; + + Vector3 parentScale = parentMatrix.ExtractScale(); + + float usedScale = 1.0f; + + switch (Scaling) + { + case ScaleMode.Horizontal: + usedScale = parentScale.X; + break; + + case ScaleMode.Vertical: + usedScale = parentScale.Y; + break; + } + + if (Scaling != ScaleMode.NoScaling) + { + if (ScalingFactor < 1.0f) + usedScale = 1.0f + (usedScale - 1.0f) * ScalingFactor; + if (ScalingFactor > 1.0f) + usedScale = (usedScale < 1.0f) ? usedScale * (1.0f / ScalingFactor) : usedScale * ScalingFactor; + } + + Scale = new Vector2(sx * usedScale, sy * usedScale); + } + } + + public enum ScaleMode + { + /// + /// Prevent this container from scaling. + /// + NoScaling, + + /// + /// Scale uniformly (maintaining aspect ratio) based on the vertical scale of the parent. + /// + Vertical, + + /// + /// Scale uniformly (maintaining aspect ratio) based on the horizontal scale of the parent. + /// + Horizontal, + } +} diff --git a/osu.Game/Graphics/Containers/UserDimContainer.cs b/osu.Game/Graphics/Containers/UserDimContainer.cs index 4e555ac1eb..6f6292c3b2 100644 --- a/osu.Game/Graphics/Containers/UserDimContainer.cs +++ b/osu.Game/Graphics/Containers/UserDimContainer.cs @@ -14,14 +14,14 @@ namespace osu.Game.Graphics.Containers /// /// A container that applies user-configured visual settings to its contents. /// - public abstract class UserDimContainer : Container + public abstract partial class UserDimContainer : Container { /// /// Amount of lightening to apply to current dim level during break times. /// public const float BREAK_LIGHTEN_AMOUNT = 0.3f; - protected const double BACKGROUND_FADE_DURATION = 800; + public const double BACKGROUND_FADE_DURATION = 800; /// /// Whether or not user-configured settings relating to brightness of elements should be ignored @@ -44,15 +44,20 @@ namespace osu.Game.Graphics.Containers /// public bool ContentDisplayed { get; private set; } - protected Bindable UserDimLevel { get; private set; } + protected Bindable UserDimLevel { get; private set; } = null!; - protected Bindable LightenDuringBreaks { get; private set; } + /// + /// The amount of dim to be used when is true. + /// + public Bindable DimWhenUserSettingsIgnored { get; set; } = new Bindable(); - protected Bindable ShowStoryboard { get; private set; } + protected Bindable LightenDuringBreaks { get; private set; } = null!; + + protected Bindable ShowStoryboard { get; private set; } = null!; private float breakLightening => LightenDuringBreaks.Value && IsBreakTime.Value ? BREAK_LIGHTEN_AMOUNT : 0; - protected float DimLevel => Math.Max(!IgnoreUserSettings.Value ? (float)UserDimLevel.Value - breakLightening : 0, 0); + protected float DimLevel => Math.Max(!IgnoreUserSettings.Value ? (float)UserDimLevel.Value - breakLightening : DimWhenUserSettingsIgnored.Value, 0); protected override Container Content => dimContent; @@ -74,6 +79,7 @@ namespace osu.Game.Graphics.Containers ShowStoryboard = config.GetBindable(OsuSetting.ShowStoryboard); UserDimLevel.ValueChanged += _ => UpdateVisuals(); + DimWhenUserSettingsIgnored.ValueChanged += _ => UpdateVisuals(); LightenDuringBreaks.ValueChanged += _ => UpdateVisuals(); IsBreakTime.ValueChanged += _ => UpdateVisuals(); ShowStoryboard.ValueChanged += _ => UpdateVisuals(); diff --git a/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs b/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs index 44afaf77ea..715677aec1 100644 --- a/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs +++ b/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; namespace osu.Game.Graphics.Containers { - public class UserTrackingScrollContainer : UserTrackingScrollContainer + public partial class UserTrackingScrollContainer : UserTrackingScrollContainer { public UserTrackingScrollContainer() { @@ -17,7 +19,7 @@ namespace osu.Game.Graphics.Containers } } - public class UserTrackingScrollContainer : OsuScrollContainer + public partial class UserTrackingScrollContainer : OsuScrollContainer where T : Drawable { /// diff --git a/osu.Game/Graphics/Containers/WaveContainer.cs b/osu.Game/Graphics/Containers/WaveContainer.cs index 8b87ddaa20..952ef3f182 100644 --- a/osu.Game/Graphics/Containers/WaveContainer.cs +++ b/osu.Game/Graphics/Containers/WaveContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osuTK.Graphics; namespace osu.Game.Graphics.Containers { - public class WaveContainer : VisibilityContainer + public partial class WaveContainer : VisibilityContainer { public const float APPEAR_DURATION = 800; public const float DISAPPEAR_DURATION = 500; @@ -129,7 +131,7 @@ namespace osu.Game.Graphics.Containers wavesContainer.Height = Math.Max(0, DrawHeight - (contentContainer.DrawHeight - contentContainer.Y * DrawHeight)); } - private class Wave : VisibilityContainer + private partial class Wave : VisibilityContainer { public float FinalPosition; diff --git a/osu.Game/Graphics/Cursor/GlobalCursorDisplay.cs b/osu.Game/Graphics/Cursor/GlobalCursorDisplay.cs new file mode 100644 index 0000000000..85a2d68e55 --- /dev/null +++ b/osu.Game/Graphics/Cursor/GlobalCursorDisplay.cs @@ -0,0 +1,94 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Input; +using osu.Framework.Input.StateChanges; +using osu.Game.Configuration; + +namespace osu.Game.Graphics.Cursor +{ + /// + /// A container which provides the main . + /// Also handles cases where a more localised cursor is provided by another component (via ). + /// + public partial class GlobalCursorDisplay : Container, IProvideCursor + { + /// + /// Control whether any cursor should be displayed. + /// + internal bool ShowCursor = true; + + CursorContainer IProvideCursor.Cursor => MenuCursor; + + public MenuCursorContainer MenuCursor { get; } + + public bool ProvidingUserCursor => true; + + protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; + + private Bindable showDuringTouch = null!; + + private InputManager inputManager = null!; + + private IProvideCursor? currentOverrideProvider; + + [Resolved] + private OsuConfigManager config { get; set; } = null!; + + public GlobalCursorDisplay() + { + AddRangeInternal(new Drawable[] + { + Content = new Container { RelativeSizeAxes = Axes.Both }, + MenuCursor = new MenuCursorContainer { State = { Value = Visibility.Hidden } } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + inputManager = GetContainingInputManager(); + showDuringTouch = config.GetBindable(OsuSetting.GameplayCursorDuringTouch); + } + + protected override void Update() + { + base.Update(); + + var lastMouseSource = inputManager.CurrentState.Mouse.LastSource; + bool hasValidInput = lastMouseSource != null && (showDuringTouch.Value || lastMouseSource is not ISourcedFromTouch); + + if (!hasValidInput || !ShowCursor) + { + currentOverrideProvider?.Cursor?.Hide(); + currentOverrideProvider = null; + return; + } + + IProvideCursor newOverrideProvider = this; + + foreach (var d in inputManager.HoveredDrawables) + { + if (d is IProvideCursor p && p.ProvidingUserCursor) + { + newOverrideProvider = p; + break; + } + } + + if (currentOverrideProvider == newOverrideProvider) + return; + + currentOverrideProvider?.Cursor?.Hide(); + newOverrideProvider.Cursor?.Show(); + + currentOverrideProvider = newOverrideProvider; + } + } +} diff --git a/osu.Game/Graphics/Cursor/IProvideCursor.cs b/osu.Game/Graphics/Cursor/IProvideCursor.cs index 3a920ba976..9f01e5da6d 100644 --- a/osu.Game/Graphics/Cursor/IProvideCursor.cs +++ b/osu.Game/Graphics/Cursor/IProvideCursor.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; diff --git a/osu.Game/Graphics/Cursor/MenuCursor.cs b/osu.Game/Graphics/Cursor/MenuCursor.cs deleted file mode 100644 index 03fad00e41..0000000000 --- a/osu.Game/Graphics/Cursor/MenuCursor.cs +++ /dev/null @@ -1,198 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osuTK; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Sprites; -using osu.Game.Configuration; -using System; -using JetBrains.Annotations; -using osu.Framework.Audio; -using osu.Framework.Audio.Sample; -using osu.Framework.Bindables; -using osu.Framework.Graphics.Textures; -using osu.Framework.Input.Events; -using osu.Framework.Utils; - -namespace osu.Game.Graphics.Cursor -{ - public class MenuCursor : CursorContainer - { - private readonly IBindable screenshotCursorVisibility = new Bindable(true); - public override bool IsPresent => screenshotCursorVisibility.Value && base.IsPresent; - - protected override Drawable CreateCursor() => activeCursor = new Cursor(); - - private Cursor activeCursor; - - private Bindable cursorRotate; - private DragRotationState dragRotationState; - private Vector2 positionMouseDown; - - private Sample tapSample; - - [BackgroundDependencyLoader(true)] - private void load([NotNull] OsuConfigManager config, [CanBeNull] ScreenshotManager screenshotManager, AudioManager audio) - { - cursorRotate = config.GetBindable(OsuSetting.CursorRotation); - - if (screenshotManager != null) - screenshotCursorVisibility.BindTo(screenshotManager.CursorVisibility); - - tapSample = audio.Samples.Get(@"UI/cursor-tap"); - } - - protected override bool OnMouseMove(MouseMoveEvent e) - { - if (dragRotationState != DragRotationState.NotDragging) - { - var position = e.MousePosition; - float distance = Vector2Extensions.Distance(position, positionMouseDown); - - // don't start rotating until we're moved a minimum distance away from the mouse down location, - // else it can have an annoying effect. - if (dragRotationState == DragRotationState.DragStarted && distance > 30) - dragRotationState = DragRotationState.Rotating; - - // don't rotate when distance is zero to avoid NaN - if (dragRotationState == DragRotationState.Rotating && distance > 0) - { - Vector2 offset = e.MousePosition - positionMouseDown; - float degrees = MathUtils.RadiansToDegrees(MathF.Atan2(-offset.X, offset.Y)) + 24.3f; - - // Always rotate in the direction of least distance - float diff = (degrees - activeCursor.Rotation) % 360; - if (diff < -180) diff += 360; - if (diff > 180) diff -= 360; - degrees = activeCursor.Rotation + diff; - - activeCursor.RotateTo(degrees, 600, Easing.OutQuint); - } - } - - return base.OnMouseMove(e); - } - - protected override bool OnMouseDown(MouseDownEvent e) - { - if (State.Value == Visibility.Visible) - { - // only trigger animation for main mouse buttons - activeCursor.Scale = new Vector2(1); - activeCursor.ScaleTo(0.90f, 800, Easing.OutQuint); - - activeCursor.AdditiveLayer.Alpha = 0; - activeCursor.AdditiveLayer.FadeInFromZero(800, Easing.OutQuint); - - if (cursorRotate.Value && dragRotationState != DragRotationState.Rotating) - { - // if cursor is already rotating don't reset its rotate origin - dragRotationState = DragRotationState.DragStarted; - positionMouseDown = e.MousePosition; - } - - playTapSample(); - } - - return base.OnMouseDown(e); - } - - protected override void OnMouseUp(MouseUpEvent e) - { - if (!e.HasAnyButtonPressed) - { - activeCursor.AdditiveLayer.FadeOutFromOne(500, Easing.OutQuint); - activeCursor.ScaleTo(1, 500, Easing.OutElastic); - - if (dragRotationState != DragRotationState.NotDragging) - { - activeCursor.RotateTo(0, 600 * (1 + Math.Abs(activeCursor.Rotation / 720)), Easing.OutElasticHalf); - dragRotationState = DragRotationState.NotDragging; - } - - if (State.Value == Visibility.Visible) - playTapSample(0.8); - } - - base.OnMouseUp(e); - } - - protected override void PopIn() - { - activeCursor.FadeTo(1, 250, Easing.OutQuint); - activeCursor.ScaleTo(1, 400, Easing.OutQuint); - } - - protected override void PopOut() - { - activeCursor.FadeTo(0, 250, Easing.OutQuint); - activeCursor.ScaleTo(0.6f, 250, Easing.In); - } - - private void playTapSample(double baseFrequency = 1f) - { - const float random_range = 0.02f; - SampleChannel channel = tapSample.GetChannel(); - - // Scale to [-0.75, 0.75] so that the sample isn't fully panned left or right (sounds weird) - channel.Balance.Value = ((activeCursor.X / DrawWidth) * 2 - 1) * 0.75; - channel.Frequency.Value = baseFrequency - (random_range / 2f) + RNG.NextDouble(random_range); - channel.Volume.Value = baseFrequency; - - channel.Play(); - } - - public class Cursor : Container - { - private Container cursorContainer; - private Bindable cursorScale; - private const float base_scale = 0.15f; - - public Sprite AdditiveLayer; - - public Cursor() - { - AutoSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load(OsuConfigManager config, TextureStore textures, OsuColour colour) - { - Children = new Drawable[] - { - cursorContainer = new Container - { - AutoSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Sprite - { - Texture = textures.Get(@"Cursor/menu-cursor"), - }, - AdditiveLayer = new Sprite - { - Blending = BlendingParameters.Additive, - Colour = colour.Pink, - Alpha = 0, - Texture = textures.Get(@"Cursor/menu-cursor-additive"), - }, - } - } - }; - - cursorScale = config.GetBindable(OsuSetting.MenuCursorSize); - cursorScale.BindValueChanged(scale => cursorContainer.Scale = new Vector2(scale.NewValue * base_scale), true); - } - } - - private enum DragRotationState - { - NotDragging, - DragStarted, - Rotating, - } - } -} diff --git a/osu.Game/Graphics/Cursor/MenuCursorContainer.cs b/osu.Game/Graphics/Cursor/MenuCursorContainer.cs index 4c7f7957e9..8cf47006ab 100644 --- a/osu.Game/Graphics/Cursor/MenuCursorContainer.cs +++ b/osu.Game/Graphics/Cursor/MenuCursorContainer.cs @@ -1,81 +1,328 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; -using osu.Framework.Input; -using osu.Framework.Input.StateChanges; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Input.Events; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osuTK; namespace osu.Game.Graphics.Cursor { - /// - /// A container which provides a which can be overridden by hovered s. - /// - public class MenuCursorContainer : Container, IProvideCursor + public partial class MenuCursorContainer : CursorContainer { - protected override Container Content => content; - private readonly Container content; + private readonly IBindable screenshotCursorVisibility = new Bindable(true); + public override bool IsPresent => screenshotCursorVisibility.Value && base.IsPresent; - /// - /// Whether any cursors can be displayed. - /// - internal bool CanShowCursor = true; + private bool hideCursorOnNonMouseInput; - public CursorContainer Cursor { get; } - public bool ProvidingUserCursor => true; - - public MenuCursorContainer() + public bool HideCursorOnNonMouseInput { - AddRangeInternal(new Drawable[] + get => hideCursorOnNonMouseInput; + set { - Cursor = new MenuCursor { State = { Value = Visibility.Hidden } }, - content = new Container { RelativeSizeAxes = Axes.Both } - }); + if (hideCursorOnNonMouseInput == value) + return; + + hideCursorOnNonMouseInput = value; + updateState(); + } } - private InputManager inputManager; + protected override Drawable CreateCursor() => activeCursor = new Cursor(); + + private Cursor activeCursor = null!; + + private DragRotationState dragRotationState; + private Vector2 positionMouseDown; + private Vector2 lastMovePosition; + + private Bindable cursorRotate = null!; + private Sample tapSample = null!; + + private MouseInputDetector mouseInputDetector = null!; + + private bool visible; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config, ScreenshotManager? screenshotManager, AudioManager audio) + { + cursorRotate = config.GetBindable(OsuSetting.CursorRotation); + + if (screenshotManager != null) + screenshotCursorVisibility.BindTo(screenshotManager.CursorVisibility); + + tapSample = audio.Samples.Get(@"UI/cursor-tap"); + + Add(mouseInputDetector = new MouseInputDetector()); + } + + [Resolved] + private OsuGame? game { get; set; } + + private readonly IBindable lastInputWasMouse = new BindableBool(); + private readonly IBindable gameActive = new BindableBool(true); + private readonly IBindable gameIdle = new BindableBool(); protected override void LoadComplete() { base.LoadComplete(); - inputManager = GetContainingInputManager(); + + lastInputWasMouse.BindTo(mouseInputDetector.LastInputWasMouseSource); + lastInputWasMouse.BindValueChanged(_ => updateState(), true); + + if (game != null) + { + gameIdle.BindTo(game.IsIdle); + gameIdle.BindValueChanged(_ => updateState()); + + gameActive.BindTo(game.IsActive); + gameActive.BindValueChanged(_ => updateState()); + } } - private IProvideCursor currentTarget; + protected override void UpdateState(ValueChangedEvent state) => updateState(); + + private void updateState() + { + bool combinedVisibility = getCursorVisibility(); + + if (visible == combinedVisibility) + return; + + visible = combinedVisibility; + + if (visible) + PopIn(); + else + PopOut(); + } + + private bool getCursorVisibility() + { + // do not display when explicitly set to hidden state. + if (State.Value == Visibility.Hidden) + return false; + + // only hide cursor when game is focused, otherwise it should always be displayed. + if (gameActive.Value) + { + // do not display when last input is not mouse. + if (hideCursorOnNonMouseInput && !lastInputWasMouse.Value) + return false; + + // do not display when game is idle. + if (gameIdle.Value) + return false; + } + + return true; + } protected override void Update() { base.Update(); - var lastMouseSource = inputManager.CurrentState.Mouse.LastSource; - bool hasValidInput = lastMouseSource != null && !(lastMouseSource is ISourcedFromTouch); - - if (!hasValidInput || !CanShowCursor) + if (dragRotationState != DragRotationState.NotDragging + && Vector2.Distance(positionMouseDown, lastMovePosition) > 60) { - currentTarget?.Cursor?.Hide(); - currentTarget = null; - return; + // make the rotation centre point floating. + positionMouseDown = Interpolation.ValueAt(0.04f, positionMouseDown, lastMovePosition, 0, Clock.ElapsedFrameTime); } + } - IProvideCursor newTarget = this; - - foreach (var d in inputManager.HoveredDrawables) + protected override bool OnMouseMove(MouseMoveEvent e) + { + if (dragRotationState != DragRotationState.NotDragging) { - if (d is IProvideCursor p && p.ProvidingUserCursor) + lastMovePosition = e.MousePosition; + + float distance = Vector2Extensions.Distance(lastMovePosition, positionMouseDown); + + // don't start rotating until we're moved a minimum distance away from the mouse down location, + // else it can have an annoying effect. + if (dragRotationState == DragRotationState.DragStarted && distance > 80) + dragRotationState = DragRotationState.Rotating; + + // don't rotate when distance is zero to avoid NaN + if (dragRotationState == DragRotationState.Rotating && distance > 0) { - newTarget = p; - break; + Vector2 offset = e.MousePosition - positionMouseDown; + float degrees = MathUtils.RadiansToDegrees(MathF.Atan2(-offset.X, offset.Y)) + 24.3f; + + // Always rotate in the direction of least distance + float diff = (degrees - activeCursor.Rotation) % 360; + if (diff < -180) diff += 360; + if (diff > 180) diff -= 360; + degrees = activeCursor.Rotation + diff; + + activeCursor.RotateTo(degrees, 120, Easing.OutQuint); } } - if (currentTarget == newTarget) - return; + return base.OnMouseMove(e); + } - currentTarget?.Cursor?.Hide(); - newTarget.Cursor?.Show(); + protected override bool OnMouseDown(MouseDownEvent e) + { + if (State.Value == Visibility.Visible) + { + // only trigger animation for main mouse buttons + activeCursor.Scale = new Vector2(1); + activeCursor.ScaleTo(0.90f, 800, Easing.OutQuint); - currentTarget = newTarget; + activeCursor.AdditiveLayer.Alpha = 0; + activeCursor.AdditiveLayer.FadeInFromZero(800, Easing.OutQuint); + + if (cursorRotate.Value && dragRotationState != DragRotationState.Rotating) + { + // if cursor is already rotating don't reset its rotate origin + dragRotationState = DragRotationState.DragStarted; + positionMouseDown = e.MousePosition; + } + + playTapSample(); + } + + return base.OnMouseDown(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + if (!e.HasAnyButtonPressed) + { + activeCursor.AdditiveLayer.FadeOutFromOne(500, Easing.OutQuint); + activeCursor.ScaleTo(1, 500, Easing.OutElastic); + + if (dragRotationState != DragRotationState.NotDragging) + { + activeCursor.RotateTo(0, 400 * (0.5f + Math.Abs(activeCursor.Rotation / 960)), Easing.OutElasticQuarter); + dragRotationState = DragRotationState.NotDragging; + } + + if (State.Value == Visibility.Visible) + playTapSample(0.8); + } + + base.OnMouseUp(e); + } + + protected override void PopIn() + { + activeCursor.FadeTo(1, 250, Easing.OutQuint); + activeCursor.ScaleTo(1, 400, Easing.OutQuint); + } + + protected override void PopOut() + { + activeCursor.FadeTo(0, 250, Easing.OutQuint); + activeCursor.ScaleTo(0.6f, 250, Easing.In); + } + + private void playTapSample(double baseFrequency = 1f) + { + const float random_range = 0.02f; + SampleChannel channel = tapSample.GetChannel(); + + // Scale to [-0.75, 0.75] so that the sample isn't fully panned left or right (sounds weird) + channel.Balance.Value = ((activeCursor.X / DrawWidth) * 2 - 1) * OsuGameBase.SFX_STEREO_STRENGTH; + channel.Frequency.Value = baseFrequency - (random_range / 2f) + RNG.NextDouble(random_range); + channel.Volume.Value = baseFrequency; + + channel.Play(); + } + + public partial class Cursor : Container + { + private Container cursorContainer = null!; + private Bindable cursorScale = null!; + private const float base_scale = 0.15f; + + public Sprite AdditiveLayer = null!; + + public Cursor() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config, TextureStore textures, OsuColour colour) + { + Children = new Drawable[] + { + cursorContainer = new Container + { + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Sprite + { + Texture = textures.Get(@"Cursor/menu-cursor"), + }, + AdditiveLayer = new Sprite + { + Blending = BlendingParameters.Additive, + Colour = colour.Pink, + Alpha = 0, + Texture = textures.Get(@"Cursor/menu-cursor-additive"), + }, + } + } + }; + + cursorScale = config.GetBindable(OsuSetting.MenuCursorSize); + cursorScale.BindValueChanged(scale => cursorContainer.Scale = new Vector2(scale.NewValue * base_scale), true); + } + } + + private partial class MouseInputDetector : Component + { + /// + /// Whether the last input applied to the game is sourced from mouse. + /// + public IBindable LastInputWasMouseSource => lastInputWasMouseSource; + + private readonly Bindable lastInputWasMouseSource = new Bindable(); + + public MouseInputDetector() + { + RelativeSizeAxes = Axes.Both; + } + + protected override bool Handle(UIEvent e) + { + switch (e) + { + case MouseDownEvent: + case MouseMoveEvent: + lastInputWasMouseSource.Value = true; + return false; + + case KeyDownEvent keyDown when !keyDown.Repeat: + case JoystickPressEvent: + case MidiDownEvent: + lastInputWasMouseSource.Value = false; + return false; + } + + return false; + } + } + + private enum DragRotationState + { + NotDragging, + DragStarted, + Rotating, } } } diff --git a/osu.Game/Graphics/Cursor/OsuContextMenuContainer.cs b/osu.Game/Graphics/Cursor/OsuContextMenuContainer.cs index 171ad4ee65..27700e71d9 100644 --- a/osu.Game/Graphics/Cursor/OsuContextMenuContainer.cs +++ b/osu.Game/Graphics/Cursor/OsuContextMenuContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; @@ -8,7 +10,7 @@ using osu.Game.Graphics.UserInterface; namespace osu.Game.Graphics.Cursor { - public class OsuContextMenuContainer : ContextMenuContainer + public partial class OsuContextMenuContainer : ContextMenuContainer { [Cached] private OsuContextMenuSamples samples = new OsuContextMenuSamples(); diff --git a/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs b/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs index 35d7b4e795..dc75d626b9 100644 --- a/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs +++ b/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK; using osuTK.Graphics; using osu.Framework.Allocation; @@ -14,7 +16,7 @@ using osu.Game.Graphics.Sprites; namespace osu.Game.Graphics.Cursor { - public class OsuTooltipContainer : TooltipContainer + public partial class OsuTooltipContainer : TooltipContainer { protected override ITooltip CreateTooltip() => new OsuTooltip(); @@ -25,7 +27,7 @@ namespace osu.Game.Graphics.Cursor protected override double AppearDelay => (1 - CurrentTooltip.Alpha) * base.AppearDelay; // reduce appear delay if the tooltip is already partly visible. - public class OsuTooltip : Tooltip + public partial class OsuTooltip : Tooltip { private readonly Box background; private readonly OsuSpriteText text; diff --git a/osu.Game/Graphics/DateTooltip.cs b/osu.Game/Graphics/DateTooltip.cs index 2dca8719e9..c62f53f1d4 100644 --- a/osu.Game/Graphics/DateTooltip.cs +++ b/osu.Game/Graphics/DateTooltip.cs @@ -1,18 +1,21 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; using osuTK; namespace osu.Game.Graphics { - public class DateTooltip : VisibilityContainer, ITooltip + public partial class DateTooltip : VisibilityContainer, ITooltip { private readonly OsuSpriteText dateText, timeText; private readonly Box background; @@ -67,8 +70,8 @@ namespace osu.Game.Graphics { DateTimeOffset localDate = date.ToLocalTime(); - dateText.Text = $"{localDate:d MMMM yyyy} "; - timeText.Text = $"{localDate:HH:mm:ss \"UTC\"z}"; + dateText.Text = LocalisableString.Interpolate($"{localDate:d MMMM yyyy} "); + timeText.Text = LocalisableString.Interpolate($"{localDate:HH:mm:ss \"UTC\"z}"); } public void Move(Vector2 pos) => Position = pos; diff --git a/osu.Game/Graphics/DrawableDate.cs b/osu.Game/Graphics/DrawableDate.cs index 567a39b4f4..553b27acb1 100644 --- a/osu.Game/Graphics/DrawableDate.cs +++ b/osu.Game/Graphics/DrawableDate.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osu.Game.Utils; namespace osu.Game.Graphics { - public class DrawableDate : OsuSpriteText, IHasCustomTooltip + public partial class DrawableDate : OsuSpriteText, IHasCustomTooltip { private DateTimeOffset date; diff --git a/osu.Game/Graphics/ErrorTextFlowContainer.cs b/osu.Game/Graphics/ErrorTextFlowContainer.cs index dafc363973..65a90534e5 100644 --- a/osu.Game/Graphics/ErrorTextFlowContainer.cs +++ b/osu.Game/Graphics/ErrorTextFlowContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Containers; @@ -8,7 +10,7 @@ using osuTK.Graphics; namespace osu.Game.Graphics { - public class ErrorTextFlowContainer : OsuTextFlowContainer + public partial class ErrorTextFlowContainer : OsuTextFlowContainer { private readonly List errorTextParts = new List(); diff --git a/osu.Game/Graphics/HSPAColour.cs b/osu.Game/Graphics/HSPAColour.cs new file mode 100644 index 0000000000..18f4c6bca4 --- /dev/null +++ b/osu.Game/Graphics/HSPAColour.cs @@ -0,0 +1,201 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osuTK.Graphics; + +namespace osu.Game.Graphics +{ + public struct HSPAColour + { + private const float p_r = 0.299f; + private const float p_g = 0.587f; + private const float p_b = 0.114f; + + /// + /// The hue. + /// + public float H; + + /// + /// The saturation. + /// + public float S; + + /// + /// The perceived brightness of this colour. + /// + public float P; + + /// + /// The alpha. + /// + public float A; + + public HSPAColour(float h, float s, float p, float a) + { + H = h; + S = s; + P = p; + A = a; + } + + public HSPAColour(Color4 colour) + { + H = 0; + S = 0; + P = MathF.Sqrt(colour.R * colour.R * p_r + colour.G * colour.G * p_g + colour.B + colour.B * p_b); + A = colour.A; + + if (colour.R == colour.G && colour.R == colour.B) + return; + + if (colour.R >= colour.G && colour.R >= colour.B) + { + if (colour.B >= colour.G) + { + H = 6f / 6f - 1f / 6f * (colour.B - colour.G) / (colour.R - colour.G); + S = 1f - colour.G / colour.R; + } + else + { + H = 0f / 6f + 1f / 6f * (colour.G - colour.B) / (colour.R - colour.B); + S = 1f - colour.B / colour.R; + } + } + else if (colour.G >= colour.R && colour.G >= colour.B) + { + if (colour.R >= colour.B) + { + H = 2f / 6f - 1f / 6f * (colour.R - colour.B) / (colour.G - colour.B); + S = 1f - colour.B / colour.G; + } + else + { + H = 2f / 6f + 1f / 6f * (colour.B - colour.R) / (colour.G - colour.R); + S = 1f - colour.R / colour.G; + } + } + else + { + if (colour.G >= colour.R) + { + H = 4f / 6f - 1f / 6f * (colour.G - colour.R) / (colour.B - colour.R); + S = 1f - colour.R / colour.B; + } + else + { + H = 4f / 6f + 1f / 6f * (colour.R - colour.G) / (colour.B - colour.G); + S = 1f - colour.G / colour.B; + } + } + } + + public Color4 ToColor4() + { + float minOverMax = 1f - S; + + Color4 result = new Color4 { A = A }; + float h = H; + + if (minOverMax > 0f) + { + if (h < 1f / 6f) + { + h = 6f * (h - 0f / 6f); + float part = 1f + h * (1f / minOverMax - 1f); + result.B = P / MathF.Sqrt(p_r / minOverMax / minOverMax + p_g * part * part + p_b); + result.R = result.B / minOverMax; + result.G = result.B + h * (result.R - result.B); + } + else if (h < 2f / 6f) + { + h = 6f * (-h + 2f / 6f); + float part = 1f + h * (1f / minOverMax - 1f); + result.B = P / MathF.Sqrt(p_g / minOverMax / minOverMax + p_r * part * part + p_b); + result.G = result.B / minOverMax; + result.R = result.B + h * (result.G - result.B); + } + else if (h < 3f / 6f) + { + h = 6f * (h - 2f / 6f); + float part = 1f + h * (1f / minOverMax - 1f); + result.R = P / MathF.Sqrt(p_g / minOverMax / minOverMax + p_b * part * part + p_r); + result.G = result.R / minOverMax; + result.B = result.R + h * (result.G - result.R); + } + else if (h < 4f / 6f) + { + h = 6f * (-h + 4f / 6f); + float part = 1f + h * (1f / minOverMax - 1f); + result.R = P / MathF.Sqrt(p_b / minOverMax / minOverMax + p_g * part * part + p_r); + result.B = result.R / minOverMax; + result.G = result.R + h * (result.B - result.R); + } + else if (h < 5f / 6f) + { + h = 6f * (h - 4f / 6f); + float part = 1f + h * (1f / minOverMax - 1f); + result.G = P / MathF.Sqrt(p_b / minOverMax / minOverMax + p_r * part * part + p_g); + result.B = result.G / minOverMax; + result.R = result.G + h * (result.B - result.G); + } + else + { + h = 6f * (-h + 6f / 6f); + float part = 1f + h * (1f / minOverMax - 1f); + result.G = P / MathF.Sqrt(p_r / minOverMax / minOverMax + p_b * part * part + p_g); + result.R = result.G / minOverMax; + result.B = result.G + h * (result.R - result.G); + } + } + else + { + if (h < 1f / 6f) + { + h = 6f * (h - 0f / 6f); + result.R = MathF.Sqrt(P * P / (p_r + p_g * h * h)); + result.G = result.R * h; + result.B = 0f; + } + else if (h < 2f / 6f) + { + h = 6f * (-h + 2f / 6f); + result.G = MathF.Sqrt(P * P / (p_g + p_r * h * h)); + result.R = result.G * h; + result.B = 0f; + } + else if (h < 3f / 6f) + { + h = 6f * (h - 2f / 6f); + result.G = MathF.Sqrt(P * P / (p_g + p_b * h * h)); + result.B = result.G * h; + result.R = 0f; + } + else if (h < 4f / 6f) + { + h = 6f * (-h + 4f / 6f); + result.B = MathF.Sqrt(P * P / (p_b + p_g * h * h)); + result.G = result.B * h; + result.R = 0f; + } + else if (h < 5f / 6f) + { + h = 6f * (h - 4f / 6f); + result.B = MathF.Sqrt(P * P / (p_b + p_r * h * h)); + result.R = result.B * h; + result.G = 0f; + } + else + { + h = 6f * (-h + 6f / 6f); + result.R = MathF.Sqrt(P * P / (p_r + p_b * h * h)); + result.B = result.R * h; + result.G = 0f; + } + } + + return result; + } + } +} diff --git a/osu.Game/Graphics/IHasAccentColour.cs b/osu.Game/Graphics/IHasAccentColour.cs index af497da70f..fc722375ce 100644 --- a/osu.Game/Graphics/IHasAccentColour.cs +++ b/osu.Game/Graphics/IHasAccentColour.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK.Graphics; using osu.Framework.Graphics; using osu.Framework.Graphics.Transforms; diff --git a/osu.Game/Graphics/InputBlockingContainer.cs b/osu.Game/Graphics/InputBlockingContainer.cs index d8387b1401..f652dc8850 100644 --- a/osu.Game/Graphics/InputBlockingContainer.cs +++ b/osu.Game/Graphics/InputBlockingContainer.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. -#nullable enable using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; @@ -10,7 +9,7 @@ namespace osu.Game.Graphics /// /// A simple container which blocks input events from travelling through it. /// - public class InputBlockingContainer : Container + public partial class InputBlockingContainer : Container { protected override bool OnHover(HoverEvent e) => true; diff --git a/osu.Game/Graphics/OpenGL/Vertices/PositionAndColourVertex.cs b/osu.Game/Graphics/OpenGL/Vertices/PositionAndColourVertex.cs index 8714138322..78c8cbb79e 100644 --- a/osu.Game/Graphics/OpenGL/Vertices/PositionAndColourVertex.cs +++ b/osu.Game/Graphics/OpenGL/Vertices/PositionAndColourVertex.cs @@ -1,9 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Runtime.InteropServices; -using osu.Framework.Graphics.OpenGL.Vertices; +using osu.Framework.Graphics.Rendering.Vertices; using osuTK; using osuTK.Graphics; using osuTK.Graphics.ES30; diff --git a/osu.Game/Graphics/OsuColour.cs b/osu.Game/Graphics/OsuColour.cs index 7fd94b57f3..e06f6b3fd0 100644 --- a/osu.Game/Graphics/OsuColour.cs +++ b/osu.Game/Graphics/OsuColour.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics.Colour; using osu.Game.Beatmaps; using osu.Game.Online.Rooms; using osu.Game.Overlays; @@ -20,38 +23,8 @@ namespace osu.Game.Graphics public static Color4 Gray(byte amt) => new Color4(amt, amt, amt, 255); /// - /// Retrieves the colour for a . + /// Retrieves the colour for a given point in the star range. /// - /// - /// Sourced from the @diff-{rating} variables in https://github.com/ppy/osu-web/blob/71fbab8936d79a7929d13854f5e854b4f383b236/resources/assets/less/variables.less. - /// - public Color4 ForDifficultyRating(DifficultyRating difficulty, bool useLighterColour = false) - { - switch (difficulty) - { - case DifficultyRating.Easy: - return Color4Extensions.FromHex("4ebfff"); - - case DifficultyRating.Normal: - return Color4Extensions.FromHex("66ff91"); - - case DifficultyRating.Hard: - return Color4Extensions.FromHex("f7e85d"); - - case DifficultyRating.Insane: - return Color4Extensions.FromHex("ff7e68"); - - case DifficultyRating.Expert: - return Color4Extensions.FromHex("fe3c71"); - - case DifficultyRating.ExpertPlus: - return Color4Extensions.FromHex("6662dd"); - - default: - throw new ArgumentOutOfRangeException(nameof(difficulty)); - } - } - public Color4 ForStarDifficulty(double starDifficulty) => ColourUtils.SampleFromLinearGradient(new[] { (0.1f, Color4Extensions.FromHex("aaaaaa")), @@ -100,26 +73,31 @@ namespace osu.Game.Graphics /// /// Retrieves the colour for a . /// - public Color4 ForHitResult(HitResult judgement) + public Color4 ForHitResult(HitResult result) { - switch (judgement) + switch (result) { - case HitResult.Perfect: - case HitResult.Great: - return Blue; - - case HitResult.Ok: - case HitResult.Good: - return Green; + case HitResult.SmallTickMiss: + case HitResult.LargeTickMiss: + case HitResult.Miss: + return Red; case HitResult.Meh: return Yellow; - case HitResult.Miss: - return Red; + case HitResult.Ok: + return Green; + + case HitResult.Good: + return GreenLight; + + case HitResult.SmallTickHit: + case HitResult.LargeTickHit: + case HitResult.Great: + return Blue; default: - return Color4.White; + return BlueLight; } } @@ -135,6 +113,9 @@ namespace osu.Game.Graphics { switch (status) { + case BeatmapOnlineStatus.LocallyModified: + return Color4.OrangeRed; + case BeatmapOnlineStatus.Ranked: case BeatmapOnlineStatus.Approved: return Color4Extensions.FromHex(@"b3ff66"); @@ -207,6 +188,41 @@ namespace osu.Game.Graphics } } + /// + /// Retrieves colour for a . + /// See https://www.figma.com/file/YHWhp9wZ089YXgB7pe6L1k/Tier-Colours + /// + public ColourInfo ForRankingTier(RankingTier tier) + { + switch (tier) + { + default: + case RankingTier.Iron: + return Color4Extensions.FromHex(@"BAB3AB"); + + case RankingTier.Bronze: + return ColourInfo.GradientVertical(Color4Extensions.FromHex(@"B88F7A"), Color4Extensions.FromHex(@"855C47")); + + case RankingTier.Silver: + return ColourInfo.GradientVertical(Color4Extensions.FromHex(@"E0E0EB"), Color4Extensions.FromHex(@"A3A3C2")); + + case RankingTier.Gold: + return ColourInfo.GradientVertical(Color4Extensions.FromHex(@"F0E4A8"), Color4Extensions.FromHex(@"E0C952")); + + case RankingTier.Platinum: + return ColourInfo.GradientVertical(Color4Extensions.FromHex(@"A8F0EF"), Color4Extensions.FromHex(@"52E0DF")); + + case RankingTier.Rhodium: + return ColourInfo.GradientVertical(Color4Extensions.FromHex(@"D9F8D3"), Color4Extensions.FromHex(@"A0CF96")); + + case RankingTier.Radiant: + return ColourInfo.GradientVertical(Color4Extensions.FromHex(@"97DCFF"), Color4Extensions.FromHex(@"ED82FF")); + + case RankingTier.Lustrous: + return ColourInfo.GradientVertical(Color4Extensions.FromHex(@"FFE600"), Color4Extensions.FromHex(@"ED82FF")); + } + } + /// /// Returns a foreground text colour that is supposed to contrast well with /// the supplied . diff --git a/osu.Game/Graphics/OsuFont.cs b/osu.Game/Graphics/OsuFont.cs index edb484021c..7aa98ece95 100644 --- a/osu.Game/Graphics/OsuFont.cs +++ b/osu.Game/Graphics/OsuFont.cs @@ -1,6 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System.ComponentModel; using osu.Framework.Graphics.Sprites; namespace osu.Game.Graphics @@ -35,7 +38,10 @@ namespace osu.Game.Graphics /// Whether all characters should be spaced the same distance apart. /// The . public static FontUsage GetFont(Typeface typeface = Typeface.Torus, float size = DEFAULT_FONT_SIZE, FontWeight weight = FontWeight.Medium, bool italics = false, bool fixedWidth = false) - => new FontUsage(GetFamilyString(typeface), size, GetWeightString(typeface, weight), getItalics(italics), fixedWidth); + { + string familyString = GetFamilyString(typeface); + return new FontUsage(familyString, size, GetWeightString(familyString, weight), getItalics(italics), fixedWidth); + } private static bool getItalics(in bool italicsRequested) { @@ -54,16 +60,16 @@ namespace osu.Game.Graphics switch (typeface) { case Typeface.Venera: - return "Venera"; + return @"Venera"; case Typeface.Torus: - return "Torus"; + return @"Torus"; case Typeface.TorusAlternate: - return "Torus-Alternate"; + return @"Torus-Alternate"; case Typeface.Inter: - return "Inter"; + return @"Inter"; } return null; @@ -72,25 +78,17 @@ namespace osu.Game.Graphics /// /// Retrieves the string representation of a . /// - /// The . - /// The . - /// The string representation of in the specified . - public static string GetWeightString(Typeface typeface, FontWeight weight) + /// The font family. + /// The font weight. + /// The string representation of in the specified . + public static string GetWeightString(string family, FontWeight weight) { - if (typeface == Typeface.Torus && weight == FontWeight.Medium) + if ((family == GetFamilyString(Typeface.Torus) || family == GetFamilyString(Typeface.TorusAlternate)) && weight == FontWeight.Medium) // torus doesn't have a medium; fallback to regular. weight = FontWeight.Regular; - return GetWeightString(GetFamilyString(typeface), weight); + return weight.ToString(); } - - /// - /// Retrieves the string representation of a . - /// - /// The family string. - /// The . - /// The string representation of in the specified . - public static string GetWeightString(string family, FontWeight weight) => weight.ToString(); } public static class OsuFontExtensions @@ -118,6 +116,8 @@ namespace osu.Game.Graphics { Venera, Torus, + + [Description("Torus (alternate)")] TorusAlternate, Inter, } diff --git a/osu.Game/Graphics/OsuIcon.cs b/osu.Game/Graphics/OsuIcon.cs index e8267edab0..0a099f1fcc 100644 --- a/osu.Game/Graphics/OsuIcon.cs +++ b/osu.Game/Graphics/OsuIcon.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Sprites; namespace osu.Game.Graphics diff --git a/osu.Game/Graphics/ParticleExplosion.cs b/osu.Game/Graphics/ParticleExplosion.cs index ec1077eb81..56e1568441 100644 --- a/osu.Game/Graphics/ParticleExplosion.cs +++ b/osu.Game/Graphics/ParticleExplosion.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Vertices; using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Utils; @@ -16,7 +18,7 @@ namespace osu.Game.Graphics /// /// An explosion of textured particles based on how osu-stable randomises the explosion pattern. /// - public class ParticleExplosion : Sprite + public partial class ParticleExplosion : Sprite { private readonly int particleCount; private readonly double duration; @@ -87,7 +89,7 @@ namespace osu.Game.Graphics currentTime = source.Time.Current; } - protected override void Blit(Action vertexAction) + protected override void Blit(IRenderer renderer) { double time = currentTime - startTime; @@ -110,9 +112,9 @@ namespace osu.Game.Graphics Vector2Extensions.Transform(rect.BottomRight, DrawInfo.Matrix) ); - DrawQuad(Texture, quad, DrawColourInfo.Colour.MultiplyAlpha(alpha), null, vertexAction, - new Vector2(InflationAmount.X / DrawRectangle.Width, InflationAmount.Y / DrawRectangle.Height), - null, TextureCoords); + renderer.DrawQuad(Texture, quad, DrawColourInfo.Colour.MultiplyAlpha(alpha), + inflationPercentage: new Vector2(InflationAmount.X / DrawRectangle.Width, InflationAmount.Y / DrawRectangle.Height), + textureCoords: TextureCoords); } } diff --git a/osu.Game/Graphics/ParticleSpewer.cs b/osu.Game/Graphics/ParticleSpewer.cs index 369a4b21c7..8519cf0c59 100644 --- a/osu.Game/Graphics/ParticleSpewer.cs +++ b/osu.Game/Graphics/ParticleSpewer.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Bindables; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Vertices; using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Utils; @@ -14,7 +16,7 @@ using osuTK; namespace osu.Game.Graphics { - public abstract class ParticleSpewer : Sprite + public abstract partial class ParticleSpewer : Sprite { private readonly FallingParticle[] particles; private int currentIndex; @@ -105,7 +107,7 @@ namespace osu.Game.Graphics sourceSize = Source.DrawSize; } - protected override void Blit(Action vertexAction) + protected override void Blit(IRenderer renderer) { foreach (var p in particles) { @@ -134,9 +136,9 @@ namespace osu.Game.Graphics transformPosition(rect.BottomRight, rect.Centre, angle) ); - DrawQuad(Texture, quad, DrawColourInfo.Colour.MultiplyAlpha(alpha), null, vertexAction, - new Vector2(InflationAmount.X / DrawRectangle.Width, InflationAmount.Y / DrawRectangle.Height), - null, TextureCoords); + renderer.DrawQuad(Texture, quad, DrawColourInfo.Colour.MultiplyAlpha(alpha), + inflationPercentage: new Vector2(InflationAmount.X / DrawRectangle.Width, InflationAmount.Y / DrawRectangle.Height), + textureCoords: TextureCoords); } } diff --git a/osu.Game/Graphics/ScreenshotManager.cs b/osu.Game/Graphics/ScreenshotManager.cs index 53f7d5791b..d799e82bc9 100644 --- a/osu.Game/Graphics/ScreenshotManager.cs +++ b/osu.Game/Graphics/ScreenshotManager.cs @@ -1,7 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; +using System.IO; using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; @@ -23,7 +26,7 @@ using SixLabors.ImageSharp.Formats.Jpeg; namespace osu.Game.Graphics { - public class ScreenshotManager : Component, IKeyBindingHandler, IHandleGlobalKeyboardInput + public partial class ScreenshotManager : Component, IKeyBindingHandler, IHandleGlobalKeyboardInput { private readonly BindableBool cursorVisibility = new BindableBool(true); @@ -101,7 +104,9 @@ namespace osu.Game.Graphics framesWaitedEvent.Set(); }, 10, true); - framesWaitedEvent.Wait(); + if (!framesWaitedEvent.Wait(1000)) + throw new TimeoutException("Screenshot data did not arrive in a timely fashion"); + waitDelegate.Cancel(); } } @@ -113,11 +118,11 @@ namespace osu.Game.Graphics host.GetClipboard()?.SetImage(image); - string filename = getFilename(); + (string filename, var stream) = getWritableStream(); if (filename == null) return; - using (var stream = storage.CreateFileSafely(filename)) + using (stream) { switch (screenshotFormat.Value) { @@ -138,7 +143,7 @@ namespace osu.Game.Graphics notificationOverlay.Post(new SimpleNotification { - Text = $"{filename} saved!", + Text = $"Screenshot {filename} saved!", Activated = () => { storage.PresentFileExternally(filename); @@ -148,23 +153,28 @@ namespace osu.Game.Graphics } }); - private string getFilename() + private static readonly object filename_reservation_lock = new object(); + + private (string filename, Stream stream) getWritableStream() { - var dt = DateTime.Now; - string fileExt = screenshotFormat.ToString().ToLowerInvariant(); - - string withoutIndex = $"osu_{dt:yyyy-MM-dd_HH-mm-ss}.{fileExt}"; - if (!storage.Exists(withoutIndex)) - return withoutIndex; - - for (ulong i = 1; i < ulong.MaxValue; i++) + lock (filename_reservation_lock) { - string indexedName = $"osu_{dt:yyyy-MM-dd_HH-mm-ss}-{i}.{fileExt}"; - if (!storage.Exists(indexedName)) - return indexedName; - } + var dt = DateTime.Now; + string fileExt = screenshotFormat.ToString().ToLowerInvariant(); - return null; + string withoutIndex = $"osu_{dt:yyyy-MM-dd_HH-mm-ss}.{fileExt}"; + if (!storage.Exists(withoutIndex)) + return (withoutIndex, storage.GetStream(withoutIndex, FileAccess.Write, FileMode.Create)); + + for (ulong i = 1; i < ulong.MaxValue; i++) + { + string indexedName = $"osu_{dt:yyyy-MM-dd_HH-mm-ss}-{i}.{fileExt}"; + if (!storage.Exists(indexedName)) + return (indexedName, storage.GetStream(indexedName, FileAccess.Write, FileMode.Create)); + } + + return (null, null); + } } } } diff --git a/osu.Game/Graphics/Sprites/GlowingSpriteText.cs b/osu.Game/Graphics/Sprites/GlowingSpriteText.cs index 9c43383a29..ae594ddfe2 100644 --- a/osu.Game/Graphics/Sprites/GlowingSpriteText.cs +++ b/osu.Game/Graphics/Sprites/GlowingSpriteText.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -11,7 +13,7 @@ using osuTK; namespace osu.Game.Graphics.Sprites { - public class GlowingSpriteText : Container, IHasText + public partial class GlowingSpriteText : Container, IHasText { private readonly OsuSpriteText spriteText, blurredText; diff --git a/osu.Game/Graphics/Sprites/LogoAnimation.cs b/osu.Game/Graphics/Sprites/LogoAnimation.cs index 36fcd39b54..f02017dc57 100644 --- a/osu.Game/Graphics/Sprites/LogoAnimation.cs +++ b/osu.Game/Graphics/Sprites/LogoAnimation.cs @@ -1,22 +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 System.Runtime.InteropServices; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Vertices; +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 { - public class LogoAnimation : Sprite + public partial class LogoAnimation : Sprite { [BackgroundDependencyLoader] private void load(ShaderManager shaders) { - TextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, @"LogoAnimation"); - RoundedTextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, @"LogoAnimation"); // Masking isn't supported for now + TextureShader = shaders.Load(@"LogoAnimation", @"LogoAnimation"); } private float animationProgress; @@ -41,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() @@ -55,14 +73,69 @@ namespace osu.Game.Graphics.Sprites progress = source.animationProgress; } - protected override void Blit(Action vertexAction) - { - Shader.GetUniform("progress").UpdateValue(ref progress); + private IUniformBuffer animationDataBuffer; + private IVertexBatch animationVertexBatch; - base.Blit(vertexAction); + 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) + { + 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; + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + animationDataBuffer?.Dispose(); + } + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + private record struct AnimationData + { + 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/Sprites/OsuSpriteText.cs b/osu.Game/Graphics/Sprites/OsuSpriteText.cs index 76e46513ba..e149e0abfb 100644 --- a/osu.Game/Graphics/Sprites/OsuSpriteText.cs +++ b/osu.Game/Graphics/Sprites/OsuSpriteText.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Sprites; namespace osu.Game.Graphics.Sprites { - public class OsuSpriteText : SpriteText + public partial class OsuSpriteText : SpriteText { public OsuSpriteText() { diff --git a/osu.Game/Graphics/Sprites/SizePreservingSpriteText.cs b/osu.Game/Graphics/Sprites/SizePreservingSpriteText.cs new file mode 100644 index 0000000000..458dac2b91 --- /dev/null +++ b/osu.Game/Graphics/Sprites/SizePreservingSpriteText.cs @@ -0,0 +1,108 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.Sprites +{ + /// + /// A wrapped version of which will expand in size based on text content, but never shrink back down. + /// + public partial class SizePreservingSpriteText : CompositeDrawable + { + private readonly OsuSpriteText text = new OsuSpriteText(); + + private Vector2 maximumSize; + + public SizePreservingSpriteText(Vector2? minimumSize = null) + { + text.Origin = Anchor.Centre; + text.Anchor = Anchor.Centre; + + AddInternal(text); + maximumSize = minimumSize ?? Vector2.Zero; + } + + protected override void Update() + { + Width = maximumSize.X = MathF.Max(maximumSize.X, text.Width); + Height = maximumSize.Y = MathF.Max(maximumSize.Y, text.Height); + } + + public new Axes AutoSizeAxes + { + get => Axes.None; + set => throw new InvalidOperationException("You can't set AutoSizeAxes of this container"); + } + + /// + /// Gets or sets the text to be displayed. + /// + public LocalisableString Text + { + get => text.Text; + set => text.Text = value; + } + + /// + /// Contains information on the font used to display the text. + /// + public FontUsage Font + { + get => text.Font; + set => text.Font = value; + } + + /// + /// True if a shadow should be displayed around the text. + /// + public bool Shadow + { + get => text.Shadow; + set => text.Shadow = value; + } + + /// + /// The colour of the shadow displayed around the text. A shadow will only be displayed if the property is set to true. + /// + public Color4 ShadowColour + { + get => text.ShadowColour; + set => text.ShadowColour = value; + } + + /// + /// The offset of the shadow displayed around the text. A shadow will only be displayed if the property is set to true. + /// + public Vector2 ShadowOffset + { + get => text.ShadowOffset; + set => text.ShadowOffset = value; + } + + /// + /// True if the 's vertical size should be equal to (the full height) or precisely the size of used characters. + /// Set to false to allow better centering of individual characters/numerals/etc. + /// + public bool UseFullGlyphHeight + { + get => text.UseFullGlyphHeight; + set => text.UseFullGlyphHeight = value; + } + + public override bool IsPresent => text.IsPresent; + + public override string ToString() => text.ToString(); + + public float LineBaseHeight => text.LineBaseHeight; + + public IEnumerable FilterTerms => text.FilterTerms; + } +} diff --git a/osu.Game/Graphics/UserInterface/BackButton.cs b/osu.Game/Graphics/UserInterface/BackButton.cs index 2b59ee0282..cd9a357ea4 100644 --- a/osu.Game/Graphics/UserInterface/BackButton.cs +++ b/osu.Game/Graphics/UserInterface/BackButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osu.Game.Input.Bindings; namespace osu.Game.Graphics.UserInterface { - public class BackButton : VisibilityContainer + public partial class BackButton : VisibilityContainer { public Action Action; @@ -58,7 +60,7 @@ namespace osu.Game.Graphics.UserInterface button.FadeOut(400, Easing.OutQuint); } - public class Receptor : Drawable, IKeyBindingHandler + public partial class Receptor : Drawable, IKeyBindingHandler { public Action OnBackPressed; diff --git a/osu.Game/Graphics/UserInterface/Bar.cs b/osu.Game/Graphics/UserInterface/Bar.cs index 0be928cf83..53217e2120 100644 --- a/osu.Game/Graphics/UserInterface/Bar.cs +++ b/osu.Game/Graphics/UserInterface/Bar.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osuTK; using osuTK.Graphics; @@ -10,7 +12,7 @@ using osu.Framework.Graphics.Shapes; namespace osu.Game.Graphics.UserInterface { - public class Bar : Container, IHasAccentColour + public partial class Bar : Container, IHasAccentColour { private readonly Box background; private readonly Box bar; @@ -107,15 +109,11 @@ namespace osu.Game.Graphics.UserInterface } } - [Flags] public enum BarDirection { - LeftToRight = 1, - RightToLeft = 1 << 1, - TopToBottom = 1 << 2, - BottomToTop = 1 << 3, - - Vertical = TopToBottom | BottomToTop, - Horizontal = LeftToRight | RightToLeft, + LeftToRight, + RightToLeft, + TopToBottom, + BottomToTop } } diff --git a/osu.Game/Graphics/UserInterface/BarGraph.cs b/osu.Game/Graphics/UserInterface/BarGraph.cs index 407bf6a923..c394e58d87 100644 --- a/osu.Game/Graphics/UserInterface/BarGraph.cs +++ b/osu.Game/Graphics/UserInterface/BarGraph.cs @@ -1,17 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using System.Collections.Generic; using System.Linq; -using osu.Framework.Extensions.EnumExtensions; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Shaders; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Textures; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Utils; +using System; namespace osu.Game.Graphics.UserInterface { - public class BarGraph : FillFlowContainer + public partial class BarGraph : Drawable { + private const int resize_duration = 250; + private const Easing easing = Easing.InOutCubic; + /// /// Manually sets the max value, if null is instead used /// @@ -19,22 +29,21 @@ namespace osu.Game.Graphics.UserInterface private BarDirection direction = BarDirection.BottomToTop; - public new BarDirection Direction + public BarDirection Direction { get => direction; set { - direction = value; - base.Direction = direction.HasFlagFast(BarDirection.Horizontal) ? FillDirection.Vertical : FillDirection.Horizontal; + if (direction == value) + return; - foreach (var bar in Children) - { - bar.Size = direction.HasFlagFast(BarDirection.Horizontal) ? new Vector2(1, 1.0f / Children.Count) : new Vector2(1.0f / Children.Count, 1); - bar.Direction = direction; - } + direction = value; + Invalidate(Invalidation.DrawNode); } } + private readonly BarsInfo bars = new BarsInfo(); + /// /// A list of floats that defines the length of each /// @@ -42,37 +51,199 @@ namespace osu.Game.Graphics.UserInterface { set { - List bars = Children.ToList(); - - foreach (var bar in value.Select((length, index) => new { Value = length, Bar = bars.Count > index ? bars[index] : null })) + if (!value.Any()) { - float length = MaxValue ?? value.Max(); - if (length != 0) - length = bar.Value / length; - - float size = value.Count(); - if (size != 0) - size = 1.0f / size; - - if (bar.Bar != null) - { - bar.Bar.Length = length; - bar.Bar.Size = direction.HasFlagFast(BarDirection.Horizontal) ? new Vector2(1, size) : new Vector2(size, 1); - } - else - { - Add(new Bar - { - RelativeSizeAxes = Axes.Both, - Size = direction.HasFlagFast(BarDirection.Horizontal) ? new Vector2(1, size) : new Vector2(size, 1), - Length = length, - Direction = Direction, - }); - } + bars.Clear(); + Invalidate(Invalidation.DrawNode); + return; } - //I'm using ToList() here because Where() returns an Enumerable which can change it's elements afterwards - RemoveRange(Children.Where((bar, index) => index >= value.Count()).ToList()); + float maxLength = MaxValue ?? value.Max(); + + bars.SetLengths(value.Select(v => maxLength == 0 ? 0 : Math.Max(0f, v / maxLength)).ToArray()); + + animationStartTime = Clock.CurrentTime; + animationComplete = false; + } + } + + private double animationStartTime; + private bool animationComplete; + + private IShader shader = null!; + private Texture texture = null!; + + [BackgroundDependencyLoader] + private void load(IRenderer renderer, ShaderManager shaders) + { + texture = renderer.WhitePixel; + shader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE); + } + + protected override void Update() + { + base.Update(); + + if (!bars.Any) + return; + + double currentTime = Clock.CurrentTime; + + if (currentTime < animationStartTime + resize_duration) + { + bars.Animate(animationStartTime, currentTime); + Invalidate(Invalidation.DrawNode); + } + else if (!animationComplete) + { + bars.FinishAnimation(); + Invalidate(Invalidation.DrawNode); + + animationComplete = true; + } + } + + protected override DrawNode CreateDrawNode() => new BarGraphDrawNode(this); + + private class BarGraphDrawNode : DrawNode + { + public new BarGraph Source => (BarGraph)base.Source; + + public BarGraphDrawNode(BarGraph source) + : base(source) + { + } + + private IShader shader = null!; + private Texture texture = null!; + private Vector2 drawSize; + private BarDirection direction; + private float barBreadth; + + private readonly List lengths = new List(); + + public override void ApplyState() + { + base.ApplyState(); + + shader = Source.shader; + texture = Source.texture; + drawSize = Source.DrawSize; + direction = Source.direction; + barBreadth = Source.bars.Breadth; + + lengths.Clear(); + lengths.AddRange(Source.bars.InstantaneousLengths); + } + + public override void Draw(IRenderer renderer) + { + base.Draw(renderer); + + shader.Bind(); + + for (int i = 0; i < lengths.Count; i++) + { + float barHeight = drawSize.Y * ((direction == BarDirection.TopToBottom || direction == BarDirection.BottomToTop) ? lengths[i] : barBreadth); + float barWidth = drawSize.X * ((direction == BarDirection.LeftToRight || direction == BarDirection.RightToLeft) ? lengths[i] : barBreadth); + + Vector2 topLeft; + + switch (direction) + { + default: + case BarDirection.LeftToRight: + topLeft = new Vector2(0, i * barHeight); + break; + + case BarDirection.RightToLeft: + topLeft = new Vector2(drawSize.X - barWidth, i * barHeight); + break; + + case BarDirection.TopToBottom: + topLeft = new Vector2(i * barWidth, 0); + break; + + case BarDirection.BottomToTop: + topLeft = new Vector2(i * barWidth, drawSize.Y - barHeight); + break; + } + + renderer.DrawQuad( + texture, + new Quad( + Vector2Extensions.Transform(topLeft, DrawInfo.Matrix), + Vector2Extensions.Transform(topLeft + new Vector2(barWidth, 0), DrawInfo.Matrix), + Vector2Extensions.Transform(topLeft + new Vector2(0, barHeight), DrawInfo.Matrix), + Vector2Extensions.Transform(topLeft + new Vector2(barWidth, barHeight), DrawInfo.Matrix) + ), + DrawColourInfo.Colour); + } + + shader.Unbind(); + } + } + + private class BarsInfo + { + public bool Any => Count > 0; + + public int Count { get; private set; } + + public float Breadth { get; private set; } + + public List InstantaneousLengths { get; } = new List(); + + private readonly List initialLengths = new List(); + private readonly List finalLengths = new List(); + + public void Clear() => SetLengths(Array.Empty()); + + public void SetLengths(float[] newLengths) + { + int newCount = newLengths.Length; + + for (int i = 0; i < newCount; i++) + { + // If we have an old bar at this index - change it's length + if (i < Count) + { + initialLengths[i] = finalLengths[i]; + finalLengths[i] = newLengths[i]; + + continue; + } + + // If exceeded old bars count - add new one + initialLengths.Add(0); + finalLengths.Add(newLengths[i]); + InstantaneousLengths.Add(0); + } + + // Remove excessive bars + if (Count > newCount) + { + int barsToRemove = Count - newCount; + + initialLengths.RemoveRange(newCount, barsToRemove); + finalLengths.RemoveRange(newCount, barsToRemove); + InstantaneousLengths.RemoveRange(newCount, barsToRemove); + } + + Count = newCount; + Breadth = Count == 0 ? 0 : (1f / Count); + } + + public void Animate(double animationStartTime, double currentTime) + { + for (int i = 0; i < Count; i++) + InstantaneousLengths[i] = Interpolation.ValueAt(currentTime, initialLengths[i], finalLengths[i], animationStartTime, animationStartTime + resize_duration, easing); + } + + public void FinishAnimation() + { + for (int i = 0; i < Count; i++) + InstantaneousLengths[i] = finalLengths[i]; } } } diff --git a/osu.Game/Graphics/UserInterface/BasicSearchTextBox.cs b/osu.Game/Graphics/UserInterface/BasicSearchTextBox.cs index aa101d7e08..c4e03133dc 100644 --- a/osu.Game/Graphics/UserInterface/BasicSearchTextBox.cs +++ b/osu.Game/Graphics/UserInterface/BasicSearchTextBox.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osuTK; namespace osu.Game.Graphics.UserInterface { - public class BasicSearchTextBox : SearchTextBox + public partial class BasicSearchTextBox : SearchTextBox { public BasicSearchTextBox() { @@ -23,4 +25,4 @@ namespace osu.Game.Graphics.UserInterface TextFlow.Padding = new MarginPadding { Right = 35 }; } } -} \ No newline at end of file +} diff --git a/osu.Game/Graphics/UserInterface/BreadcrumbControl.cs b/osu.Game/Graphics/UserInterface/BreadcrumbControl.cs index d06c227d4b..fc0770d896 100644 --- a/osu.Game/Graphics/UserInterface/BreadcrumbControl.cs +++ b/osu.Game/Graphics/UserInterface/BreadcrumbControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osuTK; using osu.Framework; @@ -12,7 +14,7 @@ using osu.Framework.Graphics.Sprites; namespace osu.Game.Graphics.UserInterface { - public class BreadcrumbControl : OsuTabControl + public partial class BreadcrumbControl : OsuTabControl { private const float padding = 10; @@ -42,7 +44,7 @@ namespace osu.Game.Graphics.UserInterface }; } - public class BreadcrumbTabItem : OsuTabItem, IStateful + public partial class BreadcrumbTabItem : OsuTabItem, IStateful { protected virtual float ChevronSize => 10; @@ -50,8 +52,8 @@ namespace osu.Game.Graphics.UserInterface public readonly SpriteIcon Chevron; - //don't allow clicking between transitions and don't make the chevron clickable - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Alpha == 1f && Text.ReceivePositionalInputAt(screenSpacePos); + //don't allow clicking between transitions + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Alpha == 1f && base.ReceivePositionalInputAt(screenSpacePos); public override bool HandleNonPositionalInput => State == Visibility.Visible; public override bool HandlePositionalInput => State == Visibility.Visible; @@ -93,7 +95,7 @@ namespace osu.Game.Graphics.UserInterface { Text.Font = Text.Font.With(size: 18); Text.Margin = new MarginPadding { Vertical = 8 }; - Padding = new MarginPadding { Right = padding + ChevronSize }; + Margin = new MarginPadding { Right = padding + ChevronSize }; Add(Chevron = new SpriteIcon { Anchor = Anchor.CentreRight, diff --git a/osu.Game/Graphics/UserInterface/CommaSeparatedScoreCounter.cs b/osu.Game/Graphics/UserInterface/CommaSeparatedScoreCounter.cs index 4e1c612f09..ba76a17fc6 100644 --- a/osu.Game/Graphics/UserInterface/CommaSeparatedScoreCounter.cs +++ b/osu.Game/Graphics/UserInterface/CommaSeparatedScoreCounter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Localisation; @@ -8,7 +10,7 @@ using osu.Game.Graphics.Sprites; namespace osu.Game.Graphics.UserInterface { - public abstract class CommaSeparatedScoreCounter : RollingCounter + public abstract partial class CommaSeparatedScoreCounter : RollingCounter { protected override double RollingDuration => 1000; protected override Easing RollingEasing => Easing.Out; diff --git a/osu.Game/Graphics/UserInterface/DangerousTriangleButton.cs b/osu.Game/Graphics/UserInterface/DangerousRoundedButton.cs similarity index 70% rename from osu.Game/Graphics/UserInterface/DangerousTriangleButton.cs rename to osu.Game/Graphics/UserInterface/DangerousRoundedButton.cs index 89a4c28c8c..5855a66ae1 100644 --- a/osu.Game/Graphics/UserInterface/DangerousTriangleButton.cs +++ b/osu.Game/Graphics/UserInterface/DangerousRoundedButton.cs @@ -1,18 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; +using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Graphics.UserInterface { - public class DangerousTriangleButton : TriangleButton + public partial class DangerousRoundedButton : RoundedButton { [BackgroundDependencyLoader] private void load(OsuColour colours) { BackgroundColour = colours.PinkDark; - Triangles.ColourDark = colours.PinkDarker; - Triangles.ColourLight = colours.Pink; } } } diff --git a/osu.Game/Graphics/UserInterface/DialogButton.cs b/osu.Game/Graphics/UserInterface/DialogButton.cs index 69fbd744c9..670778b07b 100644 --- a/osu.Game/Graphics/UserInterface/DialogButton.cs +++ b/osu.Game/Graphics/UserInterface/DialogButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework; using osu.Framework.Extensions.Color4Extensions; @@ -20,7 +22,7 @@ using osuTK.Graphics; namespace osu.Game.Graphics.UserInterface { - public class DialogButton : OsuClickableContainer, IStateful + public partial class DialogButton : OsuClickableContainer, IStateful { private const float idle_width = 0.8f; private const float hover_width = 0.9f; diff --git a/osu.Game/Graphics/UserInterface/DownloadButton.cs b/osu.Game/Graphics/UserInterface/DownloadButton.cs index af270f30ae..73783e718c 100644 --- a/osu.Game/Graphics/UserInterface/DownloadButton.cs +++ b/osu.Game/Graphics/UserInterface/DownloadButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osuTK; namespace osu.Game.Graphics.UserInterface { - public class DownloadButton : GrayButton + public partial class DownloadButton : GrayButton { [Resolved] private OsuColour colours { get; set; } diff --git a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs index 4ecc543ffd..ad02e3b2ab 100644 --- a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -14,7 +16,7 @@ using osuTK.Graphics; namespace osu.Game.Graphics.UserInterface { - public class DrawableOsuMenuItem : Menu.DrawableMenuItem + public partial class DrawableOsuMenuItem : Menu.DrawableMenuItem { public const int MARGIN_HORIZONTAL = 17; public const int MARGIN_VERTICAL = 4; @@ -22,6 +24,7 @@ namespace osu.Game.Graphics.UserInterface private const int transition_length = 80; private TextContainer text; + private HoverClickSounds hoverClickSounds; public DrawableOsuMenuItem(MenuItem item) : base(item) @@ -34,7 +37,7 @@ namespace osu.Game.Graphics.UserInterface BackgroundColour = Color4.Transparent; BackgroundColourHover = Color4Extensions.FromHex(@"172023"); - AddInternal(new HoverClickSounds()); + AddInternal(hoverClickSounds = new HoverClickSounds()); updateTextColour(); @@ -74,6 +77,7 @@ namespace osu.Game.Graphics.UserInterface private void updateState() { + hoverClickSounds.Enabled.Value = !Item.Action.Disabled; Alpha = Item.Action.Disabled ? 0.2f : 1; if (IsHovered && !Item.Action.Disabled) @@ -91,7 +95,7 @@ namespace osu.Game.Graphics.UserInterface protected sealed override Drawable CreateContent() => text = CreateTextContainer(); protected virtual TextContainer CreateTextContainer() => new TextContainer(); - protected class TextContainer : Container, IHasText + protected partial class TextContainer : Container, IHasText { public LocalisableString Text { diff --git a/osu.Game/Graphics/UserInterface/DrawableStatefulMenuItem.cs b/osu.Game/Graphics/UserInterface/DrawableStatefulMenuItem.cs index 3dc99f2dbe..ec3a5744f8 100644 --- a/osu.Game/Graphics/UserInterface/DrawableStatefulMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/DrawableStatefulMenuItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; @@ -8,7 +10,7 @@ using osuTK; namespace osu.Game.Graphics.UserInterface { - public class DrawableStatefulMenuItem : DrawableOsuMenuItem + public partial class DrawableStatefulMenuItem : DrawableOsuMenuItem { protected new StatefulMenuItem Item => (StatefulMenuItem)base.Item; @@ -19,7 +21,7 @@ namespace osu.Game.Graphics.UserInterface protected override TextContainer CreateTextContainer() => new ToggleTextContainer(Item); - private class ToggleTextContainer : TextContainer + private partial class ToggleTextContainer : TextContainer { private readonly StatefulMenuItem menuItem; private readonly Bindable state; diff --git a/osu.Game/Graphics/UserInterface/ExpandableSlider.cs b/osu.Game/Graphics/UserInterface/ExpandableSlider.cs index a05c0cfab0..5bc17303d8 100644 --- a/osu.Game/Graphics/UserInterface/ExpandableSlider.cs +++ b/osu.Game/Graphics/UserInterface/ExpandableSlider.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -17,9 +19,9 @@ namespace osu.Game.Graphics.UserInterface /// /// An implementation for the UI slider bar control. /// - public class ExpandableSlider : CompositeDrawable, IExpandable, IHasCurrentValue + public partial class ExpandableSlider : CompositeDrawable, IExpandable, IHasCurrentValue where T : struct, IEquatable, IComparable, IConvertible - where TSlider : OsuSliderBar, new() + where TSlider : RoundedSliderBar, new() { private readonly OsuSpriteText label; private readonly TSlider slider; @@ -128,7 +130,7 @@ namespace osu.Game.Graphics.UserInterface /// /// An implementation for the UI slider bar control. /// - public class ExpandableSlider : ExpandableSlider> + public partial class ExpandableSlider : ExpandableSlider> where T : struct, IEquatable, IComparable, IConvertible { } diff --git a/osu.Game/Graphics/UserInterface/ExpandingBar.cs b/osu.Game/Graphics/UserInterface/ExpandingBar.cs index 60cb35b4c4..6d7c41ee7c 100644 --- a/osu.Game/Graphics/UserInterface/ExpandingBar.cs +++ b/osu.Game/Graphics/UserInterface/ExpandingBar.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osuTK; @@ -11,7 +13,7 @@ namespace osu.Game.Graphics.UserInterface /// A rounded bar which can be expanded or collapsed. /// Generally used for tabs or breadcrumbs. /// - public class ExpandingBar : Circle + public partial class ExpandingBar : Circle { private bool expanded = true; diff --git a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs index 1730e1478f..4eccb37613 100644 --- a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs +++ b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs @@ -1,31 +1,38 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Framework.Platform; +using osu.Game.Overlays; +using osu.Game.Overlays.OSD; using osuTK; using osuTK.Graphics; namespace osu.Game.Graphics.UserInterface { - public class ExternalLinkButton : CompositeDrawable, IHasTooltip + public partial class ExternalLinkButton : CompositeDrawable, IHasTooltip, IHasContextMenu { - public string Link { get; set; } + public string? Link { get; set; } private Color4 hoverColour; [Resolved] - private GameHost host { get; set; } + private GameHost host { get; set; } = null!; + + [Resolved] + private OnScreenDisplay? onScreenDisplay { get; set; } private readonly SpriteIcon linkIcon; - public ExternalLinkButton(string link = null) + public ExternalLinkButton(string? link = null) { Link = link; Size = new Vector2(12); @@ -66,5 +73,27 @@ namespace osu.Game.Graphics.UserInterface } public LocalisableString TooltipText => "view in browser"; + + public MenuItem[] ContextMenuItems + { + get + { + List items = new List(); + + if (Link != null) + { + items.Add(new OsuMenuItem("Open", MenuItemType.Highlighted, () => host.OpenUrlExternally(Link))); + items.Add(new OsuMenuItem("Copy URL", MenuItemType.Standard, copyUrl)); + } + + return items.ToArray(); + } + } + + private void copyUrl() + { + host.GetClipboard()?.SetText(Link); + onScreenDisplay?.Display(new CopyUrlToast()); + } } } diff --git a/osu.Game/Graphics/UserInterface/FPSCounter.cs b/osu.Game/Graphics/UserInterface/FPSCounter.cs new file mode 100644 index 0000000000..9dbeba6449 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/FPSCounter.cs @@ -0,0 +1,284 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Platform; +using osu.Framework.Timing; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Graphics.UserInterface +{ + public partial class FPSCounter : VisibilityContainer, IHasCustomTooltip + { + private OsuSpriteText counterUpdateFrameTime = null!; + private OsuSpriteText counterDrawFPS = null!; + + private Container mainContent = null!; + + private Container background = null!; + + private Container counters = null!; + + private const double min_time_between_updates = 10; + + private const double spike_time_ms = 20; + + private const float idle_background_alpha = 0.4f; + + private readonly BindableBool showFpsDisplay = new BindableBool(true); + + private double displayedFpsCount; + private double displayedFrameTime; + + private bool isDisplayed; + + private double aimDrawFPS; + private double aimUpdateFPS; + + private double lastUpdate; + private ThrottledFrameClock drawClock = null!; + private ThrottledFrameClock updateClock = null!; + private ThrottledFrameClock inputClock = null!; + + /// + /// The last time value where the display was required (due to a significant change or hovering). + /// + private double lastDisplayRequiredTime; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public FPSCounter() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config, GameHost gameHost) + { + InternalChildren = new Drawable[] + { + mainContent = new Container + { + Alpha = 0, + Height = 26, + Children = new Drawable[] + { + background = new Container + { + RelativeSizeAxes = Axes.Both, + CornerRadius = 5, + CornerExponent = 5f, + Masking = true, + Alpha = idle_background_alpha, + Children = new Drawable[] + { + new Box + { + Colour = colours.Gray0, + RelativeSizeAxes = Axes.Both, + }, + } + }, + counters = new Container + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + counterUpdateFrameTime = new OsuSpriteText + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Margin = new MarginPadding(1), + Font = OsuFont.Default.With(fixedWidth: true, size: 16, weight: FontWeight.SemiBold), + Spacing = new Vector2(-1), + Y = -2, + }, + counterDrawFPS = new OsuSpriteText + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Margin = new MarginPadding(2), + Font = OsuFont.Default.With(fixedWidth: true, size: 13, weight: FontWeight.SemiBold), + Spacing = new Vector2(-2), + Y = 10, + } + } + }, + } + }, + }; + + config.BindWith(OsuSetting.ShowFpsDisplay, showFpsDisplay); + + drawClock = gameHost.DrawThread.Clock; + updateClock = gameHost.UpdateThread.Clock; + inputClock = gameHost.InputThread.Clock; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + requestDisplay(); + + showFpsDisplay.BindValueChanged(showFps => + { + State.Value = showFps.NewValue ? Visibility.Visible : Visibility.Hidden; + if (showFps.NewValue) + requestDisplay(); + }, true); + + State.BindValueChanged(state => showFpsDisplay.Value = state.NewValue == Visibility.Visible); + } + + protected override void PopIn() => this.FadeIn(100); + + protected override void PopOut() => this.FadeOut(100); + + protected override bool OnHover(HoverEvent e) + { + background.FadeTo(1, 200); + requestDisplay(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + background.FadeTo(idle_background_alpha, 200); + requestDisplay(); + base.OnHoverLost(e); + } + + protected override void Update() + { + base.Update(); + + // If the game goes into a suspended state (ie. debugger attached or backgrounded on a mobile device) + // we want to ignore really long periods of no processing. + if (updateClock.ElapsedFrameTime > 10000) + return; + + mainContent.Width = Math.Max(mainContent.Width, counters.DrawWidth); + + // Handle the case where the window has become inactive or the user changed the + // frame limiter (we want to show the FPS as it's changing, even if it isn't an outlier). + bool aimRatesChanged = updateAimFPS(); + + bool hasUpdateSpike = displayedFrameTime < spike_time_ms && updateClock.ElapsedFrameTime > spike_time_ms; + // use elapsed frame time rather then FramesPerSecond to better catch stutter frames. + bool hasDrawSpike = displayedFpsCount > (1000 / spike_time_ms) && drawClock.ElapsedFrameTime > spike_time_ms; + + const float damp_time = 100; + + displayedFrameTime = Interpolation.DampContinuously(displayedFrameTime, updateClock.ElapsedFrameTime, hasUpdateSpike ? 0 : damp_time, updateClock.ElapsedFrameTime); + + if (hasDrawSpike) + // show spike time using raw elapsed value, to account for `FramesPerSecond` being so averaged spike frames don't show. + displayedFpsCount = 1000 / drawClock.ElapsedFrameTime; + else + displayedFpsCount = Interpolation.DampContinuously(displayedFpsCount, drawClock.FramesPerSecond, damp_time, Time.Elapsed); + + if (Time.Current - lastUpdate > min_time_between_updates) + { + updateFpsDisplay(); + updateFrameTimeDisplay(); + + lastUpdate = Time.Current; + } + + bool hasSignificantChanges = aimRatesChanged + || hasDrawSpike + || hasUpdateSpike + || displayedFpsCount < aimDrawFPS * 0.8 + || 1000 / displayedFrameTime < aimUpdateFPS * 0.8; + + if (hasSignificantChanges) + requestDisplay(); + else if (isDisplayed && Time.Current - lastDisplayRequiredTime > 2000 && !IsHovered) + { + mainContent.FadeTo(0, 300, Easing.OutQuint); + isDisplayed = false; + } + } + + private void requestDisplay() + { + lastDisplayRequiredTime = Time.Current; + + if (!isDisplayed) + { + mainContent.FadeTo(1, 300, Easing.OutQuint); + isDisplayed = true; + } + } + + private void updateFpsDisplay() + { + counterDrawFPS.Colour = getColour(displayedFpsCount / aimDrawFPS); + counterDrawFPS.Text = $"{displayedFpsCount:#,0}fps"; + } + + private void updateFrameTimeDisplay() + { + counterUpdateFrameTime.Text = displayedFrameTime < 5 + ? $"{displayedFrameTime:N1}ms" + : $"{displayedFrameTime:N0}ms"; + + counterUpdateFrameTime.Colour = getColour((1000 / displayedFrameTime) / aimUpdateFPS); + } + + private bool updateAimFPS() + { + if (updateClock.Throttling) + { + double newAimDrawFPS = drawClock.MaximumUpdateHz; + double newAimUpdateFPS = updateClock.MaximumUpdateHz; + + if (aimDrawFPS != newAimDrawFPS || aimUpdateFPS != newAimUpdateFPS) + { + aimDrawFPS = newAimDrawFPS; + aimUpdateFPS = newAimUpdateFPS; + return true; + } + } + else + { + double newAimFPS = inputClock.MaximumUpdateHz; + + if (aimDrawFPS != newAimFPS || aimUpdateFPS != newAimFPS) + { + aimUpdateFPS = aimDrawFPS = newAimFPS; + return true; + } + } + + return false; + } + + private ColourInfo getColour(double performanceRatio) + { + if (performanceRatio < 0.5f) + return Interpolation.ValueAt(performanceRatio, colours.Red, colours.Orange2, 0, 0.5); + + return Interpolation.ValueAt(performanceRatio, colours.Orange2, colours.Lime0, 0.5, 0.9); + } + + public ITooltip GetCustomTooltip() => new FPSCounterTooltip(); + + public object TooltipContent => this; + } +} diff --git a/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs b/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs new file mode 100644 index 0000000000..17e7be1d8b --- /dev/null +++ b/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs @@ -0,0 +1,97 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Platform; +using osu.Game.Graphics.Containers; +using osuTK; + +namespace osu.Game.Graphics.UserInterface +{ + public partial class FPSCounterTooltip : CompositeDrawable, ITooltip + { + private OsuTextFlowContainer textFlow = null!; + + [Resolved] + private GameHost gameHost { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AutoSizeAxes = Axes.Both; + + CornerRadius = 15; + Masking = true; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = colours.Gray1, + Alpha = 1, + RelativeSizeAxes = Axes.Both, + }, + new OsuTextFlowContainer(cp => + { + cp.Font = OsuFont.Default.With(weight: FontWeight.SemiBold); + }) + { + AutoSizeAxes = Axes.Both, + TextAnchor = Anchor.TopRight, + Margin = new MarginPadding { Left = 5, Vertical = 10 }, + Text = string.Join('\n', gameHost.Threads.Select(t => t.Name)) + }, + textFlow = new OsuTextFlowContainer(cp => + { + cp.Font = OsuFont.Default.With(fixedWidth: true, weight: FontWeight.Regular); + cp.Spacing = new Vector2(-1); + }) + { + Width = 190, + Margin = new MarginPadding { Left = 35, Right = 10, Vertical = 10 }, + AutoSizeAxes = Axes.Y, + TextAnchor = Anchor.TopRight, + }, + }; + } + + private int lastUpdate; + + protected override void Update() + { + int currentSecond = (int)(Clock.CurrentTime / 100); + + if (currentSecond != lastUpdate) + { + lastUpdate = currentSecond; + + textFlow.Clear(); + + foreach (var thread in gameHost.Threads) + { + var clock = thread.Clock; + + string maximum = clock.Throttling + ? $"/{(clock.MaximumUpdateHz > 0 && clock.MaximumUpdateHz < 10000 ? clock.MaximumUpdateHz.ToString("0") : "∞"),4}" + : string.Empty; + + textFlow.AddParagraph($"{clock.FramesPerSecond:0}{maximum}fps ({clock.ElapsedFrameTime:0.00}ms)"); + } + } + } + + public void SetContent(object content) + { + } + + public void Move(Vector2 pos) + { + Position = pos; + } + } +} diff --git a/osu.Game/Graphics/UserInterface/FocusedTextBox.cs b/osu.Game/Graphics/UserInterface/FocusedTextBox.cs index d3a76a0f1a..338f32f321 100644 --- a/osu.Game/Graphics/UserInterface/FocusedTextBox.cs +++ b/osu.Game/Graphics/UserInterface/FocusedTextBox.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 enable - using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Input.Events; @@ -17,12 +15,17 @@ namespace osu.Game.Graphics.UserInterface /// /// A textbox which holds focus eagerly. /// - public class FocusedTextBox : OsuTextBox, IKeyBindingHandler + public partial class FocusedTextBox : OsuTextBox, IKeyBindingHandler { private bool focus; private bool allowImmediateFocus => host?.OnScreenKeyboardOverlapsGameWindow != true; + /// + /// Whether the content of the text box should be cleared on the first "back" key press. + /// + protected virtual bool ClearTextOnBackKey => true; + public void TakeFocus() { if (!allowImmediateFocus) @@ -80,11 +83,12 @@ namespace osu.Game.Graphics.UserInterface if (!HasFocus) return false; - if (e.Action == GlobalAction.Back) + if (ClearTextOnBackKey && e.Action == GlobalAction.Back) { if (Text.Length > 0) { Text = string.Empty; + PlayFeedbackSample(FeedbackSampleType.TextRemove); return true; } } diff --git a/osu.Game/Graphics/UserInterface/GradientLineTabControl.cs b/osu.Game/Graphics/UserInterface/GradientLineTabControl.cs index 1d67c4e033..7e29053035 100644 --- a/osu.Game/Graphics/UserInterface/GradientLineTabControl.cs +++ b/osu.Game/Graphics/UserInterface/GradientLineTabControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -11,7 +13,7 @@ using osu.Framework.Graphics.Colour; namespace osu.Game.Graphics.UserInterface { - public abstract class GradientLineTabControl : PageTabControl + public abstract partial class GradientLineTabControl : PageTabControl { protected Color4 LineColour { @@ -44,7 +46,7 @@ namespace osu.Game.Graphics.UserInterface Spacing = new Vector2(20, 0), }; - private class GradientLine : GridContainer + private partial class GradientLine : GridContainer { public GradientLine() { diff --git a/osu.Game/Graphics/UserInterface/GrayButton.cs b/osu.Game/Graphics/UserInterface/GrayButton.cs index 0a2c83d5a8..3df234e97d 100644 --- a/osu.Game/Graphics/UserInterface/GrayButton.cs +++ b/osu.Game/Graphics/UserInterface/GrayButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; @@ -9,7 +11,7 @@ using osuTK; namespace osu.Game.Graphics.UserInterface { - public class GrayButton : OsuAnimatedButton + public partial class GrayButton : OsuAnimatedButton { protected SpriteIcon Icon { get; private set; } protected Box Background { get; private set; } diff --git a/osu.Game/Graphics/UserInterface/HistoryTextBox.cs b/osu.Game/Graphics/UserInterface/HistoryTextBox.cs new file mode 100644 index 0000000000..b6dc1fcc9b --- /dev/null +++ b/osu.Game/Graphics/UserInterface/HistoryTextBox.cs @@ -0,0 +1,93 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Input.Events; +using osu.Game.Utils; +using osuTK.Input; + +namespace osu.Game.Graphics.UserInterface +{ + /// + /// A which additionally retains a history of text committed, up to a limit + /// (100 by default, specified in constructor). + /// The history of committed text can be navigated using up/down arrows. + /// This resembles the operation of command-line terminals. + /// + public partial class HistoryTextBox : FocusedTextBox + { + private readonly LimitedCapacityQueue messageHistory; + + public int HistoryCount => messageHistory.Count; + + private int selectedIndex; + + private string originalMessage = string.Empty; + + /// + /// Creates a new . + /// + /// + /// The maximum number of committed lines to keep in history. + /// When exceeded, the oldest lines in history will be dropped to make space for new ones. + /// + public HistoryTextBox(int capacity = 100) + { + messageHistory = new LimitedCapacityQueue(capacity); + + Current.ValueChanged += text => + { + if (selectedIndex != HistoryCount && text.NewValue != messageHistory[selectedIndex]) + { + selectedIndex = HistoryCount; + } + }; + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.ControlPressed || e.AltPressed || e.SuperPressed || e.ShiftPressed) + return false; + + switch (e.Key) + { + case Key.Up: + if (selectedIndex == 0) + return true; + + if (selectedIndex == HistoryCount) + originalMessage = Text; + + Text = messageHistory[--selectedIndex]; + + return true; + + case Key.Down: + if (selectedIndex == HistoryCount) + return true; + + if (selectedIndex == HistoryCount - 1) + { + selectedIndex = HistoryCount; + Text = originalMessage; + return true; + } + + Text = messageHistory[++selectedIndex]; + + return true; + } + + return base.OnKeyDown(e); + } + + protected override void Commit() + { + if (!string.IsNullOrEmpty(Text)) + messageHistory.Enqueue(Text); + + selectedIndex = HistoryCount; + + base.Commit(); + } + } +} diff --git a/osu.Game/Graphics/UserInterface/HoverClickSounds.cs b/osu.Game/Graphics/UserInterface/HoverClickSounds.cs index ba253a7c71..884834ebe8 100644 --- a/osu.Game/Graphics/UserInterface/HoverClickSounds.cs +++ b/osu.Game/Graphics/UserInterface/HoverClickSounds.cs @@ -1,10 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Input.Events; using osu.Framework.Utils; @@ -16,9 +19,13 @@ namespace osu.Game.Graphics.UserInterface /// Adds hover and click sounds to a drawable. /// Does not draw anything. /// - public class HoverClickSounds : HoverSounds + public partial class HoverClickSounds : HoverSounds { + public Bindable Enabled = new Bindable(true); + private Sample sampleClick; + private Sample sampleClickDisabled; + private readonly MouseButton[] buttons; /// @@ -37,20 +44,36 @@ namespace osu.Game.Graphics.UserInterface protected override bool OnClick(ClickEvent e) { - if (buttons.Contains(e.Button) && Contains(e.ScreenSpaceMousePosition)) + if (buttons.Contains(e.Button)) { - sampleClick.Frequency.Value = 0.99 + RNG.NextDouble(0.02); - sampleClick.Play(); + var channel = Enabled.Value ? sampleClick?.GetChannel() : sampleClickDisabled?.GetChannel(); + + if (channel != null) + { + channel.Frequency.Value = 0.99 + RNG.NextDouble(0.02); + channel.Play(); + } } return base.OnClick(e); } + public override void PlayHoverSample() + { + if (!Enabled.Value) + return; + + base.PlayHoverSample(); + } + [BackgroundDependencyLoader] private void load(AudioManager audio) { sampleClick = audio.Samples.Get($@"UI/{SampleSet.GetDescription()}-select") ?? audio.Samples.Get($@"UI/{HoverSampleSet.Default.GetDescription()}-select"); + + sampleClickDisabled = audio.Samples.Get($@"UI/{SampleSet.GetDescription()}-select-disabled") + ?? audio.Samples.Get($@"UI/{HoverSampleSet.Default.GetDescription()}-select-disabled"); } } } diff --git a/osu.Game/Graphics/UserInterface/HoverSampleDebounceComponent.cs b/osu.Game/Graphics/UserInterface/HoverSampleDebounceComponent.cs index 34ab7626c9..fee81e0e22 100644 --- a/osu.Game/Graphics/UserInterface/HoverSampleDebounceComponent.cs +++ b/osu.Game/Graphics/UserInterface/HoverSampleDebounceComponent.cs @@ -1,21 +1,26 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; using osu.Framework.Input.Events; using osu.Game.Configuration; +using osuTK; namespace osu.Game.Graphics.UserInterface { /// /// Handles debouncing hover sounds at a global level to ensure the effects are not overwhelming. /// - public abstract class HoverSampleDebounceComponent : CompositeDrawable + public abstract partial class HoverSampleDebounceComponent : Component { private Bindable lastPlaybackTime; + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parent?.ReceivePositionalInputAt(screenSpacePos) == true; + [BackgroundDependencyLoader] private void load(SessionStatics statics) { diff --git a/osu.Game/Graphics/UserInterface/HoverSampleSet.cs b/osu.Game/Graphics/UserInterface/HoverSampleSet.cs index b88f81a143..f0ff76b35d 100644 --- a/osu.Game/Graphics/UserInterface/HoverSampleSet.cs +++ b/osu.Game/Graphics/UserInterface/HoverSampleSet.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; namespace osu.Game.Graphics.UserInterface @@ -13,6 +15,9 @@ namespace osu.Game.Graphics.UserInterface [Description("button")] Button, + [Description("button-sidebar")] + ButtonSidebar, + [Description("toolbar")] Toolbar, @@ -20,6 +25,12 @@ namespace osu.Game.Graphics.UserInterface TabSelect, [Description("scrolltotop")] - ScrollToTop + ScrollToTop, + + [Description("dialog-cancel")] + DialogCancel, + + [Description("dialog-ok")] + DialogOk } } diff --git a/osu.Game/Graphics/UserInterface/HoverSounds.cs b/osu.Game/Graphics/UserInterface/HoverSounds.cs index 7db1efc75f..012594b404 100644 --- a/osu.Game/Graphics/UserInterface/HoverSounds.cs +++ b/osu.Game/Graphics/UserInterface/HoverSounds.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -14,7 +16,7 @@ namespace osu.Game.Graphics.UserInterface /// Adds hover sounds to a drawable. /// Does not draw anything. /// - public class HoverSounds : HoverSampleDebounceComponent + public partial class HoverSounds : HoverSampleDebounceComponent { private Sample sampleHover; diff --git a/osu.Game/Graphics/UserInterface/IconButton.cs b/osu.Game/Graphics/UserInterface/IconButton.cs index 858f517985..47f06715b5 100644 --- a/osu.Game/Graphics/UserInterface/IconButton.cs +++ b/osu.Game/Graphics/UserInterface/IconButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK; using osuTK.Graphics; using osu.Framework.Graphics; @@ -9,7 +11,7 @@ using osu.Framework.Input.Events; namespace osu.Game.Graphics.UserInterface { - public class IconButton : OsuAnimatedButton + public partial class IconButton : OsuAnimatedButton { public const float DEFAULT_BUTTON_SIZE = 30; diff --git a/osu.Game/Graphics/UserInterface/LineGraph.cs b/osu.Game/Graphics/UserInterface/LineGraph.cs index 70db26c817..18c022818f 100644 --- a/osu.Game/Graphics/UserInterface/LineGraph.cs +++ b/osu.Game/Graphics/UserInterface/LineGraph.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -13,7 +15,7 @@ using osuTK.Graphics; namespace osu.Game.Graphics.UserInterface { - public class LineGraph : Container + public partial class LineGraph : Container { /// /// Manually set the max value, otherwise will be used. diff --git a/osu.Game/Graphics/UserInterface/LoadingButton.cs b/osu.Game/Graphics/UserInterface/LoadingButton.cs index a75cf0639c..8a841ffc94 100644 --- a/osu.Game/Graphics/UserInterface/LoadingButton.cs +++ b/osu.Game/Graphics/UserInterface/LoadingButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,7 +12,7 @@ using osuTK; namespace osu.Game.Graphics.UserInterface { - public abstract class LoadingButton : OsuHoverContainer + public abstract partial class LoadingButton : OsuHoverContainer { private bool isLoading; @@ -39,6 +41,7 @@ namespace osu.Game.Graphics.UserInterface private readonly LoadingSpinner loading; protected LoadingButton() + : base(HoverSampleSet.Button) { Add(loading = new LoadingSpinner { diff --git a/osu.Game/Graphics/UserInterface/LoadingLayer.cs b/osu.Game/Graphics/UserInterface/LoadingLayer.cs index 47ba5fce4d..9059b61a33 100644 --- a/osu.Game/Graphics/UserInterface/LoadingLayer.cs +++ b/osu.Game/Graphics/UserInterface/LoadingLayer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using JetBrains.Annotations; using osu.Framework.Graphics; @@ -16,8 +18,10 @@ namespace osu.Game.Graphics.UserInterface /// Also optionally dims target elements. /// Useful for disabling all elements in a form and showing we are waiting on a response, for instance. /// - public class LoadingLayer : LoadingSpinner + public partial class LoadingLayer : LoadingSpinner { + private readonly bool blockInput; + [CanBeNull] protected Box BackgroundDimLayer { get; } @@ -26,9 +30,11 @@ namespace osu.Game.Graphics.UserInterface /// /// Whether the full background area should be dimmed while loading. /// Whether the spinner should have a surrounding black box for visibility. - public LoadingLayer(bool dimBackground = false, bool withBox = true) + /// Whether to block input of components behind the loading layer. + public LoadingLayer(bool dimBackground = false, bool withBox = true, bool blockInput = true) : base(withBox) { + this.blockInput = blockInput; RelativeSizeAxes = Axes.Both; Size = new Vector2(1); @@ -50,15 +56,18 @@ namespace osu.Game.Graphics.UserInterface protected override bool Handle(UIEvent e) { + if (!blockInput) + return false; + switch (e) { // blocking scroll can cause weird behaviour when this layer is used within a ScrollContainer. - case ScrollEvent _: + case ScrollEvent: return false; // blocking touch events causes the ISourcedFromTouch versions to not be fired, potentially impeding behaviour of drawables *above* the loading layer that may utilise these. // note that this will not work well if touch handling elements are beneath this loading layer (something to consider for the future). - case TouchEvent _: + case TouchEvent: return false; } @@ -81,7 +90,7 @@ namespace osu.Game.Graphics.UserInterface { base.Update(); - MainContents.Size = new Vector2(Math.Clamp(Math.Min(DrawWidth, DrawHeight) * 0.25f, 30, 100)); + MainContents.Size = new Vector2(Math.Clamp(Math.Min(DrawWidth, DrawHeight) * 0.25f, 20, 100)); } } } diff --git a/osu.Game/Graphics/UserInterface/LoadingSpinner.cs b/osu.Game/Graphics/UserInterface/LoadingSpinner.cs index 8174c4d5fe..0ea44dfe49 100644 --- a/osu.Game/Graphics/UserInterface/LoadingSpinner.cs +++ b/osu.Game/Graphics/UserInterface/LoadingSpinner.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -13,7 +15,7 @@ namespace osu.Game.Graphics.UserInterface /// /// A loading spinner. /// - public class LoadingSpinner : VisibilityContainer + public partial class LoadingSpinner : VisibilityContainer { private readonly SpriteIcon spinner; diff --git a/osu.Game/Graphics/UserInterface/MenuItemType.cs b/osu.Game/Graphics/UserInterface/MenuItemType.cs index 0269f2cb57..1eb45d6b1c 100644 --- a/osu.Game/Graphics/UserInterface/MenuItemType.cs +++ b/osu.Game/Graphics/UserInterface/MenuItemType.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Graphics.UserInterface { public enum MenuItemType diff --git a/osu.Game/Graphics/UserInterface/Nub.cs b/osu.Game/Graphics/UserInterface/Nub.cs index 8f0fed580f..28a2eb40c3 100644 --- a/osu.Game/Graphics/UserInterface/Nub.cs +++ b/osu.Game/Graphics/UserInterface/Nub.cs @@ -1,8 +1,7 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; -using JetBrains.Annotations; using osuTK; using osuTK.Graphics; using osu.Framework.Allocation; @@ -17,7 +16,7 @@ using osu.Game.Overlays; namespace osu.Game.Graphics.UserInterface { - public class Nub : CompositeDrawable, IHasCurrentValue, IHasAccentColour + public partial class Nub : Container, IHasCurrentValue, IHasAccentColour { public const float HEIGHT = 15; @@ -25,9 +24,6 @@ namespace osu.Game.Graphics.UserInterface private const float border_width = 3; - private const double animate_in_duration = 200; - private const double animate_out_duration = 500; - private readonly Box fill; private readonly Container main; @@ -59,7 +55,7 @@ namespace osu.Game.Graphics.UserInterface } [BackgroundDependencyLoader(true)] - private void load([CanBeNull] OverlayColourProvider colourProvider, OsuColour colours) + private void load(OverlayColourProvider? colourProvider, OsuColour colours) { AccentColour = colourProvider?.Highlight1 ?? colours.Pink; GlowingAccentColour = colourProvider?.Highlight1.Lighten(0.2f) ?? colours.PinkLighter; @@ -70,7 +66,7 @@ namespace osu.Game.Graphics.UserInterface Colour = GlowColour.Opacity(0), Type = EdgeEffectType.Glow, Radius = 8, - Roundness = 5, + Roundness = 4, }; } @@ -92,13 +88,18 @@ namespace osu.Game.Graphics.UserInterface if (value) { - main.FadeColour(GlowingAccentColour, animate_in_duration, Easing.OutQuint); - main.FadeEdgeEffectTo(0.2f, animate_in_duration, Easing.OutQuint); + main.FadeColour(GlowingAccentColour.Lighten(0.5f), 40, Easing.OutQuint) + .Then() + .FadeColour(GlowingAccentColour, 800, Easing.OutQuint); + + main.FadeEdgeEffectTo(Color4.White.Opacity(0.1f), 40, Easing.OutQuint) + .Then() + .FadeEdgeEffectTo(GlowColour.Opacity(0.1f), 800, Easing.OutQuint); } else { - main.FadeEdgeEffectTo(0, animate_out_duration, Easing.OutQuint); - main.FadeColour(AccentColour, animate_out_duration, Easing.OutQuint); + main.FadeEdgeEffectTo(GlowColour.Opacity(0), 800, Easing.OutQuint); + main.FadeColour(AccentColour, 800, Easing.OutQuint); } } } @@ -110,8 +111,7 @@ namespace osu.Game.Graphics.UserInterface get => current; set { - if (value == null) - throw new ArgumentNullException(nameof(value)); + ArgumentNullException.ThrowIfNull(value); current.UnbindBindings(); current.BindTo(value); @@ -161,14 +161,20 @@ namespace osu.Game.Graphics.UserInterface private void onCurrentValueChanged(ValueChangedEvent filled) { - fill.FadeTo(filled.NewValue ? 1 : 0, 200, Easing.OutQuint); + const double duration = 200; + + fill.FadeTo(filled.NewValue ? 1 : 0, duration, Easing.OutQuint); if (filled.NewValue) - main.ResizeWidthTo(1, animate_in_duration, Easing.OutElasticHalf); + { + main.ResizeWidthTo(1, duration, Easing.OutElasticHalf); + main.TransformTo(nameof(BorderThickness), 8.5f, duration, Easing.OutElasticHalf); + } else - main.ResizeWidthTo(0.9f, animate_out_duration, Easing.OutElastic); - - main.TransformTo(nameof(BorderThickness), filled.NewValue ? 8.5f : border_width, 200, Easing.OutQuint); + { + main.ResizeWidthTo(0.75f, duration, Easing.OutQuint); + main.TransformTo(nameof(BorderThickness), border_width, duration, Easing.OutQuint); + } } } } diff --git a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs index 13b42c0f13..5ef590d253 100644 --- a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs +++ b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -16,7 +18,7 @@ namespace osu.Game.Graphics.UserInterface /// /// Highlight on hover, bounce on click. /// - public class OsuAnimatedButton : OsuClickableContainer + public partial class OsuAnimatedButton : OsuClickableContainer { /// /// The colour that should be flashed when the is clicked. diff --git a/osu.Game/Graphics/UserInterface/OsuButton.cs b/osu.Game/Graphics/UserInterface/OsuButton.cs index 08514d94c3..6467ae5783 100644 --- a/osu.Game/Graphics/UserInterface/OsuButton.cs +++ b/osu.Game/Graphics/UserInterface/OsuButton.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; +using osuTK; using osuTK.Graphics; namespace osu.Game.Graphics.UserInterface @@ -18,16 +19,12 @@ namespace osu.Game.Graphics.UserInterface /// /// A button with added default sound effects. /// - public class OsuButton : Button + public abstract partial class OsuButton : Button { public LocalisableString Text { - get => SpriteText?.Text ?? default; - set - { - if (SpriteText != null) - SpriteText.Text = value; - } + get => SpriteText.Text; + set => SpriteText.Text = value; } private Color4? backgroundColour; @@ -35,7 +32,7 @@ namespace osu.Game.Graphics.UserInterface /// /// Sets a custom background colour to this button, replacing the provided default. /// - public Color4 BackgroundColour + public virtual Color4 BackgroundColour { get => backgroundColour ?? defaultBackgroundColour; set @@ -64,11 +61,19 @@ namespace osu.Game.Graphics.UserInterface protected override Container Content { get; } + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => + // base call is checked for cases when `OsuClickableContainer` has masking applied to it directly (ie. externally in object initialisation). + base.ReceivePositionalInputAt(screenSpacePos) + // Implementations often apply masking / edge rounding at a content level, so it's imperative to check that as well. + && Content.ReceivePositionalInputAt(screenSpacePos); + protected Box Hover; protected Box Background; protected SpriteText SpriteText; - public OsuButton(HoverSampleSet? hoverSounds = HoverSampleSet.Button) + private readonly Box flashLayer; + + protected OsuButton(HoverSampleSet? hoverSounds = HoverSampleSet.Button) { Height = 40; @@ -86,6 +91,7 @@ namespace osu.Game.Graphics.UserInterface Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue, }, Hover = new Box { @@ -93,16 +99,24 @@ namespace osu.Game.Graphics.UserInterface Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - Colour = Color4.White.Opacity(.1f), + Colour = Color4.White, Blending = BlendingParameters.Additive, Depth = float.MinValue }, SpriteText = CreateText(), + flashLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Depth = float.MinValue, + Colour = Color4.White.Opacity(0.5f), + Alpha = 0, + }, } }); if (hoverSounds.HasValue) - AddInternal(new HoverClickSounds(hoverSounds.Value)); + AddInternal(new HoverClickSounds(hoverSounds.Value) { Enabled = { BindTarget = Enabled } }); } [BackgroundDependencyLoader] @@ -124,15 +138,21 @@ namespace osu.Game.Graphics.UserInterface protected override bool OnClick(ClickEvent e) { if (Enabled.Value) - Background.FlashColour(BackgroundColour.Lighten(0.4f), 200); + flashLayer.FadeOutFromOne(800, Easing.OutQuint); return base.OnClick(e); } + protected virtual float HoverLayerFinalAlpha => 0.1f; + protected override bool OnHover(HoverEvent e) { if (Enabled.Value) - Hover.FadeIn(200, Easing.OutQuint); + { + Hover.FadeTo(0.2f, 40, Easing.OutQuint) + .Then() + .FadeTo(HoverLayerFinalAlpha, 800, Easing.OutQuint); + } return base.OnHover(e); } @@ -141,7 +161,7 @@ namespace osu.Game.Graphics.UserInterface { base.OnHoverLost(e); - Hover.FadeOut(300); + Hover.FadeOut(800, Easing.OutQuint); } protected override bool OnMouseDown(MouseDownEvent e) diff --git a/osu.Game/Graphics/UserInterface/OsuCheckbox.cs b/osu.Game/Graphics/UserInterface/OsuCheckbox.cs index da511d8212..160105af1a 100644 --- a/osu.Game/Graphics/UserInterface/OsuCheckbox.cs +++ b/osu.Game/Graphics/UserInterface/OsuCheckbox.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -13,7 +15,7 @@ using osu.Game.Graphics.Containers; namespace osu.Game.Graphics.UserInterface { - public class OsuCheckbox : Checkbox + public partial class OsuCheckbox : Checkbox { /// /// Whether to play sounds when the state changes as a result of user interaction. @@ -24,24 +26,24 @@ namespace osu.Game.Graphics.UserInterface { set { - if (labelText != null) - labelText.Text = value; + if (LabelTextFlowContainer != null) + LabelTextFlowContainer.Text = value; } } public MarginPadding LabelPadding { - get => labelText?.Padding ?? new MarginPadding(); + get => LabelTextFlowContainer?.Padding ?? new MarginPadding(); set { - if (labelText != null) - labelText.Padding = value; + if (LabelTextFlowContainer != null) + LabelTextFlowContainer.Padding = value; } } protected readonly Nub Nub; - private readonly OsuTextFlowContainer labelText; + protected readonly OsuTextFlowContainer LabelTextFlowContainer; private Sample sampleChecked; private Sample sampleUnchecked; @@ -54,7 +56,7 @@ namespace osu.Game.Graphics.UserInterface Children = new Drawable[] { - labelText = new OsuTextFlowContainer(ApplyLabelParameters) + LabelTextFlowContainer = new OsuTextFlowContainer(ApplyLabelParameters) { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, @@ -68,19 +70,19 @@ namespace osu.Game.Graphics.UserInterface Nub.Anchor = Anchor.CentreRight; Nub.Origin = Anchor.CentreRight; Nub.Margin = new MarginPadding { Right = nub_padding }; - labelText.Padding = new MarginPadding { Right = Nub.EXPANDED_SIZE + nub_padding * 2 }; + LabelTextFlowContainer.Padding = new MarginPadding { Right = Nub.EXPANDED_SIZE + nub_padding * 2 }; } else { Nub.Anchor = Anchor.CentreLeft; Nub.Origin = Anchor.CentreLeft; Nub.Margin = new MarginPadding { Left = nub_padding }; - labelText.Padding = new MarginPadding { Left = Nub.EXPANDED_SIZE + nub_padding * 2 }; + LabelTextFlowContainer.Padding = new MarginPadding { Left = Nub.EXPANDED_SIZE + nub_padding * 2 }; } Nub.Current.BindTo(Current); - Current.DisabledChanged += disabled => labelText.Alpha = Nub.Alpha = disabled ? 0.3f : 1; + Current.DisabledChanged += disabled => LabelTextFlowContainer.Alpha = Nub.Alpha = disabled ? 0.3f : 1; } /// diff --git a/osu.Game/Graphics/UserInterface/OsuContextMenu.cs b/osu.Game/Graphics/UserInterface/OsuContextMenu.cs index e0946fd9e1..1b5f7cc4b5 100644 --- a/osu.Game/Graphics/UserInterface/OsuContextMenu.cs +++ b/osu.Game/Graphics/UserInterface/OsuContextMenu.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -10,7 +12,7 @@ using osu.Framework.Graphics.UserInterface; namespace osu.Game.Graphics.UserInterface { - public class OsuContextMenu : OsuMenu + public partial class OsuContextMenu : OsuMenu { private const int fade_duration = 250; diff --git a/osu.Game/Graphics/UserInterface/OsuContextMenuSamples.cs b/osu.Game/Graphics/UserInterface/OsuContextMenuSamples.cs index 921fef7951..6d7543c472 100644 --- a/osu.Game/Graphics/UserInterface/OsuContextMenuSamples.cs +++ b/osu.Game/Graphics/UserInterface/OsuContextMenuSamples.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -9,7 +11,7 @@ using osu.Framework.Graphics; namespace osu.Game.Graphics.UserInterface { - public class OsuContextMenuSamples : Component + public partial class OsuContextMenuSamples : Component { private Sample sampleClick; private Sample sampleOpen; diff --git a/osu.Game/Graphics/UserInterface/OsuDropdown.cs b/osu.Game/Graphics/UserInterface/OsuDropdown.cs index 23e05c7ccc..3230bb0569 100644 --- a/osu.Game/Graphics/UserInterface/OsuDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuDropdown.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 enable - using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -24,7 +22,7 @@ using osuTK.Graphics; namespace osu.Game.Graphics.UserInterface { - public class OsuDropdown : Dropdown + public partial class OsuDropdown : Dropdown { private const float corner_radius = 5; @@ -34,7 +32,7 @@ namespace osu.Game.Graphics.UserInterface #region OsuDropdownMenu - protected class OsuDropdownMenu : DropdownMenu, IKeyBindingHandler + protected partial class OsuDropdownMenu : DropdownMenu, IKeyBindingHandler { public override bool HandleNonPositionalInput => State == MenuState.Open; @@ -137,7 +135,7 @@ namespace osu.Game.Graphics.UserInterface #region DrawableOsuDropdownMenuItem - public class DrawableOsuDropdownMenuItem : DrawableDropdownMenuItem + public partial class DrawableOsuDropdownMenuItem : DrawableDropdownMenuItem { // IsHovered is used public override bool HandlePositionalInput => true; @@ -205,7 +203,7 @@ namespace osu.Game.Graphics.UserInterface protected override Drawable CreateContent() => new Content(); - protected new class Content : CompositeDrawable, IHasText + protected new partial class Content : CompositeDrawable, IHasText { public LocalisableString Text { @@ -299,7 +297,7 @@ namespace osu.Game.Graphics.UserInterface #endregion - public class OsuDropdownHeader : DropdownHeader + public partial class OsuDropdownHeader : DropdownHeader { protected readonly SpriteText Text; diff --git a/osu.Game/Graphics/UserInterface/OsuEnumDropdown.cs b/osu.Game/Graphics/UserInterface/OsuEnumDropdown.cs index 528d7d60f8..dc089e3410 100644 --- a/osu.Game/Graphics/UserInterface/OsuEnumDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuEnumDropdown.cs @@ -1,16 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; namespace osu.Game.Graphics.UserInterface { - public class OsuEnumDropdown : OsuDropdown + public partial class OsuEnumDropdown : OsuDropdown where T : struct, Enum { public OsuEnumDropdown() { - Items = (T[])Enum.GetValues(typeof(T)); + Items = Enum.GetValues(); } } } diff --git a/osu.Game/Graphics/UserInterface/OsuMenu.cs b/osu.Game/Graphics/UserInterface/OsuMenu.cs index a16adcbd57..73d57af793 100644 --- a/osu.Game/Graphics/UserInterface/OsuMenu.cs +++ b/osu.Game/Graphics/UserInterface/OsuMenu.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -14,7 +16,7 @@ using osuTK; namespace osu.Game.Graphics.UserInterface { - public class OsuMenu : Menu + public partial class OsuMenu : Menu { private Sample sampleOpen; private Sample sampleClose; diff --git a/osu.Game/Graphics/UserInterface/OsuMenuItem.cs b/osu.Game/Graphics/UserInterface/OsuMenuItem.cs index 1da60415ba..20461de08f 100644 --- a/osu.Game/Graphics/UserInterface/OsuMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/OsuMenuItem.cs @@ -11,12 +11,12 @@ namespace osu.Game.Graphics.UserInterface { public readonly MenuItemType Type; - public OsuMenuItem(string text, MenuItemType type = MenuItemType.Standard) + public OsuMenuItem(LocalisableString text, MenuItemType type = MenuItemType.Standard) : this(text, type, null) { } - public OsuMenuItem(LocalisableString text, MenuItemType type, Action action) + public OsuMenuItem(LocalisableString text, MenuItemType type, Action? action) : base(text, action) { Type = type; diff --git a/osu.Game/Graphics/UserInterface/OsuNumberBox.cs b/osu.Game/Graphics/UserInterface/OsuNumberBox.cs index 8a3b77d3c2..f6a3abdaae 100644 --- a/osu.Game/Graphics/UserInterface/OsuNumberBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuNumberBox.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Extensions; namespace osu.Game.Graphics.UserInterface { - public class OsuNumberBox : OsuTextBox + public partial class OsuNumberBox : OsuTextBox { protected override bool AllowIme => false; diff --git a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs index b276159558..63c98d7838 100644 --- a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK; using osuTK.Graphics; using osuTK.Input; @@ -17,7 +19,7 @@ using osu.Framework.Platform; namespace osu.Game.Graphics.UserInterface { - public class OsuPasswordTextBox : OsuTextBox, ISuppressKeyEventLogging + public partial class OsuPasswordTextBox : OsuTextBox, ISuppressKeyEventLogging { protected override Drawable GetDrawableCharacter(char c) => new FallingDownContainer { @@ -71,7 +73,7 @@ namespace osu.Game.Graphics.UserInterface private void updateCapsWarning(bool visible) => warning.FadeTo(visible ? 1 : 0, 250, Easing.OutQuint); - public class PasswordMaskChar : Container + public partial class PasswordMaskChar : Container { private readonly CircularContainer circle; @@ -108,7 +110,7 @@ namespace osu.Game.Graphics.UserInterface } } - private class CapsWarning : SpriteIcon, IHasTooltip + private partial class CapsWarning : SpriteIcon, IHasTooltip { public LocalisableString TooltipText => "caps lock is active"; diff --git a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs index 21c8dfcfa4..0c36d73085 100644 --- a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs @@ -1,148 +1,45 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Globalization; -using JetBrains.Annotations; -using osuTK; -using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Events; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Framework.Utils; -using osu.Game.Overlays; using osu.Game.Utils; namespace osu.Game.Graphics.UserInterface { - public class OsuSliderBar : SliderBar, IHasTooltip, IHasAccentColour + public abstract partial class OsuSliderBar : SliderBar, IHasTooltip where T : struct, IEquatable, IComparable, IConvertible { - /// - /// Maximum number of decimal digits to be displayed in the tooltip. - /// - private const int max_decimal_digits = 5; - - private Sample sample; - private double lastSampleTime; - private T lastSampleValue; - - protected readonly Nub Nub; - private readonly Box leftBox; - private readonly Box rightBox; - private readonly Container nubContainer; - - public virtual LocalisableString TooltipText { get; private set; } + public bool PlaySamplesOnAdjust { get; set; } = true; /// /// Whether to format the tooltip as a percentage or the actual value. /// public bool DisplayAsPercentage { get; set; } - private Color4 accentColour; + public virtual LocalisableString TooltipText { get; private set; } - public Color4 AccentColour - { - get => accentColour; - set - { - accentColour = value; - leftBox.Colour = value; - } - } + /// + /// Maximum number of decimal digits to be displayed in the tooltip. + /// + private const int max_decimal_digits = 5; - private Colour4 backgroundColour; + private Sample sample = null!; - public Color4 BackgroundColour - { - get => backgroundColour; - set - { - backgroundColour = value; - rightBox.Colour = value; - } - } + private double lastSampleTime; + private T lastSampleValue; - public OsuSliderBar() - { - Height = Nub.HEIGHT; - RangePadding = Nub.EXPANDED_SIZE / 2; - Children = new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Padding = new MarginPadding { Horizontal = 2 }, - Child = new CircularContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Masking = true, - CornerRadius = 5f, - Children = new Drawable[] - { - leftBox = new Box - { - Height = 5, - EdgeSmoothness = new Vector2(0, 0.5f), - RelativeSizeAxes = Axes.None, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - }, - rightBox = new Box - { - Height = 5, - EdgeSmoothness = new Vector2(0, 0.5f), - RelativeSizeAxes = Axes.None, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Alpha = 0.5f, - }, - }, - }, - }, - nubContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Child = Nub = new Nub - { - Origin = Anchor.TopCentre, - RelativePositionAxes = Axes.X, - Current = { Value = true } - }, - }, - new HoverClickSounds() - }; - - Current.DisabledChanged += disabled => { Alpha = disabled ? 0.3f : 1; }; - } - - [BackgroundDependencyLoader(true)] - private void load(AudioManager audio, [CanBeNull] OverlayColourProvider colourProvider, OsuColour colours) + [BackgroundDependencyLoader] + private void load(AudioManager audio) { sample = audio.Samples.Get(@"UI/notch-tick"); - AccentColour = colourProvider?.Highlight1 ?? colours.Pink; - BackgroundColour = colourProvider?.Background5 ?? colours.Pink.Opacity(0.5f); - } - - protected override void Update() - { - base.Update(); - - nubContainer.Padding = new MarginPadding { Horizontal = RangePadding }; } protected override void LoadComplete() @@ -151,38 +48,20 @@ namespace osu.Game.Graphics.UserInterface CurrentNumber.BindValueChanged(current => TooltipText = getTooltipText(current.NewValue), true); } - protected override bool OnHover(HoverEvent e) - { - updateGlow(); - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - updateGlow(); - base.OnHoverLost(e); - } - - protected override void OnDragEnd(DragEndEvent e) - { - updateGlow(); - base.OnDragEnd(e); - } - - private void updateGlow() - { - Nub.Glowing = IsHovered || IsDragged; - } - protected override void OnUserChange(T value) { base.OnUserChange(value); + playSample(value); + TooltipText = getTooltipText(value); } private void playSample(T value) { + if (!PlaySamplesOnAdjust) + return; + if (Clock == null || Clock.CurrentTime - lastSampleTime <= 30) return; @@ -221,20 +100,6 @@ namespace osu.Game.Graphics.UserInterface return floatValue.ToString($"N{significantDigits}"); } - protected override void UpdateAfterChildren() - { - base.UpdateAfterChildren(); - leftBox.Scale = new Vector2(Math.Clamp( - RangePadding + Nub.DrawPosition.X - Nub.DrawWidth / 2, 0, DrawWidth), 1); - rightBox.Scale = new Vector2(Math.Clamp( - DrawWidth - Nub.DrawPosition.X - RangePadding - Nub.DrawWidth / 2, 0, DrawWidth), 1); - } - - protected override void UpdateValue(float value) - { - Nub.MoveToX(value, 250, Easing.OutQuint); - } - /// /// Removes all non-significant digits, keeping at most a requested number of decimal digits. /// diff --git a/osu.Game/Graphics/UserInterface/OsuTabControl.cs b/osu.Game/Graphics/UserInterface/OsuTabControl.cs index 3572ea5c31..05309760e7 100644 --- a/osu.Game/Graphics/UserInterface/OsuTabControl.cs +++ b/osu.Game/Graphics/UserInterface/OsuTabControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using osuTK; @@ -20,7 +22,7 @@ using osu.Game.Graphics.Sprites; namespace osu.Game.Graphics.UserInterface { - public class OsuTabControl : TabControl + public partial class OsuTabControl : TabControl { private Color4 accentColour; @@ -96,7 +98,7 @@ namespace osu.Game.Graphics.UserInterface strip.Width = Interpolation.ValueAt(Math.Clamp(Clock.ElapsedFrameTime, 0, 1000), strip.Width, StripWidth, 0, 500, Easing.OutQuint); } - public class OsuTabItem : TabItem, IHasAccentColour + public partial class OsuTabItem : TabItem, IHasAccentColour { protected readonly SpriteText Text; protected readonly Box Bar; diff --git a/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs b/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs index c6121dcd17..fa58ae27f2 100644 --- a/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs +++ b/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK; using osuTK.Graphics; using osu.Framework.Allocation; @@ -20,7 +22,7 @@ namespace osu.Game.Graphics.UserInterface /// /// A Checkbox styled to be placed in line with an /// - public class OsuTabControlCheckbox : Checkbox + public partial class OsuTabControlCheckbox : Checkbox { private readonly Box box; private readonly SpriteText text; diff --git a/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs b/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs index b7e25ae4e7..01d072b6d7 100644 --- a/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK; using osuTK.Graphics; using osu.Framework.Extensions.Color4Extensions; @@ -11,7 +13,7 @@ using osu.Framework.Input.Events; namespace osu.Game.Graphics.UserInterface { - public class OsuTabDropdown : OsuDropdown, IHasAccentColour + public partial class OsuTabDropdown : OsuDropdown, IHasAccentColour { private Color4 accentColour; @@ -58,7 +60,7 @@ namespace osu.Game.Graphics.UserInterface tabDropdownHeader.AccentColour = accentColour; } - private class OsuTabDropdownMenu : OsuDropdownMenu + private partial class OsuTabDropdownMenu : OsuDropdownMenu { public OsuTabDropdownMenu() { @@ -71,7 +73,7 @@ namespace osu.Game.Graphics.UserInterface protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new DrawableOsuTabDropdownMenuItem(item); - private class DrawableOsuTabDropdownMenuItem : DrawableOsuDropdownMenuItem + private partial class DrawableOsuTabDropdownMenuItem : DrawableOsuDropdownMenuItem { public DrawableOsuTabDropdownMenuItem(MenuItem item) : base(item) @@ -81,7 +83,7 @@ namespace osu.Game.Graphics.UserInterface } } - protected class OsuTabDropdownHeader : OsuDropdownHeader, IHasAccentColour + protected partial class OsuTabDropdownHeader : OsuDropdownHeader, IHasAccentColour { private Color4 accentColour; diff --git a/osu.Game/Graphics/UserInterface/OsuTextBox.cs b/osu.Game/Graphics/UserInterface/OsuTextBox.cs index 6db3068d84..99803e2956 100644 --- a/osu.Game/Graphics/UserInterface/OsuTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuTextBox.cs @@ -1,8 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - +using System; +using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -24,7 +24,7 @@ using osuTK; namespace osu.Game.Graphics.UserInterface { - public class OsuTextBox : BasicTextBox + public partial class OsuTextBox : BasicTextBox { /// /// Whether to allow playing a different samples based on the type of character. @@ -42,14 +42,27 @@ namespace osu.Game.Graphics.UserInterface Margin = new MarginPadding { Left = 2 }, }; - private readonly Sample?[] textAddedSamples = new Sample[4]; - private Sample? capsTextAddedSample; - private Sample? textRemovedSample; - private Sample? textCommittedSample; - private Sample? caretMovedSample; - private OsuCaret? caret; + private bool selectionStarted; + private double sampleLastPlaybackTime; + + protected enum FeedbackSampleType + { + TextAdd, + TextAddCaps, + TextRemove, + TextConfirm, + TextInvalid, + CaretMove, + SelectCharacter, + SelectWord, + SelectAll, + Deselect + } + + private Dictionary sampleMap = new Dictionary(); + public OsuTextBox() { Height = 40; @@ -73,13 +86,23 @@ namespace osu.Game.Graphics.UserInterface Placeholder.Colour = colourProvider?.Foreground1 ?? new Color4(180, 180, 180, 255); + var textAddedSamples = new Sample?[4]; for (int i = 0; i < textAddedSamples.Length; i++) textAddedSamples[i] = audio.Samples.Get($@"Keyboard/key-press-{1 + i}"); - capsTextAddedSample = audio.Samples.Get(@"Keyboard/key-caps"); - textRemovedSample = audio.Samples.Get(@"Keyboard/key-delete"); - textCommittedSample = audio.Samples.Get(@"Keyboard/key-confirm"); - caretMovedSample = audio.Samples.Get(@"Keyboard/key-movement"); + sampleMap = new Dictionary + { + { FeedbackSampleType.TextAdd, textAddedSamples }, + { FeedbackSampleType.TextAddCaps, new[] { audio.Samples.Get(@"Keyboard/key-caps") } }, + { FeedbackSampleType.TextRemove, new[] { audio.Samples.Get(@"Keyboard/key-delete") } }, + { FeedbackSampleType.TextConfirm, new[] { audio.Samples.Get(@"Keyboard/key-confirm") } }, + { FeedbackSampleType.TextInvalid, new[] { audio.Samples.Get(@"Keyboard/key-invalid") } }, + { FeedbackSampleType.CaretMove, new[] { audio.Samples.Get(@"Keyboard/key-movement") } }, + { FeedbackSampleType.SelectCharacter, new[] { audio.Samples.Get(@"Keyboard/select-char") } }, + { FeedbackSampleType.SelectWord, new[] { audio.Samples.Get(@"Keyboard/select-word") } }, + { FeedbackSampleType.SelectAll, new[] { audio.Samples.Get(@"Keyboard/select-all") } }, + { FeedbackSampleType.Deselect, new[] { audio.Samples.Get(@"Keyboard/deselect") } } + }; } private Color4 selectionColour; @@ -90,31 +113,75 @@ namespace osu.Game.Graphics.UserInterface { base.OnUserTextAdded(added); + if (!added.Any(CanAddCharacter)) + return; + if (added.Any(char.IsUpper) && AllowUniqueCharacterSamples) - capsTextAddedSample?.Play(); + PlayFeedbackSample(FeedbackSampleType.TextAddCaps); else - playTextAddedSample(); + PlayFeedbackSample(FeedbackSampleType.TextAdd); } protected override void OnUserTextRemoved(string removed) { base.OnUserTextRemoved(removed); - textRemovedSample?.Play(); + PlayFeedbackSample(FeedbackSampleType.TextRemove); + } + + protected override void NotifyInputError() + { + base.NotifyInputError(); + + PlayFeedbackSample(FeedbackSampleType.TextInvalid); } protected override void OnTextCommitted(bool textChanged) { base.OnTextCommitted(textChanged); - textCommittedSample?.Play(); + PlayFeedbackSample(FeedbackSampleType.TextConfirm); } protected override void OnCaretMoved(bool selecting) { base.OnCaretMoved(selecting); - caretMovedSample?.Play(); + if (!selecting) + PlayFeedbackSample(FeedbackSampleType.CaretMove); + } + + protected override void OnTextSelectionChanged(TextSelectionType selectionType) + { + base.OnTextSelectionChanged(selectionType); + + switch (selectionType) + { + case TextSelectionType.Character: + PlayFeedbackSample(FeedbackSampleType.SelectCharacter); + break; + + case TextSelectionType.Word: + PlayFeedbackSample(selectionStarted ? FeedbackSampleType.SelectCharacter : FeedbackSampleType.SelectWord); + break; + + case TextSelectionType.All: + PlayFeedbackSample(FeedbackSampleType.SelectAll); + break; + } + + selectionStarted = true; + } + + protected override void OnTextDeselected() + { + base.OnTextDeselected(); + + if (!selectionStarted) return; + + PlayFeedbackSample(FeedbackSampleType.Deselect); + + selectionStarted = false; } protected override void OnImeComposition(string newComposition, int removedTextLength, int addedTextLength, bool caretMoved) @@ -131,13 +198,13 @@ namespace osu.Game.Graphics.UserInterface case 1: // composition probably ended by pressing backspace, or was cancelled. - textRemovedSample?.Play(); + PlayFeedbackSample(FeedbackSampleType.TextRemove); return; default: // longer text removed, composition ended because it was cancelled. // could be a different sample if desired. - textRemovedSample?.Play(); + PlayFeedbackSample(FeedbackSampleType.TextRemove); return; } } @@ -145,7 +212,7 @@ namespace osu.Game.Graphics.UserInterface if (addedTextLength > 0) { // some text was added, probably due to typing new text or by changing the candidate. - playTextAddedSample(); + PlayFeedbackSample(FeedbackSampleType.TextAdd); return; } @@ -153,14 +220,14 @@ namespace osu.Game.Graphics.UserInterface { // text was probably removed by backspacing. // it's also possible that a candidate that only removed text was changed to. - textRemovedSample?.Play(); + PlayFeedbackSample(FeedbackSampleType.TextRemove); return; } if (caretMoved) { // only the caret/selection was moved. - caretMovedSample?.Play(); + PlayFeedbackSample(FeedbackSampleType.CaretMove); } } @@ -171,25 +238,28 @@ namespace osu.Game.Graphics.UserInterface if (successful) { // composition was successfully completed, usually by pressing the enter key. - textCommittedSample?.Play(); + PlayFeedbackSample(FeedbackSampleType.TextConfirm); } else { // composition was prematurely ended, eg. by clicking inside the textbox. // could be a different sample if desired. - textCommittedSample?.Play(); + PlayFeedbackSample(FeedbackSampleType.TextConfirm); } } protected override void OnFocus(FocusEvent e) { - BorderThickness = 3; + if (Masking) + BorderThickness = 3; + base.OnFocus(e); } protected override void OnFocusLost(FocusLostEvent e) { - BorderThickness = 0; + if (Masking) + BorderThickness = 0; base.OnFocusLost(e); } @@ -206,9 +276,36 @@ namespace osu.Game.Graphics.UserInterface SelectionColour = SelectionColour, }; - private void playTextAddedSample() => textAddedSamples[RNG.Next(0, textAddedSamples.Length)]?.Play(); + private SampleChannel? getSampleChannel(FeedbackSampleType feedbackSampleType) + { + var samples = sampleMap[feedbackSampleType]; - private class OsuCaret : Caret + if (samples.Length == 0) + return null; + + return samples[RNG.Next(0, samples.Length)]?.GetChannel(); + } + + protected void PlayFeedbackSample(FeedbackSampleType feedbackSample) => Schedule(() => + { + if (Time.Current < sampleLastPlaybackTime + 15) return; + + SampleChannel? channel = getSampleChannel(feedbackSample); + + if (channel == null) return; + + double pitch = 0.98 + RNG.NextDouble(0.04); + + if (feedbackSample == FeedbackSampleType.SelectCharacter) + pitch += ((double)SelectedText.Length / Math.Max(1, Text.Length)) * 0.15f; + + channel.Frequency.Value = pitch; + channel.Play(); + + sampleLastPlaybackTime = Time.Current; + }); + + private partial class OsuCaret : Caret { private const float caret_move_time = 60; @@ -255,7 +352,7 @@ namespace osu.Game.Graphics.UserInterface } } - private class CaretBeatSyncedContainer : BeatSyncedContainer + private partial class CaretBeatSyncedContainer : BeatSyncedContainer { private bool hasSelection; diff --git a/osu.Game/Graphics/UserInterface/PageSelector/PageEllipsis.cs b/osu.Game/Graphics/UserInterface/PageSelector/PageEllipsis.cs index d73d9f5824..068e477d79 100644 --- a/osu.Game/Graphics/UserInterface/PageSelector/PageEllipsis.cs +++ b/osu.Game/Graphics/UserInterface/PageSelector/PageEllipsis.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -9,7 +11,7 @@ using osu.Game.Overlays; namespace osu.Game.Graphics.UserInterface.PageSelector { - internal class PageEllipsis : CompositeDrawable + internal partial class PageEllipsis : CompositeDrawable { [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) diff --git a/osu.Game/Graphics/UserInterface/PageSelector/PageSelector.cs b/osu.Game/Graphics/UserInterface/PageSelector/PageSelector.cs index 5c6d087279..63f35d5f89 100644 --- a/osu.Game/Graphics/UserInterface/PageSelector/PageSelector.cs +++ b/osu.Game/Graphics/UserInterface/PageSelector/PageSelector.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics; @@ -9,7 +11,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Graphics.UserInterface.PageSelector { - public class PageSelector : CompositeDrawable + public partial class PageSelector : CompositeDrawable { public readonly BindableInt CurrentPage = new BindableInt { MinValue = 0, }; diff --git a/osu.Game/Graphics/UserInterface/PageSelector/PageSelectorButton.cs b/osu.Game/Graphics/UserInterface/PageSelector/PageSelectorButton.cs index a2c6e8532b..9388f045d3 100644 --- a/osu.Game/Graphics/UserInterface/PageSelector/PageSelectorButton.cs +++ b/osu.Game/Graphics/UserInterface/PageSelector/PageSelectorButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Containers; using osu.Framework.Graphics; using osu.Framework.Allocation; @@ -12,7 +14,7 @@ using osu.Game.Overlays; namespace osu.Game.Graphics.UserInterface.PageSelector { - public abstract class PageSelectorButton : OsuClickableContainer + public abstract partial class PageSelectorButton : OsuClickableContainer { protected const int DURATION = 200; diff --git a/osu.Game/Graphics/UserInterface/PageSelector/PageSelectorPageButton.cs b/osu.Game/Graphics/UserInterface/PageSelector/PageSelectorPageButton.cs index 247a003492..3e5d1fac6d 100644 --- a/osu.Game/Graphics/UserInterface/PageSelector/PageSelectorPageButton.cs +++ b/osu.Game/Graphics/UserInterface/PageSelector/PageSelectorPageButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Bindables; using osu.Framework.Allocation; @@ -8,7 +10,7 @@ using osu.Game.Graphics.Sprites; namespace osu.Game.Graphics.UserInterface.PageSelector { - public class PageSelectorPageButton : PageSelectorButton + public partial class PageSelectorPageButton : PageSelectorButton { private readonly BindableBool selected = new BindableBool(); diff --git a/osu.Game/Graphics/UserInterface/PageSelector/PageSelectorPrevNextButton.cs b/osu.Game/Graphics/UserInterface/PageSelector/PageSelectorPrevNextButton.cs index 889917c397..e5e0dab2f0 100644 --- a/osu.Game/Graphics/UserInterface/PageSelector/PageSelectorPrevNextButton.cs +++ b/osu.Game/Graphics/UserInterface/PageSelector/PageSelectorPrevNextButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osuTK; namespace osu.Game.Graphics.UserInterface.PageSelector { - public class PageSelectorPrevNextButton : PageSelectorButton + public partial class PageSelectorPrevNextButton : PageSelectorButton { private readonly bool rightAligned; private readonly LocalisableString text; diff --git a/osu.Game/Graphics/UserInterface/PageTabControl.cs b/osu.Game/Graphics/UserInterface/PageTabControl.cs index a218c7bf52..2fe8acfbd5 100644 --- a/osu.Game/Graphics/UserInterface/PageTabControl.cs +++ b/osu.Game/Graphics/UserInterface/PageTabControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osuTK; using osuTK.Graphics; @@ -16,7 +18,7 @@ using osu.Game.Graphics.Sprites; namespace osu.Game.Graphics.UserInterface { - public class PageTabControl : OsuTabControl + public partial class PageTabControl : OsuTabControl { protected override TabItem CreateTabItem(T value) => new PageTabItem(value); @@ -31,7 +33,7 @@ namespace osu.Game.Graphics.UserInterface AccentColour = colours.Yellow; } - public class PageTabItem : TabItem, IHasAccentColour + public partial class PageTabItem : TabItem, IHasAccentColour { private const float transition_duration = 100; diff --git a/osu.Game/Graphics/UserInterface/PercentageCounter.cs b/osu.Game/Graphics/UserInterface/PercentageCounter.cs index 0ebf2849fe..de93d9b2b4 100644 --- a/osu.Game/Graphics/UserInterface/PercentageCounter.cs +++ b/osu.Game/Graphics/UserInterface/PercentageCounter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics; using osu.Framework.Localisation; @@ -12,7 +14,7 @@ namespace osu.Game.Graphics.UserInterface /// /// Used as an accuracy counter. Represented visually as a percentage. /// - public class PercentageCounter : RollingCounter + public partial class PercentageCounter : RollingCounter { protected override double RollingDuration => 750; diff --git a/osu.Game/Graphics/UserInterface/ProgressBar.cs b/osu.Game/Graphics/UserInterface/ProgressBar.cs index 50367e600e..8f383c76db 100644 --- a/osu.Game/Graphics/UserInterface/ProgressBar.cs +++ b/osu.Game/Graphics/UserInterface/ProgressBar.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; @@ -9,7 +11,7 @@ using osuTK.Graphics; namespace osu.Game.Graphics.UserInterface { - public class ProgressBar : SliderBar + public partial class ProgressBar : SliderBar { public Action OnSeek; diff --git a/osu.Game/Graphics/UserInterface/RangeSlider.cs b/osu.Game/Graphics/UserInterface/RangeSlider.cs new file mode 100644 index 0000000000..f83dff6295 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/RangeSlider.cs @@ -0,0 +1,212 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterface +{ + public partial class RangeSlider : CompositeDrawable + { + /// + /// The lower limiting value + /// + public Bindable LowerBound + { + get => lowerBound.Current; + set => lowerBound.Current = value; + } + + /// + /// The upper limiting value + /// + public Bindable UpperBound + { + get => upperBound.Current; + set => upperBound.Current = value; + } + + /// + /// Text that describes this RangeSlider's functionality + /// + public string Label + { + set => label.Text = value; + } + + public float NubWidth + { + set => lowerBound.NubWidth = upperBound.NubWidth = value; + } + + /// + /// Minimum difference between the lower bound and higher bound + /// + public float MinRange + { + set => minRange = value; + } + + /// + /// lower bound display for when it is set to its default value + /// + public string DefaultStringLowerBound + { + set => lowerBound.DefaultString = value; + } + + /// + /// upper bound display for when it is set to its default value + /// + public string DefaultStringUpperBound + { + set => upperBound.DefaultString = value; + } + + public LocalisableString DefaultTooltipLowerBound + { + set => lowerBound.DefaultTooltip = value; + } + + public LocalisableString DefaultTooltipUpperBound + { + set => upperBound.DefaultTooltip = value; + } + + public string TooltipSuffix + { + set => upperBound.TooltipSuffix = lowerBound.TooltipSuffix = value; + } + + private float minRange = 0.1f; + + private readonly OsuSpriteText label; + + private readonly LowerBoundSlider lowerBound; + private readonly UpperBoundSlider upperBound; + + public RangeSlider() + { + const float vertical_offset = 13; + + InternalChildren = new Drawable[] + { + label = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 14), + }, + upperBound = new UpperBoundSlider + { + KeyboardStep = 0.1f, + RelativeSizeAxes = Axes.X, + Y = vertical_offset, + }, + lowerBound = new LowerBoundSlider + { + KeyboardStep = 0.1f, + RelativeSizeAxes = Axes.X, + Y = vertical_offset, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + lowerBound.Current.ValueChanged += min => upperBound.Current.Value = Math.Max(min.NewValue + minRange, upperBound.Current.Value); + upperBound.Current.ValueChanged += max => lowerBound.Current.Value = Math.Min(max.NewValue - minRange, lowerBound.Current.Value); + } + + private partial class LowerBoundSlider : BoundSlider + { + protected override void LoadComplete() + { + base.LoadComplete(); + + LeftBox.Height = 6; // hide any colour bleeding from overlap + + AccentColour = BackgroundColour; + BackgroundColour = Color4.Transparent; + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => + base.ReceivePositionalInputAt(screenSpacePos) + && screenSpacePos.X <= Nub.ScreenSpaceDrawQuad.TopRight.X; + } + + private partial class UpperBoundSlider : BoundSlider + { + protected override void LoadComplete() + { + base.LoadComplete(); + + RightBox.Height = 6; // just to match the left bar height really + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => + base.ReceivePositionalInputAt(screenSpacePos) + && screenSpacePos.X >= Nub.ScreenSpaceDrawQuad.TopLeft.X; + } + + protected partial class BoundSlider : RoundedSliderBar + { + public string? DefaultString; + public LocalisableString? DefaultTooltip; + public string? TooltipSuffix; + public float NubWidth { get; set; } = Nub.HEIGHT; + + public override LocalisableString TooltipText => + (Current.IsDefault ? DefaultTooltip : Current.Value.ToString($@"0.## {TooltipSuffix}")) ?? Current.Value.ToString($@"0.## {TooltipSuffix}"); + + protected override bool OnHover(HoverEvent e) + { + base.OnHover(e); + return true; // Make sure only one nub shows hover effect at once. + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Nub.Width = NubWidth; + RangePadding = Nub.Width / 2; + + OsuSpriteText currentDisplay; + + Nub.Add(currentDisplay = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Y = -0.5f, + Colour = Color4.White, + Font = OsuFont.Torus.With(size: 10), + }); + + Current.BindValueChanged(current => + { + currentDisplay.Text = (current.NewValue != Current.Default ? current.NewValue.ToString("N1") : DefaultString) ?? current.NewValue.ToString("N1"); + }, true); + } + + [BackgroundDependencyLoader(true)] + private void load(OverlayColourProvider? colourProvider) + { + if (colourProvider == null) return; + + AccentColour = colourProvider.Background2; + Nub.AccentColour = colourProvider.Background2; + Nub.GlowingAccentColour = colourProvider.Background1; + Nub.GlowColour = colourProvider.Background2; + } + } + } +} diff --git a/osu.Game/Graphics/UserInterface/RollingCounter.cs b/osu.Game/Graphics/UserInterface/RollingCounter.cs index 16555075d1..b80c0e3b58 100644 --- a/osu.Game/Graphics/UserInterface/RollingCounter.cs +++ b/osu.Game/Graphics/UserInterface/RollingCounter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -14,7 +16,7 @@ using osu.Framework.Localisation; namespace osu.Game.Graphics.UserInterface { - public abstract class RollingCounter : Container, IHasCurrentValue + public abstract partial class RollingCounter : Container, IHasCurrentValue where T : struct, IEquatable { private readonly BindableWithCurrent current = new BindableWithCurrent(); diff --git a/osu.Game/Graphics/UserInterface/RoundedSliderBar.cs b/osu.Game/Graphics/UserInterface/RoundedSliderBar.cs new file mode 100644 index 0000000000..a666b83c05 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/RoundedSliderBar.cs @@ -0,0 +1,170 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osuTK; +using osuTK.Graphics; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Overlays; + +namespace osu.Game.Graphics.UserInterface +{ + public partial class RoundedSliderBar : OsuSliderBar + where T : struct, IEquatable, IComparable, IConvertible + { + protected readonly Nub Nub; + protected readonly Box LeftBox; + protected readonly Box RightBox; + private readonly Container nubContainer; + + private readonly HoverClickSounds hoverClickSounds; + + private Color4 accentColour; + + public Color4 AccentColour + { + get => accentColour; + set + { + accentColour = value; + LeftBox.Colour = value; + } + } + + private Colour4 backgroundColour; + + public Color4 BackgroundColour + { + get => backgroundColour; + set + { + backgroundColour = value; + RightBox.Colour = value; + } + } + + public RoundedSliderBar() + { + Height = Nub.HEIGHT; + RangePadding = Nub.EXPANDED_SIZE / 2; + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Padding = new MarginPadding { Horizontal = 2 }, + Child = new CircularContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Masking = true, + CornerRadius = 5f, + Children = new Drawable[] + { + LeftBox = new Box + { + Height = 5, + EdgeSmoothness = new Vector2(0, 0.5f), + RelativeSizeAxes = Axes.None, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + RightBox = new Box + { + Height = 5, + EdgeSmoothness = new Vector2(0, 0.5f), + RelativeSizeAxes = Axes.None, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + }, + }, + }, + }, + nubContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Child = Nub = new Nub + { + Origin = Anchor.TopCentre, + RelativePositionAxes = Axes.X, + Current = { Value = true } + }, + }, + hoverClickSounds = new HoverClickSounds() + }; + } + + [BackgroundDependencyLoader(true)] + private void load(OverlayColourProvider? colourProvider, OsuColour colours) + { + AccentColour = colourProvider?.Highlight1 ?? colours.Pink; + BackgroundColour = colourProvider?.Background5 ?? colours.PinkDarker.Darken(1); + } + + protected override void Update() + { + base.Update(); + + nubContainer.Padding = new MarginPadding { Horizontal = RangePadding }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindDisabledChanged(disabled => + { + Alpha = disabled ? 0.3f : 1; + hoverClickSounds.Enabled.Value = !disabled; + }, true); + } + + protected override bool OnHover(HoverEvent e) + { + updateGlow(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateGlow(); + base.OnHoverLost(e); + } + + protected override bool ShouldHandleAsRelativeDrag(MouseDownEvent e) + => Nub.ReceivePositionalInputAt(e.ScreenSpaceMouseDownPosition); + + protected override void OnDragEnd(DragEndEvent e) + { + updateGlow(); + base.OnDragEnd(e); + } + + private void updateGlow() + { + Nub.Glowing = !Current.Disabled && (IsHovered || IsDragged); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + LeftBox.Scale = new Vector2(Math.Clamp(RangePadding + Nub.DrawPosition.X - Nub.DrawWidth / 2, 0, Math.Max(0, DrawWidth)), 1); + RightBox.Scale = new Vector2(Math.Clamp(DrawWidth - Nub.DrawPosition.X - RangePadding - Nub.DrawWidth / 2, 0, Math.Max(0, DrawWidth)), 1); + } + + protected override void UpdateValue(float value) + { + Nub.MoveToX(value, 250, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Graphics/UserInterface/ScoreCounter.cs b/osu.Game/Graphics/UserInterface/ScoreCounter.cs index 25f19aa0a9..255b2149f0 100644 --- a/osu.Game/Graphics/UserInterface/ScoreCounter.cs +++ b/osu.Game/Graphics/UserInterface/ScoreCounter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; @@ -9,7 +11,7 @@ using osu.Game.Graphics.Sprites; namespace osu.Game.Graphics.UserInterface { - public abstract class ScoreCounter : RollingCounter + public abstract partial class ScoreCounter : RollingCounter { protected override double RollingDuration => 1000; protected override Easing RollingEasing => Easing.Out; @@ -34,10 +36,10 @@ namespace osu.Game.Graphics.UserInterface UpdateDisplay(); } - protected override double GetProportionalDuration(double currentValue, double newValue) => + protected override double GetProportionalDuration(long currentValue, long newValue) => currentValue > newValue ? currentValue - newValue : newValue - currentValue; - protected override LocalisableString FormatCount(double count) => ((long)count).ToLocalisableString(formatString); + protected override LocalisableString FormatCount(long count) => count.ToLocalisableString(formatString); protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(s => s.Font = s.Font.With(fixedWidth: true)); diff --git a/osu.Game/Graphics/UserInterface/ScreenBreadcrumbControl.cs b/osu.Game/Graphics/UserInterface/ScreenBreadcrumbControl.cs index d7bd7d7e01..65dce422d6 100644 --- a/osu.Game/Graphics/UserInterface/ScreenBreadcrumbControl.cs +++ b/osu.Game/Graphics/UserInterface/ScreenBreadcrumbControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics.UserInterface; @@ -11,7 +13,7 @@ namespace osu.Game.Graphics.UserInterface /// /// A which follows the active screen (and allows navigation) in a stack. /// - public class ScreenBreadcrumbControl : BreadcrumbControl + public partial class ScreenBreadcrumbControl : BreadcrumbControl { public ScreenBreadcrumbControl(ScreenStack stack) { diff --git a/osu.Game/Graphics/UserInterface/SearchTextBox.cs b/osu.Game/Graphics/UserInterface/SearchTextBox.cs index cc0bc2dc46..2d09a239bb 100644 --- a/osu.Game/Graphics/UserInterface/SearchTextBox.cs +++ b/osu.Game/Graphics/UserInterface/SearchTextBox.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Game.Resources.Localisation.Web; @@ -8,7 +10,7 @@ using osuTK.Input; namespace osu.Game.Graphics.UserInterface { - public class SearchTextBox : FocusedTextBox + public partial class SearchTextBox : FocusedTextBox { protected virtual bool AllowCommit => false; diff --git a/osu.Game/Graphics/UserInterface/SeekLimitedSearchTextBox.cs b/osu.Game/Graphics/UserInterface/SeekLimitedSearchTextBox.cs index 0b76ff658f..a85cd36808 100644 --- a/osu.Game/Graphics/UserInterface/SeekLimitedSearchTextBox.cs +++ b/osu.Game/Graphics/UserInterface/SeekLimitedSearchTextBox.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Graphics.UserInterface { /// /// A which does not handle left/right arrow keys for seeking. /// - public class SeekLimitedSearchTextBox : BasicSearchTextBox + public partial class SeekLimitedSearchTextBox : BasicSearchTextBox { public override bool HandleLeftRightArrows => false; } diff --git a/osu.Game/Graphics/UserInterface/SegmentedGraph.cs b/osu.Game/Graphics/UserInterface/SegmentedGraph.cs new file mode 100644 index 0000000000..91971e5af9 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/SegmentedGraph.cs @@ -0,0 +1,348 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Shaders; +using osu.Framework.Graphics.Textures; +using osuTK; + +namespace osu.Game.Graphics.UserInterface +{ + public partial class SegmentedGraph : Drawable + where T : struct, IComparable, IConvertible, IEquatable + { + private bool graphNeedsUpdate; + + private T[]? values; + private int[] tiers = Array.Empty(); + private readonly SegmentManager segments; + + private int tierCount; + + public SegmentedGraph(int tierCount = 1) + { + this.tierCount = tierCount; + tierColours = new[] + { + new Colour4(0, 0, 0, 0) + }; + segments = new SegmentManager(tierCount); + } + + public T[] Values + { + get => values ?? Array.Empty(); + set + { + if (value == values) return; + + values = value; + graphNeedsUpdate = true; + } + } + + private IReadOnlyList tierColours; + + public IReadOnlyList TierColours + { + get => tierColours; + set + { + tierCount = value.Count; + tierColours = value; + + graphNeedsUpdate = true; + } + } + + private Texture texture = null!; + private IShader shader = null!; + + [BackgroundDependencyLoader] + private void load(IRenderer renderer, ShaderManager shaders) + { + texture = renderer.WhitePixel; + shader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE); + } + + protected override void Update() + { + base.Update(); + + if (graphNeedsUpdate) + { + recalculateTiers(values); + recalculateSegments(); + Invalidate(Invalidation.DrawNode); + graphNeedsUpdate = false; + } + } + + private void recalculateTiers(T[]? arr) + { + if (arr == null || arr.Length == 0) + { + tiers = Array.Empty(); + return; + } + + float[] floatValues = arr.Select(v => Convert.ToSingle(v)).ToArray(); + + // Shift values to eliminate negative ones + float min = floatValues.Min(); + + if (min < 0) + { + for (int i = 0; i < floatValues.Length; i++) + floatValues[i] += Math.Abs(min); + } + + // Normalize values + float max = floatValues.Max(); + + for (int i = 0; i < floatValues.Length; i++) + floatValues[i] /= max; + + // Deduce tiers from values + tiers = floatValues.Select(v => (int)Math.Floor(v * tierCount)).ToArray(); + } + + private void recalculateSegments() + { + segments.Clear(); + + if (tiers.Length == 0) + { + segments.Add(0, 0, 1); + return; + } + + for (int i = 0; i < tiers.Length; i++) + { + for (int tier = 0; tier < tierCount; tier++) + { + if (tier < 0) + continue; + + // One tier covers itself and all tiers above it. + // By layering multiple transparent boxes, higher tiers will be brighter. + // If using opaque colors, higher tiers will be on front, covering lower tiers. + if (tiers[i] >= tier) + { + if (!segments.IsTierStarted(tier)) + segments.StartSegment(tier, i * 1f / tiers.Length); + } + else + { + if (segments.IsTierStarted(tier)) + segments.EndSegment(tier, i * 1f / tiers.Length); + } + } + } + + segments.EndAllPendingSegments(); + segments.Sort(); + } + + protected override DrawNode CreateDrawNode() => new SegmentedGraphDrawNode(this); + + protected struct SegmentInfo + { + /// + /// The tier this segment is at. + /// + public int Tier; + + /// + /// The progress at which this segment starts. + /// + /// + /// The value is a normalized float (from 0 to 1). + /// + public float Start; + + /// + /// The progress at which this segment ends. + /// + /// + /// The value is a normalized float (from 0 to 1). + /// + public float End; + + /// + /// The length of this segment. + /// + /// + /// The value is a normalized float (from 0 to 1). + /// + public float Length => End - Start; + + public override string ToString() + { + return $"({Tier}, {Start * 100}%, {End * 100}%)"; + } + } + + private class SegmentedGraphDrawNode : DrawNode + { + public new SegmentedGraph Source => (SegmentedGraph)base.Source; + + private Texture texture = null!; + private IShader shader = null!; + private readonly List segments = new List(); + private Vector2 drawSize; + private readonly List tierColours = new List(); + + public SegmentedGraphDrawNode(SegmentedGraph source) + : base(source) + { + } + + public override void ApplyState() + { + base.ApplyState(); + + texture = Source.texture; + shader = Source.shader; + drawSize = Source.DrawSize; + + segments.Clear(); + segments.AddRange(Source.segments.Where(s => s.Length * drawSize.X > 1)); + + tierColours.Clear(); + tierColours.AddRange(Source.tierColours); + } + + public override void Draw(IRenderer renderer) + { + base.Draw(renderer); + + shader.Bind(); + + foreach (SegmentInfo segment in segments) + { + Vector2 topLeft = new Vector2(segment.Start * drawSize.X, 0); + Vector2 topRight = new Vector2(segment.End * drawSize.X, 0); + Vector2 bottomLeft = new Vector2(segment.Start * drawSize.X, drawSize.Y); + Vector2 bottomRight = new Vector2(segment.End * drawSize.X, drawSize.Y); + + renderer.DrawQuad( + texture, + new Quad( + Vector2Extensions.Transform(topLeft, DrawInfo.Matrix), + Vector2Extensions.Transform(topRight, DrawInfo.Matrix), + Vector2Extensions.Transform(bottomLeft, DrawInfo.Matrix), + Vector2Extensions.Transform(bottomRight, DrawInfo.Matrix)), + getSegmentColour(segment)); + } + + shader.Unbind(); + } + + private ColourInfo getSegmentColour(SegmentInfo segment) + { + var segmentColour = DrawColourInfo.Colour.Interpolate(new Quad(segment.Start, 0f, segment.End - segment.Start, 1f)); + + var tierColour = segment.Tier >= 0 ? tierColours[segment.Tier] : new Colour4(0, 0, 0, 0); + segmentColour.ApplyChild(tierColour); + + return segmentColour; + } + } + + protected class SegmentManager : IEnumerable + { + private readonly List segments = new List(); + + private readonly SegmentInfo?[] pendingSegments; + + public SegmentManager(int tierCount) + { + pendingSegments = new SegmentInfo?[tierCount]; + } + + public void StartSegment(int tier, float start) + { + if (pendingSegments[tier] != null) + throw new InvalidOperationException($"Another {nameof(SegmentInfo)} of tier {tier.ToString()} has already been started."); + + pendingSegments[tier] = new SegmentInfo + { + Tier = tier, + Start = Math.Clamp(start, 0, 1) + }; + } + + public void EndSegment(int tier, float end) + { + SegmentInfo? pendingSegment = pendingSegments[tier]; + if (pendingSegment == null) + throw new InvalidOperationException($"Cannot end {nameof(SegmentInfo)} of tier {tier.ToString()} that has not been started."); + + SegmentInfo segment = pendingSegment.Value; + segment.End = Math.Clamp(end, 0, 1); + segments.Add(segment); + pendingSegments[tier] = null; + } + + public void EndAllPendingSegments() + { + foreach (SegmentInfo? pendingSegment in pendingSegments) + { + if (pendingSegment == null) + continue; + + SegmentInfo finalizedSegment = pendingSegment.Value; + finalizedSegment.End = 1; + segments.Add(finalizedSegment); + } + } + + public void Sort() => + segments.Sort((a, b) => + a.Tier != b.Tier + ? a.Tier.CompareTo(b.Tier) + : a.Start.CompareTo(b.Start)); + + public void Add(SegmentInfo segment) => segments.Add(segment); + + public void Clear() + { + segments.Clear(); + + for (int i = 0; i < pendingSegments.Length; i++) + pendingSegments[i] = null; + } + + public int Count => segments.Count; + + public void Add(int tier, float start, float end) + { + SegmentInfo segment = new SegmentInfo + { + Tier = tier, + Start = Math.Clamp(start, 0, 1), + End = Math.Clamp(end, 0, 1) + }; + + if (segment.Start > segment.End) + throw new InvalidOperationException("Segment start cannot be after segment end."); + + Add(segment); + } + + public bool IsTierStarted(int tier) => tier >= 0 && pendingSegments[tier].HasValue; + + public IEnumerator GetEnumerator() => segments.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } +} diff --git a/osu.Game/Graphics/UserInterface/SelectionState.cs b/osu.Game/Graphics/UserInterface/SelectionState.cs index c85b2ad3ab..edabf0547b 100644 --- a/osu.Game/Graphics/UserInterface/SelectionState.cs +++ b/osu.Game/Graphics/UserInterface/SelectionState.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Graphics.UserInterface { public enum SelectionState diff --git a/osu.Game/Graphics/UserInterface/ShearedButton.cs b/osu.Game/Graphics/UserInterface/ShearedButton.cs index 66c6eedd0c..f1afacb2f4 100644 --- a/osu.Game/Graphics/UserInterface/ShearedButton.cs +++ b/osu.Game/Graphics/UserInterface/ShearedButton.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. -#nullable enable using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -16,7 +15,7 @@ using osuTK; namespace osu.Game.Graphics.UserInterface { - public class ShearedButton : OsuClickableContainer + public partial class ShearedButton : OsuClickableContainer { public LocalisableString Text { @@ -98,7 +97,7 @@ namespace osu.Game.Graphics.UserInterface { backgroundLayer = new Container { - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.Y, CornerRadius = corner_radius, Masking = true, BorderThickness = 2, @@ -129,15 +128,17 @@ namespace osu.Game.Graphics.UserInterface if (width != null) { Width = width.Value; + backgroundLayer.RelativeSizeAxes = Axes.Both; } else { AutoSizeAxes = Axes.X; + backgroundLayer.AutoSizeAxes = Axes.X; text.Margin = new MarginPadding { Horizontal = 15 }; } } - protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverClickSounds(sampleSet); + protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverClickSounds(sampleSet) { Enabled = { BindTarget = Enabled } }; protected override void LoadComplete() { diff --git a/osu.Game/Graphics/UserInterface/ShearedNub.cs b/osu.Game/Graphics/UserInterface/ShearedNub.cs new file mode 100644 index 0000000000..3a09fd7445 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/ShearedNub.cs @@ -0,0 +1,183 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterface +{ + public partial class ShearedNub : Container, IHasCurrentValue, IHasAccentColour + { + protected const float BORDER_WIDTH = 3; + + public const int HEIGHT = 30; + public const float EXPANDED_SIZE = 50; + + public static readonly Vector2 SHEAR = new Vector2(0.15f, 0); + + private readonly Box fill; + private readonly Container main; + + /// + /// Implements the shape for the nub, allowing for any type of container to be used. + /// + /// + public ShearedNub() + { + Size = new Vector2(EXPANDED_SIZE, HEIGHT); + InternalChild = main = new Container + { + Shear = SHEAR, + BorderColour = Colour4.White, + BorderThickness = BORDER_WIDTH, + Masking = true, + CornerRadius = 5, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Child = fill = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + } + }; + } + + [BackgroundDependencyLoader(true)] + private void load(OverlayColourProvider? colourProvider, OsuColour colours) + { + AccentColour = colourProvider?.Highlight1 ?? colours.Pink; + GlowingAccentColour = colourProvider?.Highlight1.Lighten(0.4f) ?? colours.PinkLighter; + GlowColour = colourProvider?.Highlight1 ?? colours.PinkLighter; + + main.EdgeEffect = new EdgeEffectParameters + { + Colour = GlowColour.Opacity(0), + Type = EdgeEffectType.Glow, + Radius = 8, + Roundness = 4, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(onCurrentValueChanged, true); + } + + private bool glowing; + + public bool Glowing + { + get => glowing; + set + { + if (glowing == value) + return; + + glowing = value; + + if (value) + { + main.FadeColour(GlowingAccentColour.Lighten(0.1f), 40, Easing.OutQuint) + .Then() + .FadeColour(GlowingAccentColour, 800, Easing.OutQuint); + + main.FadeEdgeEffectTo(Color4.White.Opacity(0.1f), 40, Easing.OutQuint) + .Then() + .FadeEdgeEffectTo(GlowColour.Opacity(0.1f), 800, Easing.OutQuint); + } + else + { + main.FadeEdgeEffectTo(GlowColour.Opacity(0), 800, Easing.OutQuint); + main.FadeColour(AccentColour, 800, Easing.OutQuint); + } + } + } + + private readonly Bindable current = new Bindable(); + + public Bindable Current + { + get => current; + set + { + ArgumentNullException.ThrowIfNull(value); + + current.UnbindBindings(); + current.BindTo(value); + } + } + + private Color4 accentColour; + + public Color4 AccentColour + { + get => accentColour; + set + { + accentColour = value; + if (!Glowing) + main.Colour = value; + } + } + + private Color4 glowingAccentColour; + + public Color4 GlowingAccentColour + { + get => glowingAccentColour; + set + { + glowingAccentColour = value; + if (Glowing) + main.Colour = value; + } + } + + private Color4 glowColour; + + public Color4 GlowColour + { + get => glowColour; + set + { + glowColour = value; + + var effect = main.EdgeEffect; + effect.Colour = Glowing ? value : value.Opacity(0); + main.EdgeEffect = effect; + } + } + + private void onCurrentValueChanged(ValueChangedEvent filled) + { + const double duration = 200; + + fill.FadeTo(filled.NewValue ? 1 : 0, duration, Easing.OutQuint); + + if (filled.NewValue) + { + main.ResizeWidthTo(1, duration, Easing.OutElasticHalf); + main.TransformTo(nameof(BorderThickness), 8.5f, duration, Easing.OutElasticHalf); + } + else + { + main.ResizeWidthTo(0.75f, duration, Easing.OutQuint); + main.TransformTo(nameof(BorderThickness), BORDER_WIDTH, duration, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Graphics/UserInterface/ShearedOverlayHeader.cs b/osu.Game/Graphics/UserInterface/ShearedOverlayHeader.cs index 452a1dd394..99eb439f75 100644 --- a/osu.Game/Graphics/UserInterface/ShearedOverlayHeader.cs +++ b/osu.Game/Graphics/UserInterface/ShearedOverlayHeader.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 enable - using System; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -19,7 +17,7 @@ using osuTK; namespace osu.Game.Graphics.UserInterface { - public class ShearedOverlayHeader : CompositeDrawable + public partial class ShearedOverlayHeader : CompositeDrawable { public const float HEIGHT = main_area_height + 2 * corner_radius; diff --git a/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs b/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs index 0c34a04a65..7bd083f9d5 100644 --- a/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs +++ b/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -16,7 +18,7 @@ using osuTK; namespace osu.Game.Graphics.UserInterface { - public class ShearedSearchTextBox : CompositeDrawable, IHasCurrentValue + public partial class ShearedSearchTextBox : CompositeDrawable, IHasCurrentValue { private const float corner_radius = 7; @@ -93,7 +95,7 @@ namespace osu.Game.Graphics.UserInterface public override bool HandleNonPositionalInput => textBox.HandleNonPositionalInput; - private class InnerSearchTextBox : SearchTextBox + private partial class InnerSearchTextBox : SearchTextBox { [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) @@ -110,7 +112,7 @@ namespace osu.Game.Graphics.UserInterface protected override SpriteText CreatePlaceholder() => new SearchPlaceholder(); - internal class SearchPlaceholder : SpriteText + internal partial class SearchPlaceholder : SpriteText { public override void Show() { diff --git a/osu.Game/Graphics/UserInterface/ShearedSliderBar.cs b/osu.Game/Graphics/UserInterface/ShearedSliderBar.cs new file mode 100644 index 0000000000..a18a6a259c --- /dev/null +++ b/osu.Game/Graphics/UserInterface/ShearedSliderBar.cs @@ -0,0 +1,173 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osuTK; +using osuTK.Graphics; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Overlays; +using static osu.Game.Graphics.UserInterface.ShearedNub; + +namespace osu.Game.Graphics.UserInterface +{ + public partial class ShearedSliderBar : OsuSliderBar + where T : struct, IEquatable, IComparable, IConvertible + { + protected readonly ShearedNub Nub; + protected readonly Box LeftBox; + protected readonly Box RightBox; + private readonly Container nubContainer; + + private readonly HoverClickSounds hoverClickSounds; + + private Color4 accentColour; + + public Color4 AccentColour + { + get => accentColour; + set + { + accentColour = value; + + // We want to slightly darken the colour for the box because the sheared slider has the boxes at the same height as the nub, + // making the nub invisible when not hovered. + LeftBox.Colour = value.Darken(0.1f); + } + } + + private Colour4 backgroundColour; + + public Color4 BackgroundColour + { + get => backgroundColour; + set + { + backgroundColour = value; + RightBox.Colour = value; + } + } + + public ShearedSliderBar() + { + Shear = SHEAR; + Height = HEIGHT; + RangePadding = EXPANDED_SIZE / 2; + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Padding = new MarginPadding { Horizontal = 2 }, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Masking = true, + CornerRadius = 5, + Children = new Drawable[] + { + LeftBox = new Box + { + EdgeSmoothness = new Vector2(0, 0.5f), + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + RightBox = new Box + { + EdgeSmoothness = new Vector2(0, 0.5f), + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + }, + }, + }, + }, + nubContainer = new Container + { + Shear = -SHEAR, + RelativeSizeAxes = Axes.Both, + Child = Nub = new ShearedNub + { + X = -SHEAR.X * HEIGHT / 2f, + Origin = Anchor.TopCentre, + RelativePositionAxes = Axes.X, + Current = { Value = true } + }, + }, + hoverClickSounds = new HoverClickSounds() + }; + } + + [BackgroundDependencyLoader(true)] + private void load(OverlayColourProvider? colourProvider, OsuColour colours) + { + AccentColour = colourProvider?.Highlight1 ?? colours.Pink; + BackgroundColour = colourProvider?.Background5 ?? colours.PinkDarker.Darken(1); + } + + protected override void Update() + { + base.Update(); + + nubContainer.Padding = new MarginPadding { Horizontal = RangePadding }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindDisabledChanged(disabled => + { + Alpha = disabled ? 0.3f : 1; + hoverClickSounds.Enabled.Value = !disabled; + }, true); + } + + protected override bool OnHover(HoverEvent e) + { + updateGlow(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateGlow(); + base.OnHoverLost(e); + } + + protected override bool ShouldHandleAsRelativeDrag(MouseDownEvent e) + => Nub.ReceivePositionalInputAt(e.ScreenSpaceMouseDownPosition); + + protected override void OnDragEnd(DragEndEvent e) + { + updateGlow(); + base.OnDragEnd(e); + } + + private void updateGlow() + { + Nub.Glowing = !Current.Disabled && (IsHovered || IsDragged); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + LeftBox.Scale = new Vector2(Math.Clamp(RangePadding + Nub.DrawPosition.X - Nub.DrawWidth / 2.15f, 0, Math.Max(0, DrawWidth)), 1); + RightBox.Scale = new Vector2(Math.Clamp(DrawWidth - Nub.DrawPosition.X - RangePadding - Nub.DrawWidth / 2.15f, 0, Math.Max(0, DrawWidth)), 1); + } + + protected override void UpdateValue(float value) + { + Nub.MoveToX(value, 250, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs b/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs index ee59da7279..d5e0abe9d8 100644 --- a/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs +++ b/osu.Game/Graphics/UserInterface/ShearedToggleButton.cs @@ -1,9 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - -using System; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -11,7 +8,7 @@ using osu.Framework.Bindables; namespace osu.Game.Graphics.UserInterface { - public class ShearedToggleButton : ShearedButton + public partial class ShearedToggleButton : ShearedButton { private Sample? sampleClick; private Sample? sampleOff; @@ -49,18 +46,18 @@ namespace osu.Game.Graphics.UserInterface protected override void LoadComplete() { - Active.BindDisabledChanged(disabled => Action = disabled ? (Action?)null : Active.Toggle, true); + Active.BindDisabledChanged(disabled => Action = disabled ? null : Active.Toggle, true); Active.BindValueChanged(_ => { - updateActiveState(); + UpdateActiveState(); playSample(); }); - updateActiveState(); + UpdateActiveState(); base.LoadComplete(); } - private void updateActiveState() + protected virtual void UpdateActiveState() { DarkerColour = Active.Value ? ColourProvider.Highlight1 : ColourProvider.Background3; LighterColour = Active.Value ? ColourProvider.Colour0 : ColourProvider.Background1; diff --git a/osu.Game/Graphics/UserInterface/ShowMoreButton.cs b/osu.Game/Graphics/UserInterface/ShowMoreButton.cs index 05dda324d4..3afb7e701f 100644 --- a/osu.Game/Graphics/UserInterface/ShowMoreButton.cs +++ b/osu.Game/Graphics/UserInterface/ShowMoreButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -17,7 +19,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Graphics.UserInterface { - public class ShowMoreButton : LoadingButton + public partial class ShowMoreButton : LoadingButton { private const int duration = 200; @@ -113,7 +115,7 @@ namespace osu.Game.Graphics.UserInterface rightIcon.SetHoveredState(false); } - public class ChevronIcon : SpriteIcon + public partial class ChevronIcon : SpriteIcon { [Resolved] private OverlayColourProvider colourProvider { get; set; } diff --git a/osu.Game/Graphics/UserInterface/SlimEnumDropdown.cs b/osu.Game/Graphics/UserInterface/SlimEnumDropdown.cs index c01ee1a059..e3f5bc65e6 100644 --- a/osu.Game/Graphics/UserInterface/SlimEnumDropdown.cs +++ b/osu.Game/Graphics/UserInterface/SlimEnumDropdown.cs @@ -1,18 +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; using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; namespace osu.Game.Graphics.UserInterface { - public class SlimEnumDropdown : OsuEnumDropdown + public partial class SlimEnumDropdown : OsuEnumDropdown where T : struct, Enum { protected override DropdownHeader CreateHeader() => new SlimDropdownHeader(); - private class SlimDropdownHeader : OsuDropdownHeader + private partial class SlimDropdownHeader : OsuDropdownHeader { public SlimDropdownHeader() { diff --git a/osu.Game/Graphics/UserInterface/StarCounter.cs b/osu.Game/Graphics/UserInterface/StarCounter.cs index b66f371801..d7d088d798 100644 --- a/osu.Game/Graphics/UserInterface/StarCounter.cs +++ b/osu.Game/Graphics/UserInterface/StarCounter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -11,7 +13,7 @@ using osu.Framework.Graphics.Sprites; namespace osu.Game.Graphics.UserInterface { - public class StarCounter : Container + public partial class StarCounter : Container { private readonly FillFlowContainer stars; @@ -66,7 +68,7 @@ namespace osu.Game.Graphics.UserInterface AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(star_spacing), - ChildrenEnumerable = Enumerable.Range(0, StarCount).Select(i => CreateStar()) + ChildrenEnumerable = Enumerable.Range(0, StarCount).Select(_ => CreateStar()) } }; } @@ -118,7 +120,7 @@ namespace osu.Game.Graphics.UserInterface } } - public class DefaultStar : Star + public partial class DefaultStar : Star { private const double scaling_duration = 1000; @@ -154,7 +156,7 @@ namespace osu.Game.Graphics.UserInterface } } - public abstract class Star : CompositeDrawable + public abstract partial class Star : CompositeDrawable { public abstract void DisplayAt(float scale); } diff --git a/osu.Game/Graphics/UserInterface/StatefulMenuItem.cs b/osu.Game/Graphics/UserInterface/StatefulMenuItem.cs index 0d7b36e51b..85efd75a60 100644 --- a/osu.Game/Graphics/UserInterface/StatefulMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/StatefulMenuItem.cs @@ -4,6 +4,7 @@ using System; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; namespace osu.Game.Graphics.UserInterface { @@ -23,7 +24,7 @@ namespace osu.Game.Graphics.UserInterface /// The text to display. /// A function that mutates a state to another state after this is pressed. /// The type of action which this performs. - protected StatefulMenuItem(string text, Func changeStateFunc, MenuItemType type = MenuItemType.Standard) + protected StatefulMenuItem(LocalisableString text, Func changeStateFunc, MenuItemType type = MenuItemType.Standard) : this(text, changeStateFunc, type, null) { } @@ -35,7 +36,7 @@ namespace osu.Game.Graphics.UserInterface /// A function that mutates a state to another state after this is pressed. /// The type of action which this performs. /// A delegate to be invoked when this is pressed. - protected StatefulMenuItem(string text, Func changeStateFunc, MenuItemType type, Action action) + protected StatefulMenuItem(LocalisableString text, Func? changeStateFunc, MenuItemType type, Action? action) : base(text, type) { Action.Value = () => @@ -67,7 +68,7 @@ namespace osu.Game.Graphics.UserInterface /// The text to display. /// A function that mutates a state to another state after this is pressed. /// The type of action which this performs. - protected StatefulMenuItem(string text, Func changeStateFunc, MenuItemType type = MenuItemType.Standard) + protected StatefulMenuItem(LocalisableString text, Func? changeStateFunc, MenuItemType type = MenuItemType.Standard) : this(text, changeStateFunc, type, null) { } @@ -79,7 +80,7 @@ namespace osu.Game.Graphics.UserInterface /// A function that mutates a state to another state after this is pressed. /// The type of action which this performs. /// A delegate to be invoked when this is pressed. - protected StatefulMenuItem(string text, Func changeStateFunc, MenuItemType type, Action action) + protected StatefulMenuItem(LocalisableString text, Func? changeStateFunc, MenuItemType type, Action? action) : base(text, o => changeStateFunc?.Invoke((T)o) ?? o, type, o => action?.Invoke((T)o)) { base.State.BindValueChanged(state => diff --git a/osu.Game/Graphics/UserInterface/TernaryState.cs b/osu.Game/Graphics/UserInterface/TernaryState.cs index d4de28044f..effbe624c3 100644 --- a/osu.Game/Graphics/UserInterface/TernaryState.cs +++ b/osu.Game/Graphics/UserInterface/TernaryState.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Graphics.UserInterface { /// diff --git a/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs b/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs index 5c623150b7..d2b6ff2dba 100644 --- a/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics.Sprites; diff --git a/osu.Game/Graphics/UserInterface/TernaryStateRadioMenuItem.cs b/osu.Game/Graphics/UserInterface/TernaryStateRadioMenuItem.cs index 46eda06294..133362d3e6 100644 --- a/osu.Game/Graphics/UserInterface/TernaryStateRadioMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/TernaryStateRadioMenuItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; namespace osu.Game.Graphics.UserInterface diff --git a/osu.Game/Graphics/UserInterface/TernaryStateToggleMenuItem.cs b/osu.Game/Graphics/UserInterface/TernaryStateToggleMenuItem.cs index ce951984fd..ec776f5cc4 100644 --- a/osu.Game/Graphics/UserInterface/TernaryStateToggleMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/TernaryStateToggleMenuItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; namespace osu.Game.Graphics.UserInterface diff --git a/osu.Game/Graphics/UserInterface/TimeSlider.cs b/osu.Game/Graphics/UserInterface/TimeSlider.cs index 82b02f1b48..e4058827f3 100644 --- a/osu.Game/Graphics/UserInterface/TimeSlider.cs +++ b/osu.Game/Graphics/UserInterface/TimeSlider.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Localisation; namespace osu.Game.Graphics.UserInterface @@ -8,7 +10,7 @@ namespace osu.Game.Graphics.UserInterface /// /// A slider bar which displays a millisecond time value. /// - public class TimeSlider : OsuSliderBar + public partial class TimeSlider : RoundedSliderBar { public override LocalisableString TooltipText => $"{Current.Value:N0} ms"; } diff --git a/osu.Game/Graphics/UserInterface/ToggleMenuItem.cs b/osu.Game/Graphics/UserInterface/ToggleMenuItem.cs index f9ff9859dd..51e1248bc1 100644 --- a/osu.Game/Graphics/UserInterface/ToggleMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/ToggleMenuItem.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; namespace osu.Game.Graphics.UserInterface { @@ -16,7 +17,7 @@ namespace osu.Game.Graphics.UserInterface /// /// The text to display. /// The type of action which this performs. - public ToggleMenuItem(string text, MenuItemType type = MenuItemType.Standard) + public ToggleMenuItem(LocalisableString text, MenuItemType type = MenuItemType.Standard) : this(text, type, null) { } @@ -27,11 +28,11 @@ namespace osu.Game.Graphics.UserInterface /// The text to display. /// The type of action which this performs. /// A delegate to be invoked when this is pressed. - public ToggleMenuItem(string text, MenuItemType type, Action action) + public ToggleMenuItem(LocalisableString text, MenuItemType type, Action? action) : base(text, value => !value, type, action) { } - public override IconUsage? GetIconForState(bool state) => state ? (IconUsage?)FontAwesome.Solid.Check : null; + public override IconUsage? GetIconForState(bool state) => state ? FontAwesome.Solid.Check : null; } } diff --git a/osu.Game/Graphics/UserInterface/TriangleButton.cs b/osu.Game/Graphics/UserInterface/TriangleButton.cs deleted file mode 100644 index 5ae6130039..0000000000 --- a/osu.Game/Graphics/UserInterface/TriangleButton.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Localisation; -using osu.Game.Graphics.Backgrounds; - -namespace osu.Game.Graphics.UserInterface -{ - /// - /// A button with moving triangles in the background. - /// - public class TriangleButton : OsuButton, IFilterable - { - protected Triangles Triangles { get; private set; } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Add(Triangles = new Triangles - { - RelativeSizeAxes = Axes.Both, - ColourDark = colours.BlueDarker, - ColourLight = colours.Blue, - }); - } - - public virtual IEnumerable FilterTerms => new[] { Text }; - - public bool MatchingFilter - { - set => this.FadeTo(value ? 1 : 0); - } - - public bool FilteringActive { get; set; } - } -} diff --git a/osu.Game/Graphics/UserInterface/TwoLayerButton.cs b/osu.Game/Graphics/UserInterface/TwoLayerButton.cs index 1f5d29571d..aa542b8f49 100644 --- a/osu.Game/Graphics/UserInterface/TwoLayerButton.cs +++ b/osu.Game/Graphics/UserInterface/TwoLayerButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -20,7 +22,7 @@ using osu.Game.Screens.Select; namespace osu.Game.Graphics.UserInterface { - public class TwoLayerButton : OsuClickableContainer + public partial class TwoLayerButton : OsuClickableContainer { private readonly BouncingIcon bouncingIcon; @@ -62,8 +64,8 @@ namespace osu.Game.Graphics.UserInterface X = value.HasFlagFast(Anchor.x2) ? SIZE_RETRACTED.X * shear.X * 0.5f : 0; - Remove(c1); - Remove(c2); + Remove(c1, false); + Remove(c2, false); c1.Depth = value.HasFlagFast(Anchor.x2) ? 0 : 1; c2.Depth = value.HasFlagFast(Anchor.x2) ? 1 : 0; Add(c1); @@ -205,7 +207,7 @@ namespace osu.Game.Graphics.UserInterface return base.OnClick(e); } - private class BouncingIcon : BeatSyncedContainer + private partial class BouncingIcon : BeatSyncedContainer { private const double beat_in_time = 60; diff --git a/osu.Game/Graphics/UserInterfaceV2/ColourDisplay.cs b/osu.Game/Graphics/UserInterfaceV2/ColourDisplay.cs index cec319f28e..9ddbf84c39 100644 --- a/osu.Game/Graphics/UserInterfaceV2/ColourDisplay.cs +++ b/osu.Game/Graphics/UserInterfaceV2/ColourDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -22,7 +24,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 /// /// A component which displays a colour along with related description text. /// - public class ColourDisplay : CompositeDrawable, IHasCurrentValue + public partial class ColourDisplay : CompositeDrawable, IHasCurrentValue { /// /// Invoked when the user has requested the colour corresponding to this @@ -84,7 +86,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 }; } - private class ColourCircle : OsuClickableContainer, IHasPopover, IHasContextMenu + private partial class ColourCircle : OsuClickableContainer, IHasPopover, IHasContextMenu { public Bindable Current { get; } = new Bindable(); diff --git a/osu.Game/Graphics/UserInterfaceV2/ColourPalette.cs b/osu.Game/Graphics/UserInterfaceV2/ColourPalette.cs index a966f61b74..f554887510 100644 --- a/osu.Game/Graphics/UserInterfaceV2/ColourPalette.cs +++ b/osu.Game/Graphics/UserInterfaceV2/ColourPalette.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Collections.Specialized; @@ -11,6 +13,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osuTK; @@ -20,13 +23,13 @@ namespace osu.Game.Graphics.UserInterfaceV2 /// /// A component which displays a collection of colours in individual s. /// - public class ColourPalette : CompositeDrawable + public partial class ColourPalette : CompositeDrawable { public BindableList Colours { get; } = new BindableList(); - private string colourNamePrefix = "Colour"; + private LocalisableString colourNamePrefix = "Colour"; - public string ColourNamePrefix + public LocalisableString ColourNamePrefix { get => colourNamePrefix; set @@ -116,7 +119,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 } } - internal class AddColourButton : CompositeDrawable + internal partial class AddColourButton : CompositeDrawable { public Action Action { diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledColourPalette.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledColourPalette.cs index 8970ef1115..721d8990ba 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledColourPalette.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledColourPalette.cs @@ -1,12 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Localisation; namespace osu.Game.Graphics.UserInterfaceV2 { - public class LabelledColourPalette : LabelledDrawable + public partial class LabelledColourPalette : LabelledDrawable { public LabelledColourPalette() : base(true) @@ -15,7 +18,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 public BindableList Colours => Component.Colours; - public string ColourNamePrefix + public LocalisableString ColourNamePrefix { get => Component.ColourNamePrefix; set => Component.ColourNamePrefix = value; diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledComponent.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledComponent.cs index dd6a902989..8fd9a62ad7 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledComponent.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledComponent.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; namespace osu.Game.Graphics.UserInterfaceV2 { - public abstract class LabelledComponent : LabelledDrawable, IHasCurrentValue + public abstract partial class LabelledComponent : LabelledDrawable, IHasCurrentValue where TDrawable : Drawable, IHasCurrentValue { protected LabelledComponent(bool padded) diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs index 2353d9e0e8..9b7087ce6d 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.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 enable - using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -15,7 +13,7 @@ using osuTK; namespace osu.Game.Graphics.UserInterfaceV2 { - public abstract class LabelledDrawable : CompositeDrawable + public abstract partial class LabelledDrawable : CompositeDrawable where T : Drawable { private float? fixedLabelWidth; diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledDropdown.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledDropdown.cs index 44f09f13eb..0e2ea362da 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledDropdown.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledDropdown.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Graphics; using osu.Game.Graphics.UserInterface; namespace osu.Game.Graphics.UserInterfaceV2 { - public class LabelledDropdown : LabelledComponent, TItem> + public partial class LabelledDropdown : LabelledComponent, TItem> { public LabelledDropdown() : base(true) diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledEnumDropdown.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledEnumDropdown.cs index b818c394ae..3ca460be90 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledEnumDropdown.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledEnumDropdown.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Game.Graphics.UserInterface; namespace osu.Game.Graphics.UserInterfaceV2 { - public class LabelledEnumDropdown : LabelledDropdown + public partial class LabelledEnumDropdown : LabelledDropdown where TEnum : struct, Enum { protected override OsuDropdown CreateDropdown() => new OsuEnumDropdown(); diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledNumberBox.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledNumberBox.cs index ca247ab679..2643db0547 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledNumberBox.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledNumberBox.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Graphics.UserInterface; namespace osu.Game.Graphics.UserInterfaceV2 { - public class LabelledNumberBox : LabelledTextBox + public partial class LabelledNumberBox : LabelledTextBox { protected override OsuTextBox CreateTextBox() => new OsuNumberBox(); } diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledSliderBar.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledSliderBar.cs index cba94e314b..00f4ef1a30 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledSliderBar.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledSliderBar.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics; using osu.Game.Overlays.Settings; namespace osu.Game.Graphics.UserInterfaceV2 { - public class LabelledSliderBar : LabelledComponent, TNumber> + public partial class LabelledSliderBar : LabelledComponent, TNumber> where TNumber : struct, IEquatable, IComparable, IConvertible { public LabelledSliderBar() diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledSwitchButton.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledSwitchButton.cs index c374d80830..3c27829de3 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledSwitchButton.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledSwitchButton.cs @@ -1,9 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Graphics.UserInterfaceV2 { - public class LabelledSwitchButton : LabelledComponent + public partial class LabelledSwitchButton : LabelledComponent { public LabelledSwitchButton() : base(true) diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs index 82b9fe559f..454be02d0b 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -11,7 +13,7 @@ using osu.Game.Graphics.UserInterface; namespace osu.Game.Graphics.UserInterfaceV2 { - public class LabelledTextBox : LabelledComponent + public partial class LabelledTextBox : LabelledComponent { public event TextBox.OnCommitHandler OnCommit; diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuColourPicker.cs b/osu.Game/Graphics/UserInterfaceV2/OsuColourPicker.cs index 5394e5d0aa..fed17eaf20 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuColourPicker.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuColourPicker.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.UserInterface; namespace osu.Game.Graphics.UserInterfaceV2 { - public class OsuColourPicker : ColourPicker + public partial class OsuColourPicker : ColourPicker { public OsuColourPicker() { diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelector.cs b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelector.cs index 1ce4d97fdf..21f926ba42 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelector.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelector.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.IO; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osu.Game.Graphics.Containers; namespace osu.Game.Graphics.UserInterfaceV2 { - public class OsuDirectorySelector : DirectorySelector + public partial class OsuDirectorySelector : DirectorySelector { public const float ITEM_HEIGHT = 20; @@ -29,6 +31,8 @@ namespace osu.Game.Graphics.UserInterfaceV2 protected override DirectorySelectorBreadcrumbDisplay CreateBreadcrumb() => new OsuDirectorySelectorBreadcrumbDisplay(); + protected override Drawable CreateHiddenToggleButton() => new OsuDirectorySelectorHiddenToggle { Current = { BindTarget = ShowHiddenItems } }; + protected override DirectorySelectorDirectory CreateParentDirectoryItem(DirectoryInfo directory) => new OsuDirectorySelectorParentDirectory(directory); protected override DirectorySelectorDirectory CreateDirectoryItem(DirectoryInfo directory, string displayName = null) => new OsuDirectorySelectorDirectory(directory, displayName); diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorBreadcrumbDisplay.cs b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorBreadcrumbDisplay.cs index cb5ff242a1..0917b9db97 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorBreadcrumbDisplay.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorBreadcrumbDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.IO; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osuTK; namespace osu.Game.Graphics.UserInterfaceV2 { - internal class OsuDirectorySelectorBreadcrumbDisplay : DirectorySelectorBreadcrumbDisplay + internal partial class OsuDirectorySelectorBreadcrumbDisplay : DirectorySelectorBreadcrumbDisplay { protected override Drawable CreateCaption() => new OsuSpriteText { @@ -23,13 +25,12 @@ namespace osu.Game.Graphics.UserInterfaceV2 protected override DirectorySelectorDirectory CreateDirectoryItem(DirectoryInfo directory, string displayName = null) => new OsuBreadcrumbDisplayDirectory(directory, displayName); - [BackgroundDependencyLoader] - private void load() + public OsuDirectorySelectorBreadcrumbDisplay() { - Height = 50; + Padding = new MarginPadding(15); } - private class OsuBreadcrumbDisplayComputer : OsuBreadcrumbDisplayDirectory + private partial class OsuBreadcrumbDisplayComputer : OsuBreadcrumbDisplayDirectory { protected override IconUsage? Icon => null; @@ -39,7 +40,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 } } - private class OsuBreadcrumbDisplayDirectory : OsuDirectorySelectorDirectory + private partial class OsuBreadcrumbDisplayDirectory : OsuDirectorySelectorDirectory { public OsuBreadcrumbDisplayDirectory(DirectoryInfo directory, string displayName = null) : base(directory, displayName) diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.cs b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.cs index 456bde6d1b..932017b03e 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.IO; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -14,7 +16,7 @@ using osu.Game.Overlays; namespace osu.Game.Graphics.UserInterfaceV2 { - internal class OsuDirectorySelectorDirectory : DirectorySelectorDirectory + internal partial class OsuDirectorySelectorDirectory : DirectorySelectorDirectory { public OsuDirectorySelectorDirectory(DirectoryInfo directory, string displayName = null) : base(directory, displayName) @@ -43,7 +45,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 ? FontAwesome.Solid.Database : FontAwesome.Regular.Folder; - internal class Background : CompositeDrawable + internal partial class Background : CompositeDrawable { [BackgroundDependencyLoader(true)] private void load(OverlayColourProvider overlayColourProvider, OsuColour colours) diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorHiddenToggle.cs b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorHiddenToggle.cs new file mode 100644 index 0000000000..7665ed507f --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorHiddenToggle.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + internal partial class OsuDirectorySelectorHiddenToggle : OsuCheckbox + { + public OsuDirectorySelectorHiddenToggle() + { + RelativeSizeAxes = Axes.None; + AutoSizeAxes = Axes.None; + Size = new Vector2(100, 50); + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + LabelTextFlowContainer.Anchor = Anchor.CentreLeft; + LabelTextFlowContainer.Origin = Anchor.CentreLeft; + LabelText = @"Show hidden"; + } + + [BackgroundDependencyLoader(true)] + private void load(OverlayColourProvider? overlayColourProvider, OsuColour colours) + { + if (overlayColourProvider != null) + return; + + Nub.AccentColour = colours.GreySeaFoamLighter; + Nub.GlowingAccentColour = Color4.White; + Nub.GlowColour = Color4.White; + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorParentDirectory.cs b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorParentDirectory.cs index 481d811adb..beaeb86243 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorParentDirectory.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorParentDirectory.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.IO; using osu.Framework.Graphics.Sprites; namespace osu.Game.Graphics.UserInterfaceV2 { - internal class OsuDirectorySelectorParentDirectory : OsuDirectorySelectorDirectory + internal partial class OsuDirectorySelectorParentDirectory : OsuDirectorySelectorDirectory { protected override IconUsage? Icon => FontAwesome.Solid.Folder; diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs b/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs index d9dbf4974b..37e15c6127 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.IO; using System.Linq; using osu.Framework.Allocation; @@ -8,14 +10,13 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; -using osu.Game.Beatmaps; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; namespace osu.Game.Graphics.UserInterfaceV2 { - public class OsuFileSelector : FileSelector + public partial class OsuFileSelector : FileSelector { public OsuFileSelector(string initialPath = null, string[] validFileExtensions = null) : base(initialPath, validFileExtensions) @@ -32,6 +33,8 @@ namespace osu.Game.Graphics.UserInterfaceV2 protected override DirectorySelectorBreadcrumbDisplay CreateBreadcrumb() => new OsuDirectorySelectorBreadcrumbDisplay(); + protected override Drawable CreateHiddenToggleButton() => new OsuDirectorySelectorHiddenToggle { Current = { BindTarget = ShowHiddenItems } }; + protected override DirectorySelectorDirectory CreateParentDirectoryItem(DirectoryInfo directory) => new OsuDirectorySelectorParentDirectory(directory); protected override DirectorySelectorDirectory CreateDirectoryItem(DirectoryInfo directory, string displayName = null) => new OsuDirectorySelectorDirectory(directory, displayName); @@ -40,7 +43,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 protected override void NotifySelectionError() => this.FlashColour(Colour4.Red, 300); - protected class OsuDirectoryListingFile : DirectoryListingFile + protected partial class OsuDirectoryListingFile : DirectoryListingFile { public OsuDirectoryListingFile(FileInfo file) : base(file) @@ -67,7 +70,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 { get { - if (BeatmapModelManager.VIDEO_EXTENSIONS.Contains(File.Extension)) + if (OsuGameBase.VIDEO_EXTENSIONS.Contains(File.Extension)) return FontAwesome.Regular.FileVideo; switch (File.Extension) diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuHSVColourPicker.cs b/osu.Game/Graphics/UserInterfaceV2/OsuHSVColourPicker.cs index 0189b30aad..ff51f3aa92 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuHSVColourPicker.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuHSVColourPicker.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -14,7 +16,7 @@ using osuTK; namespace osu.Game.Graphics.UserInterfaceV2 { - public class OsuHSVColourPicker : HSVColourPicker + public partial class OsuHSVColourPicker : HSVColourPicker { private const float spacing = 10; private const float corner_radius = 10; @@ -40,7 +42,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 Colour = Colour4.Black.Opacity(0.3f) }; - private class OsuHueSelector : HueSelector + private partial class OsuHueSelector : HueSelector { public OsuHueSelector() { @@ -50,7 +52,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 protected override Drawable CreateSliderNub() => new SliderNub(this); - private class SliderNub : CompositeDrawable + private partial class SliderNub : CompositeDrawable { private readonly Bindable hue; private readonly Box fill; @@ -83,7 +85,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 } } - private class OsuSaturationValueSelector : SaturationValueSelector + private partial class OsuSaturationValueSelector : SaturationValueSelector { public OsuSaturationValueSelector() { @@ -93,7 +95,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 protected override Marker CreateMarker() => new OsuMarker(); - private class OsuMarker : Marker + private partial class OsuMarker : Marker { private readonly Box previewBox; diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuHexColourPicker.cs b/osu.Game/Graphics/UserInterfaceV2/OsuHexColourPicker.cs index 5368a800bc..9aa650d88d 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuHexColourPicker.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuHexColourPicker.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osu.Game.Overlays; namespace osu.Game.Graphics.UserInterfaceV2 { - public class OsuHexColourPicker : HexColourPicker + public partial class OsuHexColourPicker : HexColourPicker { public OsuHexColourPicker() { @@ -29,7 +31,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 protected override TextBox CreateHexCodeTextBox() => new OsuTextBox(); protected override ColourPreview CreateColourPreview() => new OsuColourPreview(); - private class OsuColourPreview : ColourPreview + private partial class OsuColourPreview : ColourPreview { private readonly Box preview; diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs b/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs index 085541b3ef..d89322cecd 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; @@ -12,10 +15,11 @@ using osu.Framework.Input.Events; using osu.Game.Input.Bindings; using osu.Game.Overlays; using osuTK; +using osuTK.Input; namespace osu.Game.Graphics.UserInterfaceV2 { - public class OsuPopover : Popover, IKeyBindingHandler + public partial class OsuPopover : Popover, IKeyBindingHandler { private const float fade_duration = 250; private const double scale_duration = 500; @@ -56,6 +60,14 @@ namespace osu.Game.Graphics.UserInterfaceV2 this.FadeOut(fade_duration, Easing.OutQuint); } + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Key == Key.Escape) + return false; // disable the framework-level handling of escape key for conformity (we use GlobalAction.Back). + + return base.OnKeyDown(e); + } + public bool OnPressed(KeyBindingPressEvent e) { if (e.Repeat) @@ -66,7 +78,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 if (e.Action == GlobalAction.Back) { - Hide(); + this.HidePopover(); return true; } diff --git a/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs b/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs index cb8c63371d..6aded3fe32 100644 --- a/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs +++ b/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs @@ -2,16 +2,29 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Diagnostics; using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK.Graphics; namespace osu.Game.Graphics.UserInterfaceV2 { - public class RoundedButton : OsuButton, IFilterable + public partial class RoundedButton : OsuButton, IFilterable { + protected TrianglesV2? Triangles { get; private set; } + + protected override float HoverLayerFinalAlpha => 0; + + private Color4? triangleGradientSecondColour; + public override float Height { get => base.Height; @@ -24,19 +37,64 @@ namespace osu.Game.Graphics.UserInterfaceV2 } } - [BackgroundDependencyLoader(true)] - private void load(OsuColour colours) + public override Color4 BackgroundColour { - // According to flyte, buttons are supposed to have explicit colours for now. - // Not sure this is the correct direction, but we haven't decided on an `OverlayColourProvider` stand-in yet. - // This is a better default. See `SettingsButton` for an override which uses `OverlayColourProvider`. - DefaultBackgroundColour = colours.Blue3; + get => base.BackgroundColour; + set + { + base.BackgroundColour = value; + triangleGradientSecondColour = BackgroundColour.Lighten(0.2f); + updateColours(); + } + } + + [BackgroundDependencyLoader(true)] + private void load(OverlayColourProvider? overlayColourProvider, OsuColour colours) + { + // Many buttons have local colours, but this provides a sane default for all other cases. + DefaultBackgroundColour = overlayColourProvider?.Colour3 ?? colours.Blue3; + triangleGradientSecondColour ??= overlayColourProvider?.Colour1 ?? colours.Blue3.Lighten(0.2f); } protected override void LoadComplete() { base.LoadComplete(); + updateCornerRadius(); + + Add(Triangles = new TrianglesV2 + { + Thickness = 0.02f, + SpawnRatio = 0.6f, + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue, + }); + + updateColours(); + } + + private void updateColours() + { + if (Triangles == null) + return; + + Debug.Assert(triangleGradientSecondColour != null); + + Triangles.Colour = ColourInfo.GradientVertical(triangleGradientSecondColour.Value, BackgroundColour); + } + + protected override bool OnHover(HoverEvent e) + { + Debug.Assert(triangleGradientSecondColour != null); + + Background.FadeColour(triangleGradientSecondColour.Value, 300, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + Background.FadeColour(BackgroundColour, 300, Easing.OutQuint); + base.OnHoverLost(e); } private void updateCornerRadius() => Content.CornerRadius = DrawHeight / 2; diff --git a/osu.Game/Graphics/UserInterfaceV2/SwitchButton.cs b/osu.Game/Graphics/UserInterfaceV2/SwitchButton.cs index f483e67b27..cf569a73ca 100644 --- a/osu.Game/Graphics/UserInterfaceV2/SwitchButton.cs +++ b/osu.Game/Graphics/UserInterfaceV2/SwitchButton.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 enable - using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -20,7 +18,7 @@ using osuTK.Graphics; namespace osu.Game.Graphics.UserInterfaceV2 { - public class SwitchButton : Checkbox + public partial class SwitchButton : Checkbox { private const float border_thickness = 4.5f; private const float padding = 1.25f; @@ -130,7 +128,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 circularContainer.TransformBorderTo((Current.Value ? enabledColour : disabledColour).Lighten(IsHovered ? 0.3f : 0)); } - private class CircularBorderContainer : CircularContainer + private partial class CircularBorderContainer : CircularContainer { public void TransformBorderTo(ColourInfo colour) => this.TransformTo(nameof(BorderColour), colour, 250, Easing.OutQuint); diff --git a/osu.Game/IO/Archives/ArchiveReader.cs b/osu.Game/IO/Archives/ArchiveReader.cs index dab70eaf70..91165261b0 100644 --- a/osu.Game/IO/Archives/ArchiveReader.cs +++ b/osu.Game/IO/Archives/ArchiveReader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.IO; using System.Threading; diff --git a/osu.Game/IO/Archives/LegacyByteArrayReader.cs b/osu.Game/IO/Archives/LegacyByteArrayReader.cs index ea8ff3bbe0..e58dbb48ce 100644 --- a/osu.Game/IO/Archives/LegacyByteArrayReader.cs +++ b/osu.Game/IO/Archives/LegacyByteArrayReader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.IO; diff --git a/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs b/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs index dfae58aed7..e26f6af081 100644 --- a/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs +++ b/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.IO; using System.Linq; diff --git a/osu.Game/IO/Archives/LegacyFileArchiveReader.cs b/osu.Game/IO/Archives/LegacyFileArchiveReader.cs index 72e5a21079..aee1add2f6 100644 --- a/osu.Game/IO/Archives/LegacyFileArchiveReader.cs +++ b/osu.Game/IO/Archives/LegacyFileArchiveReader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.IO; diff --git a/osu.Game/IO/Archives/ZipArchiveReader.cs b/osu.Game/IO/Archives/ZipArchiveReader.cs index ae2b85da51..1fca8aa055 100644 --- a/osu.Game/IO/Archives/ZipArchiveReader.cs +++ b/osu.Game/IO/Archives/ZipArchiveReader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Buffers; using System.Collections.Generic; using System.IO; diff --git a/osu.Game/IO/FileAbstraction/StreamFileAbstraction.cs b/osu.Game/IO/FileAbstraction/StreamFileAbstraction.cs index f5709b5158..d47f936eb3 100644 --- a/osu.Game/IO/FileAbstraction/StreamFileAbstraction.cs +++ b/osu.Game/IO/FileAbstraction/StreamFileAbstraction.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.IO; @@ -21,8 +23,7 @@ namespace osu.Game.IO.FileAbstraction public void CloseStream(Stream stream) { - if (stream == null) - throw new ArgumentNullException(nameof(stream)); + ArgumentNullException.ThrowIfNull(stream); stream.Close(); } diff --git a/osu.Game/IO/FileInfo.cs b/osu.Game/IO/FileInfo.cs index 148afba40d..3d32e7fb6d 100644 --- a/osu.Game/IO/FileInfo.cs +++ b/osu.Game/IO/FileInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Database; namespace osu.Game.IO diff --git a/osu.Game/IO/HardLinkHelper.cs b/osu.Game/IO/HardLinkHelper.cs new file mode 100644 index 0000000000..619bfdad6e --- /dev/null +++ b/osu.Game/IO/HardLinkHelper.cs @@ -0,0 +1,190 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; +using Microsoft.Win32.SafeHandles; +using osu.Framework; + +namespace osu.Game.IO +{ + internal static class HardLinkHelper + { + public static bool CheckAvailability(string testDestinationPath, string testSourcePath) + { + // For simplicity, only support desktop operating systems for now. + if (!RuntimeInfo.IsDesktop) + return false; + + const string test_filename = "_hard_link_test"; + + testDestinationPath = Path.Combine(testDestinationPath, test_filename); + testSourcePath = Path.Combine(testSourcePath, test_filename); + + cleanupFiles(); + + try + { + File.WriteAllText(testSourcePath, string.Empty); + + // Test availability by creating an arbitrary hard link between the source and destination paths. + return TryCreateHardLink(testDestinationPath, testSourcePath); + } + catch + { + return false; + } + finally + { + cleanupFiles(); + } + + void cleanupFiles() + { + try + { + File.Delete(testDestinationPath); + File.Delete(testSourcePath); + } + catch + { + } + } + } + + /// + /// Attempts to create a hard link from to , + /// using platform-specific native methods. + /// + /// + /// Hard links are only available on desktop platforms. + /// + /// Whether the hard link was successfully created. + public static bool TryCreateHardLink(string destinationPath, string sourcePath) + { + switch (RuntimeInfo.OS) + { + case RuntimeInfo.Platform.Windows: + return CreateHardLink(destinationPath, sourcePath, IntPtr.Zero); + + case RuntimeInfo.Platform.Linux: + case RuntimeInfo.Platform.macOS: + return link(sourcePath, destinationPath) == 0; + + default: + return false; + } + } + + // For future use (to detect if a file is a hard link with other references existing on disk). + public static int GetFileLinkCount(string filePath) + { + int result = 0; + + switch (RuntimeInfo.OS) + { + case RuntimeInfo.Platform.Windows: + SafeFileHandle handle = CreateFile(filePath, FileAccess.Read, FileShare.Read, IntPtr.Zero, FileMode.Open, FileAttributes.Archive, IntPtr.Zero); + + ByHandleFileInformation fileInfo; + + if (GetFileInformationByHandle(handle, out fileInfo)) + result = (int)fileInfo.NumberOfLinks; + CloseHandle(handle); + break; + + case RuntimeInfo.Platform.Linux: + case RuntimeInfo.Platform.macOS: + if (stat(filePath, out var statbuf) == 0) + result = (int)statbuf.st_nlink; + + break; + } + + return result; + } + + #region Windows native methods + + [DllImport("Kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool CreateHardLink(string lpFileName, string lpExistingFileName, IntPtr lpSecurityAttributes); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + private static extern SafeFileHandle CreateFile( + string lpFileName, + [MarshalAs(UnmanagedType.U4)] FileAccess dwDesiredAccess, + [MarshalAs(UnmanagedType.U4)] FileShare dwShareMode, + IntPtr lpSecurityAttributes, + [MarshalAs(UnmanagedType.U4)] FileMode dwCreationDisposition, + [MarshalAs(UnmanagedType.U4)] FileAttributes dwFlagsAndAttributes, + IntPtr hTemplateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle(SafeFileHandle handle, out ByHandleFileInformation lpFileInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(SafeHandle hObject); + + [StructLayout(LayoutKind.Sequential)] + private struct ByHandleFileInformation + { + public readonly uint FileAttributes; + public readonly FILETIME CreationTime; + public readonly FILETIME LastAccessTime; + public readonly FILETIME LastWriteTime; + public readonly uint VolumeSerialNumber; + public readonly uint FileSizeHigh; + public readonly uint FileSizeLow; + public readonly uint NumberOfLinks; + public readonly uint FileIndexHigh; + public readonly uint FileIndexLow; + } + + #endregion + + #region Linux native methods + +#pragma warning disable IDE1006 // Naming rule violation + + [DllImport("libc", SetLastError = true)] + public static extern int link(string oldpath, string newpath); + + [DllImport("libc", SetLastError = true)] + private static extern int stat(string pathname, out struct_stat statbuf); + + // ReSharper disable once InconsistentNaming + // Struct layout is likely non-portable across unices. Tread with caution. + [StructLayout(LayoutKind.Sequential)] + private struct struct_stat + { + public readonly long st_dev; + public readonly long st_ino; + public readonly long st_nlink; + public readonly int st_mode; + public readonly int st_uid; + public readonly int st_gid; + public readonly long st_rdev; + public readonly long st_size; + public readonly long st_blksize; + public readonly long st_blocks; + public readonly timespec st_atim; + public readonly timespec st_mtim; + public readonly timespec st_ctim; + } + + // ReSharper disable once InconsistentNaming + [StructLayout(LayoutKind.Sequential)] + private struct timespec + { + public readonly long tv_sec; + public readonly long tv_nsec; + } + +#pragma warning restore IDE1006 + + #endregion + } +} diff --git a/osu.Game/IO/IFileInfo.cs b/osu.Game/IO/IFileInfo.cs index 080d8e57f5..932ea31e25 100644 --- a/osu.Game/IO/IFileInfo.cs +++ b/osu.Game/IO/IFileInfo.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 enable - namespace osu.Game.IO { /// diff --git a/osu.Game/IO/IStorageResourceProvider.cs b/osu.Game/IO/IStorageResourceProvider.cs index b381ac70b0..08982a8b5f 100644 --- a/osu.Game/IO/IStorageResourceProvider.cs +++ b/osu.Game/IO/IStorageResourceProvider.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Audio; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Game.Database; @@ -10,10 +11,15 @@ namespace osu.Game.IO { public interface IStorageResourceProvider { + /// + /// The game renderer. + /// + IRenderer Renderer { get; } + /// /// Retrieve the game-wide audio manager. /// - AudioManager AudioManager { get; } + AudioManager? AudioManager { get; } /// /// Access game-wide user files. diff --git a/osu.Game/IO/Legacy/ILegacySerializable.cs b/osu.Game/IO/Legacy/ILegacySerializable.cs index 0124ea756a..f21e67a34b 100644 --- a/osu.Game/IO/Legacy/ILegacySerializable.cs +++ b/osu.Game/IO/Legacy/ILegacySerializable.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.IO.Legacy { public interface ILegacySerializable diff --git a/osu.Game/IO/Legacy/SerializationReader.cs b/osu.Game/IO/Legacy/SerializationReader.cs index 5423485c95..2d3d5bffd5 100644 --- a/osu.Game/IO/Legacy/SerializationReader.cs +++ b/osu.Game/IO/Legacy/SerializationReader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.IO; diff --git a/osu.Game/IO/Legacy/SerializationWriter.cs b/osu.Game/IO/Legacy/SerializationWriter.cs index c9fff05bcc..10572a6478 100644 --- a/osu.Game/IO/Legacy/SerializationWriter.cs +++ b/osu.Game/IO/Legacy/SerializationWriter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.IO; diff --git a/osu.Game/IO/LineBufferedReader.cs b/osu.Game/IO/LineBufferedReader.cs index a6b8c9492a..da1cdba73b 100644 --- a/osu.Game/IO/LineBufferedReader.cs +++ b/osu.Game/IO/LineBufferedReader.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.IO; using System.Text; @@ -15,34 +14,31 @@ namespace osu.Game.IO public class LineBufferedReader : IDisposable { private readonly StreamReader streamReader; - private readonly Queue lineBuffer; + + private string? peekedLine; public LineBufferedReader(Stream stream, bool leaveOpen = false) { streamReader = new StreamReader(stream, Encoding.UTF8, true, 1024, leaveOpen); - lineBuffer = new Queue(); } /// /// Reads the next line from the stream without consuming it. /// Subsequent calls to without a will return the same string. /// - public string PeekLine() - { - if (lineBuffer.Count > 0) - return lineBuffer.Peek(); - - string line = streamReader.ReadLine(); - if (line != null) - lineBuffer.Enqueue(line); - return line; - } + public string? PeekLine() => peekedLine ??= streamReader.ReadLine(); /// /// Reads the next line from the stream and consumes it. /// If a line was peeked, that same line will then be consumed and returned. /// - public string ReadLine() => lineBuffer.Count > 0 ? lineBuffer.Dequeue() : streamReader.ReadLine(); + public string? ReadLine() + { + string? line = peekedLine ?? streamReader.ReadLine(); + + peekedLine = null; + return line; + } /// /// Reads the stream to its end and returns the text read. @@ -51,14 +47,13 @@ namespace osu.Game.IO public string ReadToEnd() { string remainingText = streamReader.ReadToEnd(); - if (lineBuffer.Count == 0) + if (peekedLine == null) return remainingText; var builder = new StringBuilder(); // this might not be completely correct due to varying platform line endings - while (lineBuffer.Count > 0) - builder.AppendLine(lineBuffer.Dequeue()); + builder.AppendLine(peekedLine); builder.Append(remainingText); return builder.ToString(); @@ -66,7 +61,7 @@ namespace osu.Game.IO public void Dispose() { - streamReader?.Dispose(); + streamReader.Dispose(); } } } diff --git a/osu.Game/IO/MigratableStorage.cs b/osu.Game/IO/MigratableStorage.cs index e478144294..14a3c5a43c 100644 --- a/osu.Game/IO/MigratableStorage.cs +++ b/osu.Game/IO/MigratableStorage.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.IO; using System.Linq; @@ -24,6 +26,11 @@ namespace osu.Game.IO /// public virtual string[] IgnoreFiles => Array.Empty(); + /// + /// A list of file/directory suffixes which should not be migrated. + /// + public virtual string[] IgnoreSuffixes => Array.Empty(); + protected MigratableStorage(Storage storage, string subPath = null) : base(storage, subPath) { @@ -71,6 +78,9 @@ namespace osu.Game.IO if (topLevelExcludes && IgnoreFiles.Contains(fi.Name)) continue; + if (IgnoreSuffixes.Any(suffix => fi.Name.EndsWith(suffix, StringComparison.Ordinal))) + continue; + allFilesDeleted &= AttemptOperation(() => fi.Delete(), throwOnFailure: false); } @@ -79,6 +89,9 @@ namespace osu.Game.IO if (topLevelExcludes && IgnoreDirectories.Contains(dir.Name)) continue; + if (IgnoreSuffixes.Any(suffix => dir.Name.EndsWith(suffix, StringComparison.Ordinal))) + continue; + allFilesDeleted &= AttemptOperation(() => dir.Delete(true), throwOnFailure: false); } @@ -94,12 +107,25 @@ namespace osu.Game.IO if (!destination.Exists) Directory.CreateDirectory(destination.FullName); - foreach (System.IO.FileInfo fi in source.GetFiles()) + foreach (System.IO.FileInfo fileInfo in source.GetFiles()) { - if (topLevelExcludes && IgnoreFiles.Contains(fi.Name)) + if (topLevelExcludes && IgnoreFiles.Contains(fileInfo.Name)) continue; - AttemptOperation(() => fi.CopyTo(Path.Combine(destination.FullName, fi.Name), true)); + if (IgnoreSuffixes.Any(suffix => fileInfo.Name.EndsWith(suffix, StringComparison.Ordinal))) + continue; + + AttemptOperation(() => + { + fileInfo.Refresh(); + + // A temporary file may have been deleted since the initial GetFiles operation. + // We don't want the whole migration process to fail in such a case. + if (!fileInfo.Exists) + return; + + fileInfo.CopyTo(Path.Combine(destination.FullName, fileInfo.Name), true); + }); } foreach (DirectoryInfo dir in source.GetDirectories()) @@ -107,6 +133,9 @@ namespace osu.Game.IO if (topLevelExcludes && IgnoreDirectories.Contains(dir.Name)) continue; + if (IgnoreSuffixes.Any(suffix => dir.Name.EndsWith(suffix, StringComparison.Ordinal))) + continue; + CopyRecursive(dir, destination.CreateSubdirectory(dir.Name), false); } } diff --git a/osu.Game/IO/OsuStorage.cs b/osu.Game/IO/OsuStorage.cs index c49365a9de..f4c55e4b0e 100644 --- a/osu.Game/IO/OsuStorage.cs +++ b/osu.Game/IO/OsuStorage.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Diagnostics; using System.Linq; using JetBrains.Annotations; @@ -36,15 +38,20 @@ namespace osu.Game.IO public override string[] IgnoreDirectories => new[] { "cache", - $"{OsuGameBase.CLIENT_DATABASE_FILENAME}.management", }; public override string[] IgnoreFiles => new[] { "framework.ini", "storage.ini", - $"{OsuGameBase.CLIENT_DATABASE_FILENAME}.note", - $"{OsuGameBase.CLIENT_DATABASE_FILENAME}.lock", + }; + + public override string[] IgnoreSuffixes => new[] + { + // Realm pipe files don't play well with copy operations + ".note", + ".lock", + ".management", }; public OsuStorage(GameHost host, Storage defaultStorage) @@ -92,6 +99,8 @@ namespace osu.Game.IO error = OsuStorageError.None; Storage lastStorage = UnderlyingStorage; + Logger.Log($"Attempting to use custom storage location {CustomStoragePath}"); + try { Storage userStorage = host.GetStorage(CustomStoragePath); @@ -100,6 +109,7 @@ namespace osu.Game.IO error = OsuStorageError.AccessibleButEmpty; ChangeTargetStorage(userStorage); + Logger.Log($"Storage successfully changed to {CustomStoragePath}."); } catch { @@ -107,6 +117,9 @@ namespace osu.Game.IO ChangeTargetStorage(lastStorage); } + if (error != OsuStorageError.None) + Logger.Log($"Custom storage location could not be used ({error})."); + return error == OsuStorageError.None; } diff --git a/osu.Game/IO/Serialization/Converters/SnakeCaseStringEnumConverter.cs b/osu.Game/IO/Serialization/Converters/SnakeCaseStringEnumConverter.cs index 1d82a5bc87..65283d0d82 100644 --- a/osu.Game/IO/Serialization/Converters/SnakeCaseStringEnumConverter.cs +++ b/osu.Game/IO/Serialization/Converters/SnakeCaseStringEnumConverter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Newtonsoft.Json.Converters; using Newtonsoft.Json.Serialization; diff --git a/osu.Game/IO/Serialization/Converters/TypedListConverter.cs b/osu.Game/IO/Serialization/Converters/TypedListConverter.cs index 715c83b07e..de25d3e30e 100644 --- a/osu.Game/IO/Serialization/Converters/TypedListConverter.cs +++ b/osu.Game/IO/Serialization/Converters/TypedListConverter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using Newtonsoft.Json; @@ -61,7 +63,7 @@ namespace osu.Game.IO.Serialization.Converters throw new JsonException("Expected $type token."); string typeName = lookupTable[(int)tok["$type"]]; - var instance = (T)Activator.CreateInstance(Type.GetType(typeName).AsNonNull()); + var instance = (T)Activator.CreateInstance(Type.GetType(typeName).AsNonNull())!; serializer.Populate(itemReader, instance); list.Add(instance); diff --git a/osu.Game/IO/Serialization/JsonSerializableExtensions.cs b/osu.Game/IO/Serialization/JsonSerializableExtensions.cs index 68b2110ede..bdf42205d4 100644 --- a/osu.Game/IO/Serialization/JsonSerializableExtensions.cs +++ b/osu.Game/IO/Serialization/JsonSerializableExtensions.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using Newtonsoft.Json; using osu.Framework.IO.Serialization; diff --git a/osu.Game/IO/Serialization/SnakeCaseKeyContractResolver.cs b/osu.Game/IO/Serialization/SnakeCaseKeyContractResolver.cs index 68cb66d0dc..b51a8473ca 100644 --- a/osu.Game/IO/Serialization/SnakeCaseKeyContractResolver.cs +++ b/osu.Game/IO/Serialization/SnakeCaseKeyContractResolver.cs @@ -1,8 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using Humanizer; +#nullable disable + using Newtonsoft.Json.Serialization; +using osu.Game.Extensions; namespace osu.Game.IO.Serialization { @@ -10,7 +12,7 @@ namespace osu.Game.IO.Serialization { protected override string ResolvePropertyName(string propertyName) { - return propertyName.Underscore(); + return propertyName.ToSnakeCase(); } } } diff --git a/osu.Game/IO/StableStorage.cs b/osu.Game/IO/StableStorage.cs index 84b7da91fc..41c7495b42 100644 --- a/osu.Game/IO/StableStorage.cs +++ b/osu.Game/IO/StableStorage.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.IO; using System.Linq; @@ -14,7 +16,7 @@ namespace osu.Game.IO /// public class StableStorage : DesktopStorage { - private const string stable_default_songs_path = "Songs"; + public const string STABLE_DEFAULT_SONGS_PATH = "Songs"; private readonly DesktopGameHost host; private readonly Lazy songsPath; @@ -62,7 +64,7 @@ namespace osu.Game.IO } } - return GetFullPath(stable_default_songs_path); + return GetFullPath(STABLE_DEFAULT_SONGS_PATH); } } } diff --git a/osu.Game/IO/WrappedStorage.cs b/osu.Game/IO/WrappedStorage.cs index 7205ea3adb..95ff26db6a 100644 --- a/osu.Game/IO/WrappedStorage.cs +++ b/osu.Game/IO/WrappedStorage.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.IO; diff --git a/osu.Game/IPC/ArchiveImportIPCChannel.cs b/osu.Game/IPC/ArchiveImportIPCChannel.cs index f381aad39a..02b1f72700 100644 --- a/osu.Game/IPC/ArchiveImportIPCChannel.cs +++ b/osu.Game/IPC/ArchiveImportIPCChannel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Diagnostics; using System.IO; using System.Linq; diff --git a/osu.Game/IPC/IPCTimeoutException.cs b/osu.Game/IPC/IPCTimeoutException.cs new file mode 100644 index 0000000000..d820184468 --- /dev/null +++ b/osu.Game/IPC/IPCTimeoutException.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +namespace osu.Game.IPC +{ + public class IPCTimeoutException : TimeoutException + { + public IPCTimeoutException(Type channelType) + : base($@"IPC took too long to send message via channel {channelType}") + { + } + } +} diff --git a/osu.Game/IPC/OsuSchemeLinkIPCChannel.cs b/osu.Game/IPC/OsuSchemeLinkIPCChannel.cs new file mode 100644 index 0000000000..33318e329c --- /dev/null +++ b/osu.Game/IPC/OsuSchemeLinkIPCChannel.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using System.Threading.Tasks; +using osu.Framework.Platform; +using osu.Game.Online; + +namespace osu.Game.IPC +{ + public class OsuSchemeLinkIPCChannel : IpcChannel + { + private readonly ILinkHandler? linkHandler; + + public OsuSchemeLinkIPCChannel(IIpcHost host, ILinkHandler? linkHandler = null) + : base(host) + { + this.linkHandler = linkHandler; + + MessageReceived += msg => + { + Debug.Assert(linkHandler != null); + linkHandler.HandleLink(msg.Link); + return null; + }; + } + + public async Task HandleLinkAsync(string url) + { + if (linkHandler == null) + { + await SendMessageAsync(new OsuSchemeLinkMessage(url)).ConfigureAwait(false); + return; + } + + linkHandler.HandleLink(url); + } + } + + public class OsuSchemeLinkMessage + { + public string Link { get; } + + public OsuSchemeLinkMessage(string link) + { + Link = link; + } + } +} diff --git a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs index ba129b93e5..fab0be6cf0 100644 --- a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs +++ b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -16,7 +18,7 @@ namespace osu.Game.Input.Bindings /// A KeyBindingInputManager with a database backing for custom overrides. /// /// The type of the custom action. - public class DatabasedKeyBindingContainer : KeyBindingContainer + public partial class DatabasedKeyBindingContainer : KeyBindingContainer where T : struct { private readonly RulesetInfo ruleset; @@ -49,17 +51,17 @@ namespace osu.Game.Input.Bindings protected override void LoadComplete() { - realmSubscription = realm.RegisterForNotifications(queryRealmKeyBindings, (sender, changes, error) => + realmSubscription = realm.RegisterForNotifications(queryRealmKeyBindings, (sender, _, _) => { // The first fire of this is a bit redundant as this is being called in base.LoadComplete, // but this is safest in case the subscription is restored after a context recycle. - reloadMappings(sender.AsQueryable()); + ReloadMappings(sender.AsQueryable()); }); base.LoadComplete(); } - protected override void ReloadMappings() => reloadMappings(queryRealmKeyBindings(realm.Realm)); + protected sealed override void ReloadMappings() => ReloadMappings(queryRealmKeyBindings(realm.Realm)); private IQueryable queryRealmKeyBindings(Realm realm) { @@ -68,7 +70,7 @@ namespace osu.Game.Input.Bindings .Where(b => b.RulesetName == rulesetName && b.Variant == variant); } - private void reloadMappings(IQueryable realmKeyBindings) + protected virtual void ReloadMappings(IQueryable realmKeyBindings) { var defaults = DefaultKeyBindings.ToList(); diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index 3da5f3212e..d580eea248 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -11,12 +11,13 @@ using osu.Game.Localisation; namespace osu.Game.Input.Bindings { - public class GlobalActionContainer : DatabasedKeyBindingContainer, IHandleGlobalKeyboardInput + public partial class GlobalActionContainer : DatabasedKeyBindingContainer, IHandleGlobalKeyboardInput { - private readonly Drawable handler; - private InputManager parentInputManager; + private readonly Drawable? handler; - public GlobalActionContainer(OsuGameBase game) + private InputManager? parentInputManager; + + public GlobalActionContainer(OsuGameBase? game) : base(matchingMode: KeyCombinationMatchingMode.Modifiers) { if (game is IKeyBindingHandler) @@ -30,32 +31,21 @@ namespace osu.Game.Input.Bindings parentInputManager = GetContainingInputManager(); } + // IMPORTANT: Take care when changing order of the items in the enumerable. + // It is used to decide the order of precedence, with the earlier items having higher precedence. public override IEnumerable DefaultKeyBindings => GlobalKeyBindings .Concat(EditorKeyBindings) .Concat(InGameKeyBindings) + .Concat(ReplayKeyBindings) .Concat(SongSelectKeyBindings) - .Concat(AudioControlKeyBindings); + .Concat(AudioControlKeyBindings) + // Overlay bindings may conflict with more local cases like the editor so they are checked last. + // It has generally been agreed on that local screens like the editor should have priority, + // based on such usages potentially requiring a lot more key bindings that may be "shared" with global ones. + .Concat(OverlayKeyBindings); public IEnumerable GlobalKeyBindings => new[] { - new KeyBinding(InputKey.F6, GlobalAction.ToggleNowPlaying), - new KeyBinding(InputKey.F8, GlobalAction.ToggleChat), - new KeyBinding(InputKey.F9, GlobalAction.ToggleSocial), - new KeyBinding(InputKey.F10, GlobalAction.ToggleGameplayMouseButtons), - new KeyBinding(InputKey.F12, GlobalAction.TakeScreenshot), - - new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.R }, GlobalAction.ResetInputSettings), - new KeyBinding(new[] { InputKey.Control, InputKey.T }, GlobalAction.ToggleToolbar), - new KeyBinding(new[] { InputKey.Control, InputKey.O }, GlobalAction.ToggleSettings), - new KeyBinding(new[] { InputKey.Control, InputKey.D }, GlobalAction.ToggleBeatmapListing), - new KeyBinding(new[] { InputKey.Control, InputKey.N }, GlobalAction.ToggleNotifications), - new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.S }, GlobalAction.ToggleSkinEditor), - - new KeyBinding(InputKey.Escape, GlobalAction.Back), - new KeyBinding(InputKey.ExtraMouseButton1, GlobalAction.Back), - - new KeyBinding(new[] { InputKey.Alt, InputKey.Home }, GlobalAction.Home), - new KeyBinding(InputKey.Up, GlobalAction.SelectPrevious), new KeyBinding(InputKey.Down, GlobalAction.SelectNext), @@ -66,7 +56,32 @@ namespace osu.Game.Input.Bindings new KeyBinding(InputKey.Enter, GlobalAction.Select), new KeyBinding(InputKey.KeypadEnter, GlobalAction.Select), + new KeyBinding(InputKey.Escape, GlobalAction.Back), + new KeyBinding(InputKey.ExtraMouseButton1, GlobalAction.Back), + + new KeyBinding(new[] { InputKey.Alt, InputKey.Home }, GlobalAction.Home), + + new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.F }, GlobalAction.ToggleFPSDisplay), + new KeyBinding(new[] { InputKey.Control, InputKey.T }, GlobalAction.ToggleToolbar), + new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.S }, GlobalAction.ToggleSkinEditor), + new KeyBinding(new[] { InputKey.Control, InputKey.P }, GlobalAction.ToggleProfile), + + new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.R }, GlobalAction.ResetInputSettings), + new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.R }, GlobalAction.RandomSkin), + + new KeyBinding(InputKey.F10, GlobalAction.ToggleGameplayMouseButtons), + new KeyBinding(InputKey.F12, GlobalAction.TakeScreenshot), + }; + + public IEnumerable OverlayKeyBindings => new[] + { + new KeyBinding(InputKey.F8, GlobalAction.ToggleChat), + new KeyBinding(InputKey.F6, GlobalAction.ToggleNowPlaying), + new KeyBinding(InputKey.F9, GlobalAction.ToggleSocial), + new KeyBinding(new[] { InputKey.Control, InputKey.B }, GlobalAction.ToggleBeatmapListing), + new KeyBinding(new[] { InputKey.Control, InputKey.O }, GlobalAction.ToggleSettings), + new KeyBinding(new[] { InputKey.Control, InputKey.N }, GlobalAction.ToggleNotifications), }; public IEnumerable EditorKeyBindings => new[] @@ -76,6 +91,7 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.F3 }, GlobalAction.EditorTimingMode), new KeyBinding(new[] { InputKey.F4 }, GlobalAction.EditorSetupMode), new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.A }, GlobalAction.EditorVerifyMode), + new KeyBinding(new[] { InputKey.Control, InputKey.D }, GlobalAction.EditorCloneSelection), new KeyBinding(new[] { InputKey.J }, GlobalAction.EditorNudgeLeft), new KeyBinding(new[] { InputKey.K }, GlobalAction.EditorNudgeRight), new KeyBinding(new[] { InputKey.G }, GlobalAction.EditorCycleGridDisplayMode), @@ -97,13 +113,18 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.F4 }, GlobalAction.IncreaseScrollSpeed), new KeyBinding(new[] { InputKey.Shift, InputKey.Tab }, GlobalAction.ToggleInGameInterface), new KeyBinding(InputKey.MouseMiddle, GlobalAction.PauseGameplay), - new KeyBinding(InputKey.Space, GlobalAction.TogglePauseReplay), - new KeyBinding(InputKey.Left, GlobalAction.SeekReplayBackward), - new KeyBinding(InputKey.Right, GlobalAction.SeekReplayForward), new KeyBinding(InputKey.Control, GlobalAction.HoldForHUD), new KeyBinding(InputKey.Tab, GlobalAction.ToggleChatFocus), }; + public IEnumerable ReplayKeyBindings => new[] + { + new KeyBinding(InputKey.Space, GlobalAction.TogglePauseReplay), + new KeyBinding(InputKey.MouseMiddle, GlobalAction.TogglePauseReplay), + new KeyBinding(InputKey.Left, GlobalAction.SeekReplayBackward), + new KeyBinding(InputKey.Right, GlobalAction.SeekReplayForward), + }; + public IEnumerable SongSelectKeyBindings => new[] { new KeyBinding(InputKey.F1, GlobalAction.ToggleModSelection), @@ -326,5 +347,14 @@ namespace osu.Game.Input.Bindings [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorTapForBPM))] EditorTapForBPM, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleFPSCounter))] + ToggleFPSDisplay, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleProfile))] + ToggleProfile, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorCloneSelection))] + EditorCloneSelection } } diff --git a/osu.Game/Input/Bindings/RealmKeyBinding.cs b/osu.Game/Input/Bindings/RealmKeyBinding.cs index c941319ddb..4af0357535 100644 --- a/osu.Game/Input/Bindings/RealmKeyBinding.cs +++ b/osu.Game/Input/Bindings/RealmKeyBinding.cs @@ -7,8 +7,6 @@ using osu.Framework.Input.Bindings; using osu.Game.Database; using Realms; -#nullable enable - namespace osu.Game.Input.Bindings { [MapTo(nameof(KeyBinding))] diff --git a/osu.Game/Input/ConfineMouseTracker.cs b/osu.Game/Input/ConfineMouseTracker.cs index d2bf953dbc..de8660dbce 100644 --- a/osu.Game/Input/ConfineMouseTracker.cs +++ b/osu.Game/Input/ConfineMouseTracker.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Configuration; @@ -16,7 +18,7 @@ namespace osu.Game.Input /// If is true, we should also confine the mouse cursor if it has been /// requested with . /// - public class ConfineMouseTracker : Component + public partial class ConfineMouseTracker : Component { private Bindable frameworkConfineMode; private Bindable frameworkWindowMode; diff --git a/osu.Game/Input/GameIdleTracker.cs b/osu.Game/Input/GameIdleTracker.cs index 260be7e5c9..560ec1bc1e 100644 --- a/osu.Game/Input/GameIdleTracker.cs +++ b/osu.Game/Input/GameIdleTracker.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Input; namespace osu.Game.Input { - public class GameIdleTracker : IdleTracker + public partial class GameIdleTracker : IdleTracker { private InputManager inputManager; diff --git a/osu.Game/Input/Handlers/ReplayInputHandler.cs b/osu.Game/Input/Handlers/ReplayInputHandler.cs index 205a1ea1ac..712e0acb60 100644 --- a/osu.Game/Input/Handlers/ReplayInputHandler.cs +++ b/osu.Game/Input/Handlers/ReplayInputHandler.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; diff --git a/osu.Game/Input/IdleTracker.cs b/osu.Game/Input/IdleTracker.cs index e2f13309cf..54157c9e3d 100644 --- a/osu.Game/Input/IdleTracker.cs +++ b/osu.Game/Input/IdleTracker.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -15,7 +17,7 @@ namespace osu.Game.Input /// /// Track whether the end-user is in an idle state, based on their last interaction with the game. /// - public class IdleTracker : Component, IKeyBindingHandler, IKeyBindingHandler, IHandleGlobalKeyboardInput + public partial class IdleTracker : Component, IKeyBindingHandler, IKeyBindingHandler, IHandleGlobalKeyboardInput { private readonly double timeToIdle; @@ -78,11 +80,11 @@ namespace osu.Game.Input switch (e) { - case KeyDownEvent _: - case KeyUpEvent _: - case MouseDownEvent _: - case MouseUpEvent _: - case MouseMoveEvent _: + case KeyDownEvent: + case KeyUpEvent: + case MouseDownEvent: + case MouseUpEvent: + case MouseMoveEvent: return updateLastInteractionTime(); default: diff --git a/osu.Game/Input/OsuConfineMouseMode.cs b/osu.Game/Input/OsuConfineMouseMode.cs index a4a1c9eb46..2d914ac6e0 100644 --- a/osu.Game/Input/OsuConfineMouseMode.cs +++ b/osu.Game/Input/OsuConfineMouseMode.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.ComponentModel; +#nullable disable + using osu.Framework.Input; +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Input { @@ -15,18 +18,20 @@ namespace osu.Game.Input /// /// The mouse cursor will be free to move outside the game window. /// + [LocalisableDescription(typeof(MouseSettingsStrings), nameof(MouseSettingsStrings.NeverConfine))] Never, /// /// The mouse cursor will be locked to the window bounds during gameplay, /// but may otherwise move freely. /// - [Description("During Gameplay")] + [LocalisableDescription(typeof(MouseSettingsStrings), nameof(MouseSettingsStrings.ConfineDuringGameplay))] DuringGameplay, /// /// The mouse cursor will always be locked to the window bounds while the game has focus. /// + [LocalisableDescription(typeof(MouseSettingsStrings), nameof(MouseSettingsStrings.AlwaysConfine))] Always } } diff --git a/osu.Game/Input/OsuUserInputManager.cs b/osu.Game/Input/OsuUserInputManager.cs index 621f1e1fc9..ab43497156 100644 --- a/osu.Game/Input/OsuUserInputManager.cs +++ b/osu.Game/Input/OsuUserInputManager.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Input; using osuTK.Input; namespace osu.Game.Input { - public class OsuUserInputManager : UserInputManager + public partial class OsuUserInputManager : UserInputManager { internal OsuUserInputManager() { diff --git a/osu.Game/Input/RealmKeyBindingStore.cs b/osu.Game/Input/RealmKeyBindingStore.cs index 20971ffca5..10ad731037 100644 --- a/osu.Game/Input/RealmKeyBindingStore.cs +++ b/osu.Game/Input/RealmKeyBindingStore.cs @@ -10,8 +10,6 @@ using osu.Game.Input.Bindings; using osu.Game.Rulesets; using Realms; -#nullable enable - namespace osu.Game.Input { public class RealmKeyBindingStore diff --git a/osu.Game/Localisation/BeatmapOverlayStrings.cs b/osu.Game/Localisation/BeatmapOverlayStrings.cs new file mode 100644 index 0000000000..fc818f7596 --- /dev/null +++ b/osu.Game/Localisation/BeatmapOverlayStrings.cs @@ -0,0 +1,33 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class BeatmapOverlayStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.BeatmapOverlayStrings"; + + /// + /// "User content disclaimer" + /// + public static LocalisableString UserContentDisclaimerHeader => new TranslatableString(getKey(@"user_content_disclaimer"), @"User content disclaimer"); + + /// + /// "By turning off the "Featured Artist" filter, all user-uploaded content will be displayed. + /// + /// This includes content that may not be correctly licensed for osu! usage. Browse at your own risk." + /// + public static LocalisableString UserContentDisclaimerDescription => new TranslatableString(getKey(@"by_turning_off_the_featured"), @"By turning off the ""Featured Artist"" filter, all user-uploaded content will be displayed. + +This includes content that may not be correctly licensed for osu! usage. Browse at your own risk."); + + /// + /// "I understand" + /// + public static LocalisableString UserContentConfirmButtonText => new TranslatableString(getKey(@"understood"), @"I understand"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/ChatStrings.cs b/osu.Game/Localisation/ChatStrings.cs index 7bd284a94e..6b0a6bd8e1 100644 --- a/osu.Game/Localisation/ChatStrings.cs +++ b/osu.Game/Localisation/ChatStrings.cs @@ -19,6 +19,11 @@ namespace osu.Game.Localisation /// public static LocalisableString HeaderDescription => new TranslatableString(getKey(@"header_description"), @"join the real-time discussion"); + /// + /// "Mention" + /// + public static LocalisableString MentionUser => new TranslatableString(getKey(@"mention_user"), @"Mention"); + private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/CommonStrings.cs b/osu.Game/Localisation/CommonStrings.cs index 1fd677034d..fed7b6cab7 100644 --- a/osu.Game/Localisation/CommonStrings.cs +++ b/osu.Game/Localisation/CommonStrings.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; @@ -89,6 +89,76 @@ namespace osu.Game.Localisation /// public static LocalisableString Collections => new TranslatableString(getKey(@"collections"), @"Collections"); + /// + /// "Mod presets" + /// + public static LocalisableString ModPresets => new TranslatableString(getKey(@"mod_presets"), @"Mod presets"); + + /// + /// "Name" + /// + public static LocalisableString Name => new TranslatableString(getKey(@"name"), @"Name"); + + /// + /// "Description" + /// + public static LocalisableString Description => new TranslatableString(getKey(@"description"), @"Description"); + + /// + /// "File" + /// + public static LocalisableString MenuBarFile => new TranslatableString(getKey(@"menu_bar_file"), @"File"); + + /// + /// "Edit" + /// + public static LocalisableString MenuBarEdit => new TranslatableString(getKey(@"menu_bar_edit"), @"Edit"); + + /// + /// "View" + /// + public static LocalisableString MenuBarView => new TranslatableString(getKey(@"menu_bar_view"), @"View"); + + /// + /// "Undo" + /// + public static LocalisableString Undo => new TranslatableString(getKey(@"undo"), @"Undo"); + + /// + /// "Redo" + /// + public static LocalisableString Redo => new TranslatableString(getKey(@"redo"), @"Redo"); + + /// + /// "Cut" + /// + public static LocalisableString Cut => new TranslatableString(getKey(@"cut"), @"Cut"); + + /// + /// "Copy" + /// + public static LocalisableString Copy => new TranslatableString(getKey(@"copy"), @"Copy"); + + /// + /// "Paste" + /// + public static LocalisableString Paste => new TranslatableString(getKey(@"paste"), @"Paste"); + + /// + /// "Clone" + /// + public static LocalisableString Clone => new TranslatableString(getKey(@"clone"), @"Clone"); + + /// + /// "Exit" + /// + public static LocalisableString Exit => new TranslatableString(getKey(@"exit"), @"Exit"); + + /// + /// "Revert to default" + /// + public static LocalisableString RevertToDefault => new TranslatableString(getKey(@"revert_to_default"), @"Revert to default"); + private static string getKey(string key) => $@"{prefix}:{key}"; } -} \ No newline at end of file +} diff --git a/osu.Game/Localisation/ContextMenuStrings.cs b/osu.Game/Localisation/ContextMenuStrings.cs new file mode 100644 index 0000000000..8bc213016b --- /dev/null +++ b/osu.Game/Localisation/ContextMenuStrings.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class ContextMenuStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.ContextMenu"; + + /// + /// "View profile" + /// + public static LocalisableString ViewProfile => new TranslatableString(getKey(@"view_profile"), @"View profile"); + + /// + /// "View beatmap" + /// + public static LocalisableString ViewBeatmap => new TranslatableString(getKey(@"view_beatmap"), @"View beatmap"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/DebugSettingsStrings.cs b/osu.Game/Localisation/DebugSettingsStrings.cs index 74b2c8d892..dd21739096 100644 --- a/osu.Game/Localisation/DebugSettingsStrings.cs +++ b/osu.Game/Localisation/DebugSettingsStrings.cs @@ -44,11 +44,6 @@ namespace osu.Game.Localisation /// public static LocalisableString ClearAllCaches => new TranslatableString(getKey(@"clear_all_caches"), @"Clear all caches"); - /// - /// "Compact realm" - /// - public static LocalisableString CompactRealm => new TranslatableString(getKey(@"compact_realm"), @"Compact realm"); - private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/DeleteConfirmationDialogStrings.cs b/osu.Game/Localisation/DeleteConfirmationDialogStrings.cs new file mode 100644 index 0000000000..33738fe95e --- /dev/null +++ b/osu.Game/Localisation/DeleteConfirmationDialogStrings.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class DeleteConfirmationDialogStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.DeleteConfirmationDialog"; + + /// + /// "Confirm deletion of" + /// + public static LocalisableString HeaderText => new TranslatableString(getKey(@"header_text"), @"Confirm deletion of"); + + /// + /// "Yes. Go for it." + /// + public static LocalisableString Confirm => new TranslatableString(getKey(@"confirm"), @"Yes. Go for it."); + + /// + /// "No! Abort mission" + /// + public static LocalisableString Cancel => new TranslatableString(getKey(@"cancel"), @"No! Abort mission"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/DifficultyMultiplierDisplayStrings.cs b/osu.Game/Localisation/DifficultyMultiplierDisplayStrings.cs index c281d90190..952ca22678 100644 --- a/osu.Game/Localisation/DifficultyMultiplierDisplayStrings.cs +++ b/osu.Game/Localisation/DifficultyMultiplierDisplayStrings.cs @@ -16,4 +16,4 @@ namespace osu.Game.Localisation private static string getKey(string key) => $@"{prefix}:{key}"; } -} \ No newline at end of file +} diff --git a/osu.Game/Localisation/EditorDialogsStrings.cs b/osu.Game/Localisation/EditorDialogsStrings.cs new file mode 100644 index 0000000000..fc4c2b7f2a --- /dev/null +++ b/osu.Game/Localisation/EditorDialogsStrings.cs @@ -0,0 +1,54 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class EditorDialogsStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.EditorDialogs"; + + /// + /// "Would you like to create a blank difficulty?" + /// + public static LocalisableString NewDifficultyDialogHeader => new TranslatableString(getKey(@"new_difficulty_dialog_header"), @"Would you like to create a blank difficulty?"); + + /// + /// "Yeah, let's start from scratch!" + /// + public static LocalisableString CreateNew => new TranslatableString(getKey(@"create_new"), @"Yeah, let's start from scratch!"); + + /// + /// "No, create an exact copy of this difficulty" + /// + public static LocalisableString CreateCopy => new TranslatableString(getKey(@"create_copy"), @"No, create an exact copy of this difficulty"); + + /// + /// "I changed my mind, I want to keep editing this difficulty" + /// + public static LocalisableString KeepEditing => new TranslatableString(getKey(@"keep_editing"), @"I changed my mind, I want to keep editing this difficulty"); + + /// + /// "Did you want to save your changes?" + /// + public static LocalisableString SaveDialogHeader => new TranslatableString(getKey(@"save_dialog_header"), @"Did you want to save your changes?"); + + /// + /// "Save my masterpiece!" + /// + public static LocalisableString Save => new TranslatableString(getKey(@"save"), @"Save my masterpiece!"); + + /// + /// "Forget all changes" + /// + public static LocalisableString ForgetAllChanges => new TranslatableString(getKey(@"forget_all_changes"), @"Forget all changes"); + + /// + /// "Oops, continue editing" + /// + public static LocalisableString ContinueEditing => new TranslatableString(getKey(@"continue_editing"), @"Oops, continue editing"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/EditorSetupStrings.cs b/osu.Game/Localisation/EditorSetupStrings.cs new file mode 100644 index 0000000000..4ddacf2c5b --- /dev/null +++ b/osu.Game/Localisation/EditorSetupStrings.cs @@ -0,0 +1,216 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class EditorSetupStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.EditorSetup"; + + /// + /// "Beatmap Setup" + /// + public static LocalisableString BeatmapSetup => new TranslatableString(getKey(@"beatmap_setup"), @"Beatmap Setup"); + + /// + /// "change general settings of your beatmap" + /// + public static LocalisableString BeatmapSetupDescription => new TranslatableString(getKey(@"beatmap_setup_description"), @"change general settings of your beatmap"); + + /// + /// "Colours" + /// + public static LocalisableString ColoursHeader => new TranslatableString(getKey(@"colours_header"), @"Colours"); + + /// + /// "Hit circle / Slider Combos" + /// + public static LocalisableString HitCircleSliderCombos => new TranslatableString(getKey(@"hit_circle_slider_combos"), @"Hit circle / Slider Combos"); + + /// + /// "Design" + /// + public static LocalisableString DesignHeader => new TranslatableString(getKey(@"design_header"), @"Design"); + + /// + /// "Enable countdown" + /// + public static LocalisableString EnableCountdown => new TranslatableString(getKey(@"enable_countdown"), @"Enable countdown"); + + /// + /// "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" + /// + public static LocalisableString CountdownSpeed => new TranslatableString(getKey(@"countdown_speed"), @"Countdown speed"); + + /// + /// "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" + /// + public static LocalisableString CountdownOffset => new TranslatableString(getKey(@"countdown_offset"), @"Countdown offset"); + + /// + /// "Widescreen support" + /// + public static LocalisableString WidescreenSupport => new TranslatableString(getKey(@"widescreen_support"), @"Widescreen support"); + + /// + /// "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" + /// + public static LocalisableString EpilepsyWarning => new TranslatableString(getKey(@"epilepsy_warning"), @"Epilepsy warning"); + + /// + /// "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" + /// + public static LocalisableString LetterboxDuringBreaks => new TranslatableString(getKey(@"letterbox_during_breaks"), @"Letterbox during breaks"); + + /// + /// "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" + /// + public static LocalisableString SamplesMatchPlaybackRate => new TranslatableString(getKey(@"samples_match_playback_rate"), @"Samples match playback rate"); + + /// + /// "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" + /// + public static LocalisableString CircleSizeDescription => new TranslatableString(getKey(@"circle_size_description"), @"The size of all hit objects"); + + /// + /// "The rate of passive health drain throughout playable time" + /// + public static LocalisableString DrainRateDescription => new TranslatableString(getKey(@"drain_rate_description"), @"The rate of passive health drain throughout playable time"); + + /// + /// "The speed at which objects are presented to the player" + /// + public static LocalisableString ApproachRateDescription => new TranslatableString(getKey(@"approach_rate_description"), @"The speed at which objects are presented to the player"); + + /// + /// "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)"); + + /// + /// "Metadata" + /// + public static LocalisableString MetadataHeader => new TranslatableString(getKey(@"metadata_header"), @"Metadata"); + + /// + /// "Romanised Artist" + /// + public static LocalisableString RomanisedArtist => new TranslatableString(getKey(@"romanised_artist"), @"Romanised Artist"); + + /// + /// "Romanised Title" + /// + public static LocalisableString RomanisedTitle => new TranslatableString(getKey(@"romanised_title"), @"Romanised Title"); + + /// + /// "Creator" + /// + public static LocalisableString Creator => new TranslatableString(getKey(@"creator"), @"Creator"); + + /// + /// "Difficulty Name" + /// + public static LocalisableString DifficultyName => new TranslatableString(getKey(@"difficulty_name"), @"Difficulty Name"); + + /// + /// "Resources" + /// + public static LocalisableString ResourcesHeader => new TranslatableString(getKey(@"resources_header"), @"Resources"); + + /// + /// "Audio Track" + /// + public static LocalisableString AudioTrack => new TranslatableString(getKey(@"audio_track"), @"Audio Track"); + + /// + /// "Click to select a track" + /// + public static LocalisableString ClickToSelectTrack => new TranslatableString(getKey(@"click_to_select_track"), @"Click to select a track"); + + /// + /// "Click to replace the track" + /// + public static LocalisableString ClickToReplaceTrack => new TranslatableString(getKey(@"click_to_replace_track"), @"Click to replace the track"); + + /// + /// "Click to select a background image" + /// + public static LocalisableString ClickToSelectBackground => new TranslatableString(getKey(@"click_to_select_background"), @"Click to select a background image"); + + /// + /// "Click to replace the background image" + /// + public static LocalisableString ClickToReplaceBackground => new TranslatableString(getKey(@"click_to_replace_background"), @"Click to replace the background image"); + + /// + /// "Ruleset ({0})" + /// + public static LocalisableString RulesetHeader(string arg0) => new TranslatableString(getKey(@"ruleset"), @"Ruleset ({0})", arg0); + + /// + /// "Combo" + /// + public static LocalisableString ComboColourPrefix => new TranslatableString(getKey(@"combo_colour_prefix"), @"Combo"); + + /// + /// "Artist" + /// + public static LocalisableString Artist => new TranslatableString(getKey(@"artist"), @"Artist"); + + /// + /// "Title" + /// + public static LocalisableString Title => new TranslatableString(getKey(@"title"), @"Title"); + + /// + /// "Difficulty" + /// + public static LocalisableString DifficultyHeader => new TranslatableString(getKey(@"difficulty_header"), @"Difficulty"); + + /// + /// "Drag image here to set beatmap background!" + /// + public static LocalisableString DragToSetBackground => new TranslatableString(getKey(@"drag_to_set_background"), @"Drag image here to set beatmap background!"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/EditorStrings.cs b/osu.Game/Localisation/EditorStrings.cs new file mode 100644 index 0000000000..20258b9c35 --- /dev/null +++ b/osu.Game/Localisation/EditorStrings.cs @@ -0,0 +1,114 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class EditorStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.Editor"; + + /// + /// "Waveform opacity" + /// + public static LocalisableString WaveformOpacity => new TranslatableString(getKey(@"waveform_opacity"), @"Waveform opacity"); + + /// + /// "Show hit markers" + /// + public static LocalisableString ShowHitMarkers => new TranslatableString(getKey(@"show_hit_markers"), @"Show hit markers"); + + /// + /// "Automatically seek after placing objects" + /// + public static LocalisableString AutoSeekOnPlacement => new TranslatableString(getKey(@"auto_seek_on_placement"), @"Automatically seek after placing objects"); + + /// + /// "Timing" + /// + public static LocalisableString Timing => new TranslatableString(getKey(@"timing"), @"Timing"); + + /// + /// "Set preview point to current time" + /// + public static LocalisableString SetPreviewPointToCurrent => new TranslatableString(getKey(@"set_preview_point_to_current"), @"Set preview point to current time"); + + /// + /// "Export package" + /// + public static LocalisableString ExportPackage => new TranslatableString(getKey(@"export_package"), @"Export package"); + + /// + /// "Create new difficulty" + /// + public static LocalisableString CreateNewDifficulty => new TranslatableString(getKey(@"create_new_difficulty"), @"Create new difficulty"); + + /// + /// "Change difficulty" + /// + public static LocalisableString ChangeDifficulty => new TranslatableString(getKey(@"change_difficulty"), @"Change difficulty"); + + /// + /// "Delete difficulty" + /// + public static LocalisableString DeleteDifficulty => new TranslatableString(getKey(@"delete_difficulty"), @"Delete difficulty"); + + /// + /// "setup" + /// + public static LocalisableString SetupScreen => new TranslatableString(getKey(@"setup_screen"), @"setup"); + + /// + /// "compose" + /// + public static LocalisableString ComposeScreen => new TranslatableString(getKey(@"compose_screen"), @"compose"); + + /// + /// "design" + /// + public static LocalisableString DesignScreen => new TranslatableString(getKey(@"design_screen"), @"design"); + + /// + /// "timing" + /// + public static LocalisableString TimingScreen => new TranslatableString(getKey(@"timing_screen"), @"timing"); + + /// + /// "verify" + /// + public static LocalisableString VerifyScreen => new TranslatableString(getKey(@"verify_screen"), @"verify"); + + /// + /// "Playback speed" + /// + public static LocalisableString PlaybackSpeed => new TranslatableString(getKey(@"playback_speed"), @"Playback speed"); + + /// + /// "Test!" + /// + public static LocalisableString TestBeatmap => new TranslatableString(getKey(@"test_beatmap"), @"Test!"); + + /// + /// "Waveform" + /// + public static LocalisableString TimelineWaveform => new TranslatableString(getKey(@"timeline_waveform"), @"Waveform"); + + /// + /// "Ticks" + /// + 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/FirstRunOverlayImportFromStableScreenStrings.cs b/osu.Game/Localisation/FirstRunOverlayImportFromStableScreenStrings.cs index deac7d8628..f0620245c3 100644 --- a/osu.Game/Localisation/FirstRunOverlayImportFromStableScreenStrings.cs +++ b/osu.Game/Localisation/FirstRunOverlayImportFromStableScreenStrings.cs @@ -15,10 +15,10 @@ namespace osu.Game.Localisation public static LocalisableString Header => new TranslatableString(getKey(@"header"), @"Import"); /// - /// "If you have an installation of a previous osu! version, you can choose to migrate your existing content. Note that this will create a copy, and not affect your existing installation." + /// "If you have an installation of a previous osu! version, you can choose to migrate your existing content. Note that this will not affect your existing installation's files in any way." /// public static LocalisableString Description => new TranslatableString(getKey(@"description"), - @"If you have an installation of a previous osu! version, you can choose to migrate your existing content. Note that this will create a copy, and not affect your existing installation."); + @"If you have an installation of a previous osu! version, you can choose to migrate your existing content. Note that this will not affect your existing installation's files in any way."); /// /// "previous osu! install" 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/GameplaySettingsStrings.cs b/osu.Game/Localisation/GameplaySettingsStrings.cs index 8a0f773551..40f39d927d 100644 --- a/osu.Game/Localisation/GameplaySettingsStrings.cs +++ b/osu.Game/Localisation/GameplaySettingsStrings.cs @@ -79,6 +79,11 @@ namespace osu.Game.Localisation /// public static LocalisableString AlwaysShowKeyOverlay => new TranslatableString(getKey(@"key_overlay"), @"Always show key overlay"); + /// + /// "Always show gameplay leaderboard" + /// + public static LocalisableString AlwaysShowGameplayLeaderboard => new TranslatableString(getKey(@"gameplay_leaderboard"), @"Always show gameplay leaderboard"); + /// /// "Always play first combo break sound" /// @@ -104,6 +109,31 @@ namespace osu.Game.Localisation /// public static LocalisableString IncreaseFirstObjectVisibility => new TranslatableString(getKey(@"increase_first_object_visibility"), @"Increase visibility of first object when visual impairment mods are enabled"); + /// + /// "Hide during gameplay" + /// + public static LocalisableString HideDuringGameplay => new TranslatableString(getKey(@"hide_during_gameplay"), @"Hide during gameplay"); + + /// + /// "Always" + /// + public static LocalisableString AlwaysShowHUD => new TranslatableString(getKey(@"always_show_hud"), @"Always"); + + /// + /// "Never" + /// + public static LocalisableString NeverShowHUD => new TranslatableString(getKey(@"never_show_hud"), @"Never"); + + /// + /// "Standardised" + /// + public static LocalisableString StandardisedScoreDisplay => new TranslatableString(getKey(@"standardised_score_display"), @"Standardised"); + + /// + /// "Classic" + /// + public static LocalisableString ClassicScoreDisplay => new TranslatableString(getKey(@"classic_score_display"), @"Classic"); + private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/GeneralSettingsStrings.cs b/osu.Game/Localisation/GeneralSettingsStrings.cs index 2aa91f5245..a525af508b 100644 --- a/osu.Game/Localisation/GeneralSettingsStrings.cs +++ b/osu.Game/Localisation/GeneralSettingsStrings.cs @@ -64,6 +64,21 @@ namespace osu.Game.Localisation /// public static LocalisableString RunSetupWizard => new TranslatableString(getKey(@"run_setup_wizard"), @"Run setup wizard"); + /// + /// "Learn more about lazer" + /// + public static LocalisableString LearnMoreAboutLazer => new TranslatableString(getKey(@"learn_more_about_lazer"), @"Learn more about lazer"); + + /// + /// "Check out the feature comparison and FAQ" + /// + public static LocalisableString LearnMoreAboutLazerTooltip => new TranslatableString(getKey(@"check_out_the_feature_comparison"), @"Check out the feature comparison and FAQ"); + + /// + /// "You are running the latest release ({0})" + /// + public static LocalisableString RunningLatestRelease(string version) => new TranslatableString(getKey(@"running_latest_release"), @"You are running the latest release ({0})", version); + private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs index 82d03dbb5b..303dbb6f46 100644 --- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs +++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs @@ -150,9 +150,14 @@ namespace osu.Game.Localisation public static LocalisableString ToggleNotifications => new TranslatableString(getKey(@"toggle_notifications"), @"Toggle notifications"); /// - /// "Pause gameplay" + /// "Toggle profile" /// - public static LocalisableString PauseGameplay => new TranslatableString(getKey(@"pause_gameplay"), @"Pause gameplay"); + public static LocalisableString ToggleProfile => new TranslatableString(getKey(@"toggle_profile"), @"Toggle profile"); + + /// + /// "Pause / resume gameplay" + /// + public static LocalisableString PauseGameplay => new TranslatableString(getKey(@"pause_gameplay"), @"Pause / resume gameplay"); /// /// "Setup mode" @@ -179,6 +184,11 @@ namespace osu.Game.Localisation /// public static LocalisableString EditorTapForBPM => new TranslatableString(getKey(@"editor_tap_for_bpm"), @"Tap for BPM"); + /// + /// "Clone selection" + /// + public static LocalisableString EditorCloneSelection => new TranslatableString(getKey(@"editor_clone_selection"), @"Clone selection"); + /// /// "Cycle grid display mode" /// @@ -274,6 +284,11 @@ namespace osu.Game.Localisation /// public static LocalisableString ToggleSkinEditor => new TranslatableString(getKey(@"toggle_skin_editor"), @"Toggle skin editor"); + /// + /// "Toggle FPS counter" + /// + public static LocalisableString ToggleFPSCounter => new TranslatableString(getKey(@"toggle_fps_counter"), @"Toggle FPS counter"); + /// /// "Previous volume meter" /// diff --git a/osu.Game/Localisation/GraphicsSettingsStrings.cs b/osu.Game/Localisation/GraphicsSettingsStrings.cs index 1c9aa64df5..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" /// @@ -34,11 +39,6 @@ namespace osu.Game.Localisation /// public static LocalisableString ShowFPS => new TranslatableString(getKey(@"show_fps"), @"Show FPS"); - /// - /// "Using unlimited frame limiter can lead to stutters, bad performance and overheating. It will not improve perceived latency. "2x refresh rate" is recommended." - /// - public static LocalisableString UnlimitedFramesNote => new TranslatableString(getKey(@"unlimited_frames_note"), @"Using unlimited frame limiter can lead to stutters, bad performance and overheating. It will not improve perceived latency. ""2x refresh rate"" is recommended."); - /// /// "Layout" /// @@ -104,6 +104,11 @@ namespace osu.Game.Localisation /// public static LocalisableString StoryboardVideo => new TranslatableString(getKey(@"storyboard_video"), @"Storyboard / video"); + /// + /// "Combo colour normalisation" + /// + public static LocalisableString ComboColourNormalisation => new TranslatableString(getKey(@"combo_colour_normalisation"), @"Combo colour normalisation"); + /// /// "Hit lighting" /// @@ -134,6 +139,22 @@ namespace osu.Game.Localisation /// public static LocalisableString UseHardwareAcceleration => new TranslatableString(getKey(@"use_hardware_acceleration"), @"Use hardware acceleration"); + /// + /// "JPG (web-friendly)" + /// + public static LocalisableString Jpg => new TranslatableString(getKey(@"jpg_web_friendly"), @"JPG (web-friendly)"); + + /// + /// "PNG (lossless)" + /// + 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/HUD/BarHitErrorMeterStrings.cs b/osu.Game/Localisation/HUD/BarHitErrorMeterStrings.cs new file mode 100644 index 0000000000..2f77a287a0 --- /dev/null +++ b/osu.Game/Localisation/HUD/BarHitErrorMeterStrings.cs @@ -0,0 +1,89 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation.HUD +{ + public static class BarHitErrorMeterStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.HUD.BarHitErrorMeter"; + + /// + /// "Judgement line thickness" + /// + public static LocalisableString JudgementLineThickness => new TranslatableString(getKey(@"judgement_line_thickness"), "Judgement line thickness"); + + /// + /// "How thick the individual lines should be." + /// + public static LocalisableString JudgementLineThicknessDescription => new TranslatableString(getKey(@"judgement_line_thickness_description"), "How thick the individual lines should be."); + + /// + /// "Show colour bars" + /// + public static LocalisableString ColourBarVisibility => new TranslatableString(getKey(@"colour_bar_visibility"), "Show colour bars"); + + /// + /// "Show moving average arrow" + /// + public static LocalisableString ShowMovingAverage => new TranslatableString(getKey(@"show_moving_average"), "Show moving average arrow"); + + /// + /// "Whether an arrow should move beneath the bar showing the average error." + /// + public static LocalisableString ShowMovingAverageDescription => new TranslatableString(getKey(@"show_moving_average_description"), "Whether an arrow should move beneath the bar showing the average error."); + + /// + /// "Centre marker style" + /// + public static LocalisableString CentreMarkerStyle => new TranslatableString(getKey(@"centre_marker_style"), "Centre marker style"); + + /// + /// "How to signify the centre of the display" + /// + public static LocalisableString CentreMarkerStyleDescription => new TranslatableString(getKey(@"centre_marker_style_description"), "How to signify the centre of the display"); + + /// + /// "None" + /// + public static LocalisableString CentreMarkerStylesNone => new TranslatableString(getKey(@"centre_marker_styles_none"), "None"); + + /// + /// "Circle" + /// + public static LocalisableString CentreMarkerStylesCircle => new TranslatableString(getKey(@"centre_marker_styles_circle"), "Circle"); + + /// + /// "Line" + /// + public static LocalisableString CentreMarkerStylesLine => new TranslatableString(getKey(@"centre_marker_styles_line"), "Line"); + + /// + /// "Label style" + /// + public static LocalisableString LabelStyle => new TranslatableString(getKey(@"label_style"), "Label style"); + + /// + /// "How to show early/late extremities" + /// + public static LocalisableString LabelStyleDescription => new TranslatableString(getKey(@"label_style_description"), "How to show early/late extremities"); + + /// + /// "None" + /// + public static LocalisableString LabelStylesNone => new TranslatableString(getKey(@"label_styles_none"), "None"); + + /// + /// "Icons" + /// + public static LocalisableString LabelStylesIcons => new TranslatableString(getKey(@"label_styles_icons"), "Icons"); + + /// + /// "Text" + /// + public static LocalisableString LabelStylesText => new TranslatableString(getKey(@"label_styles_text"), "Text"); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/HUD/ColourHitErrorMeterStrings.cs b/osu.Game/Localisation/HUD/ColourHitErrorMeterStrings.cs new file mode 100644 index 0000000000..8fdcb34a49 --- /dev/null +++ b/osu.Game/Localisation/HUD/ColourHitErrorMeterStrings.cs @@ -0,0 +1,54 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation.HUD +{ + public static class ColourHitErrorMeterStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.HUD.ColourHitError"; + + /// + /// "Judgement count" + /// + public static LocalisableString JudgementCount => new TranslatableString(getKey(@"judgement_count"), "Judgement count"); + + /// + /// "The number of displayed judgements" + /// + public static LocalisableString JudgementCountDescription => new TranslatableString(getKey(@"judgement_count_description"), "The number of displayed judgements"); + + /// + /// "Judgement spacing" + /// + public static LocalisableString JudgementSpacing => new TranslatableString(getKey(@"judgement_spacing"), "Judgement spacing"); + + /// + /// "The space between each displayed judgement" + /// + public static LocalisableString JudgementSpacingDescription => new TranslatableString(getKey(@"judgement_spacing_description"), "The space between each displayed judgement"); + + /// + /// "Judgement shape" + /// + public static LocalisableString JudgementShape => new TranslatableString(getKey(@"judgement_shape"), "Judgement shape"); + + /// + /// "The shape of each displayed judgement" + /// + public static LocalisableString JudgementShapeDescription => new TranslatableString(getKey(@"judgement_shape_description"), "The shape of each displayed judgement"); + + /// + /// "Circle" + /// + public static LocalisableString ShapeStyleCircle => new TranslatableString(getKey(@"shape_style_cricle"), "Circle"); + + /// + /// "Square" + /// + public static LocalisableString ShapeStyleSquare => new TranslatableString(getKey(@"shape_style_square"), "Square"); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/HUD/GameplayAccuracyCounterStrings.cs b/osu.Game/Localisation/HUD/GameplayAccuracyCounterStrings.cs new file mode 100644 index 0000000000..ec7f4a1af3 --- /dev/null +++ b/osu.Game/Localisation/HUD/GameplayAccuracyCounterStrings.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation.HUD +{ + public static class GameplayAccuracyCounterStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.HUD.GameplayAccuracyCounter"; + + /// + /// "Accuracy display mode" + /// + public static LocalisableString AccuracyDisplay => new TranslatableString(getKey(@"accuracy_display"), "Accuracy display mode"); + + /// + /// "Which accuracy mode should be displayed." + /// + public static LocalisableString AccuracyDisplayDescription => new TranslatableString(getKey(@"accuracy_display_description"), "Which accuracy mode should be displayed."); + + /// + /// "Standard" + /// + public static LocalisableString AccuracyDisplayModeStandard => new TranslatableString(getKey(@"accuracy_display_mode_standard"), "Standard"); + + /// + /// "Maximum achievable" + /// + public static LocalisableString AccuracyDisplayModeMax => new TranslatableString(getKey(@"accuracy_display_mode_max"), "Maximum achievable"); + + /// + /// "Minimum achievable" + /// + public static LocalisableString AccuracyDisplayModeMin => new TranslatableString(getKey(@"accuracy_display_mode_min"), "Minimum achievable"); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/HUD/JudgementCounterDisplayStrings.cs b/osu.Game/Localisation/HUD/JudgementCounterDisplayStrings.cs new file mode 100644 index 0000000000..b1c756e48e --- /dev/null +++ b/osu.Game/Localisation/HUD/JudgementCounterDisplayStrings.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.HUD +{ + public static class JudgementCounterDisplayStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.HUD.JudgementCounterDisplay"; + + /// + /// "Display mode" + /// + public static LocalisableString JudgementDisplayMode => new TranslatableString(getKey(@"judgement_display_mode"), "Display mode"); + + /// + /// "Counter direction" + /// + public static LocalisableString FlowDirection => new TranslatableString(getKey(@"flow_direction"), "Counter direction"); + + /// + /// "Show judgement names" + /// + public static LocalisableString ShowJudgementNames => new TranslatableString(getKey(@"show_judgement_names"), "Show judgement names"); + + /// + /// "Show max judgement" + /// + public static LocalisableString ShowMaxJudgement => new TranslatableString(getKey(@"show_max_judgement"), "Show max judgement"); + + /// + /// "Simple" + /// + public static LocalisableString JudgementDisplayModeSimple => new TranslatableString(getKey(@"judgement_display_mode_simple"), "Simple"); + + /// + /// "Normal" + /// + public static LocalisableString JudgementDisplayModeNormal => new TranslatableString(getKey(@"judgement_display_mode_normal"), "Normal"); + + /// + /// "All" + /// + public static LocalisableString JudgementDisplayModeAll => new TranslatableString(getKey(@"judgement_display_mode_all"), "All"); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/HUD/SongProgressStrings.cs b/osu.Game/Localisation/HUD/SongProgressStrings.cs new file mode 100644 index 0000000000..4c621e8e8c --- /dev/null +++ b/osu.Game/Localisation/HUD/SongProgressStrings.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation.HUD +{ + public static class SongProgressStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.HUD.SongProgress"; + + /// + /// "Show difficulty graph" + /// + public static LocalisableString ShowGraph => new TranslatableString(getKey(@"show_graph"), "Show difficulty graph"); + + /// + /// "Whether a graph displaying difficulty throughout the beatmap should be shown" + /// + public static LocalisableString ShowGraphDescription => new TranslatableString(getKey(@"show_graph_description"), "Whether a graph displaying difficulty throughout the beatmap should be shown"); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/InputSettingsStrings.cs b/osu.Game/Localisation/InputSettingsStrings.cs index e46b4cecf3..2c9b175dfb 100644 --- a/osu.Game/Localisation/InputSettingsStrings.cs +++ b/osu.Game/Localisation/InputSettingsStrings.cs @@ -19,6 +19,11 @@ namespace osu.Game.Localisation /// public static LocalisableString GlobalKeyBindingHeader => new TranslatableString(getKey(@"global_key_binding_header"), @"Global"); + /// + /// "Overlays" + /// + public static LocalisableString OverlaysSection => new TranslatableString(getKey(@"overlays_section"), @"Overlays"); + /// /// "Song Select" /// @@ -29,6 +34,11 @@ namespace osu.Game.Localisation /// public static LocalisableString InGameSection => new TranslatableString(getKey(@"in_game_section"), @"In Game"); + /// + /// "Replay" + /// + public static LocalisableString ReplaySection => new TranslatableString(getKey(@"replay_section"), @"Replay"); + /// /// "Audio" /// diff --git a/osu.Game/Localisation/Language.cs b/osu.Game/Localisation/Language.cs index c13a1a10cb..6a4e5110e6 100644 --- a/osu.Game/Localisation/Language.cs +++ b/osu.Game/Localisation/Language.cs @@ -2,9 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using System.ComponentModel; +using JetBrains.Annotations; namespace osu.Game.Localisation { + [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public enum Language { [Description(@"English")] diff --git a/osu.Game/Localisation/LayoutSettingsStrings.cs b/osu.Game/Localisation/LayoutSettingsStrings.cs index b4326b8e39..a5172ec774 100644 --- a/osu.Game/Localisation/LayoutSettingsStrings.cs +++ b/osu.Game/Localisation/LayoutSettingsStrings.cs @@ -15,20 +15,40 @@ namespace osu.Game.Localisation public static LocalisableString CheckingForFullscreenCapabilities => new TranslatableString(getKey(@"checking_for_fullscreen_capabilities"), @"Checking for fullscreen capabilities..."); /// - /// "osu! is running exclusive fullscreen, guaranteeing low latency!" + /// "osu! is running in exclusive fullscreen, guaranteeing low latency!" /// - public static LocalisableString OsuIsRunningExclusiveFullscreen => new TranslatableString(getKey(@"osu_is_running_exclusive_fullscreen"), @"osu! is running exclusive fullscreen, guaranteeing low latency!"); + public static LocalisableString OsuIsRunningExclusiveFullscreen => new TranslatableString(getKey(@"osu_is_running_exclusive_fullscreen"), @"osu! is running in exclusive fullscreen, guaranteeing low latency!"); /// - /// "Unable to run exclusive fullscreen. You'll still experience some input latency." + /// "Unable to run in exclusive fullscreen. You may experience some input latency." /// - public static LocalisableString UnableToRunExclusiveFullscreen => new TranslatableString(getKey(@"unable_to_run_exclusive_fullscreen"), @"Unable to run exclusive fullscreen. You'll still experience some input latency."); + public static LocalisableString UnableToRunExclusiveFullscreen => new TranslatableString(getKey(@"unable_to_run_exclusive_fullscreen"), @"Unable to run in exclusive fullscreen. You may experience some input latency."); /// /// "Using fullscreen on macOS makes interacting with the menu bar and spaces no longer work, and may lead to freezes if a system dialog is presented. Using borderless is recommended." /// public static LocalisableString FullscreenMacOSNote => new TranslatableString(getKey(@"fullscreen_macos_note"), @"Using fullscreen on macOS makes interacting with the menu bar and spaces no longer work, and may lead to freezes if a system dialog is presented. Using borderless is recommended."); + /// + /// "Excluding overlays" + /// + public static LocalisableString ScaleEverythingExcludingOverlays => new TranslatableString(getKey(@"scale_everything_excluding_overlays"), @"Excluding overlays"); + + /// + /// "Everything" + /// + public static LocalisableString ScaleEverything => new TranslatableString(getKey(@"scale_everything"), @"Everything"); + + /// + /// "Gameplay" + /// + public static LocalisableString ScaleGameplay => new TranslatableString(getKey(@"scale_gameplay"), @"Gameplay"); + + /// + /// "Off" + /// + public static LocalisableString ScalingOff => new TranslatableString(getKey(@"scaling_off"), @"Off"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/MaintenanceSettingsStrings.cs b/osu.Game/Localisation/MaintenanceSettingsStrings.cs index 7a04bcd1ca..469f565f1e 100644 --- a/osu.Game/Localisation/MaintenanceSettingsStrings.cs +++ b/osu.Game/Localisation/MaintenanceSettingsStrings.cs @@ -20,9 +20,39 @@ namespace osu.Game.Localisation public static LocalisableString SelectDirectory => new TranslatableString(getKey(@"select_directory"), @"Select directory"); /// - /// "Import beatmaps from stable" + /// "Migration in progress" /// - public static LocalisableString ImportBeatmapsFromStable => new TranslatableString(getKey(@"import_beatmaps_from_stable"), @"Import beatmaps from stable"); + public static LocalisableString MigrationInProgress => new TranslatableString(getKey(@"migration_in_progress"), @"Migration in progress"); + + /// + /// "This could take a few minutes depending on the speed of your disk(s)." + /// + public static LocalisableString MigrationDescription => new TranslatableString(getKey(@"migration_description"), @"This could take a few minutes depending on the speed of your disk(s)."); + + /// + /// "Please avoid interacting with the game!" + /// + public static LocalisableString ProhibitedInteractDuringMigration => new TranslatableString(getKey(@"prohibited_interact_during_migration"), @"Please avoid interacting with the game!"); + + /// + /// "Some files couldn't be cleaned up during migration. Clicking this notification will open the folder so you can manually clean things up." + /// + public static LocalisableString FailedCleanupNotification => new TranslatableString(getKey(@"failed_cleanup_notification"), @"Some files couldn't be cleaned up during migration. Clicking this notification will open the folder so you can manually clean things up."); + + /// + /// "Please select a new location" + /// + public static LocalisableString SelectNewLocation => new TranslatableString(getKey(@"select_new_location"), @"Please select a new location"); + + /// + /// "The target directory already seems to have an osu! install. Use that data instead?" + /// + public static LocalisableString TargetDirectoryAlreadyInstalledOsu => new TranslatableString(getKey(@"target_directory_already_installed_osu"), @"The target directory already seems to have an osu! install. Use that data instead?"); + + /// + /// "To complete this operation, osu! will close. Please open it again to use the new data location." + /// + public static LocalisableString RestartAndReOpenRequiredForCompletion => new TranslatableString(getKey(@"restart_and_re_open_required_for_completion"), @"To complete this operation, osu! will close. Please open it again to use the new data location."); /// /// "Delete ALL beatmaps" @@ -34,31 +64,16 @@ namespace osu.Game.Localisation /// public static LocalisableString DeleteAllBeatmapVideos => new TranslatableString(getKey(@"delete_all_beatmap_videos"), @"Delete ALL beatmap videos"); - /// - /// "Import scores from stable" - /// - public static LocalisableString ImportScoresFromStable => new TranslatableString(getKey(@"import_scores_from_stable"), @"Import scores from stable"); - /// /// "Delete ALL scores" /// public static LocalisableString DeleteAllScores => new TranslatableString(getKey(@"delete_all_scores"), @"Delete ALL scores"); - /// - /// "Import skins from stable" - /// - public static LocalisableString ImportSkinsFromStable => new TranslatableString(getKey(@"import_skins_from_stable"), @"Import skins from stable"); - /// /// "Delete ALL skins" /// public static LocalisableString DeleteAllSkins => new TranslatableString(getKey(@"delete_all_skins"), @"Delete ALL skins"); - /// - /// "Import collections from stable" - /// - public static LocalisableString ImportCollectionsFromStable => new TranslatableString(getKey(@"import_collections_from_stable"), @"Import collections from stable"); - /// /// "Delete ALL collections" /// @@ -74,6 +89,36 @@ namespace osu.Game.Localisation /// public static LocalisableString RestoreAllRecentlyDeletedBeatmaps => new TranslatableString(getKey(@"restore_all_recently_deleted_beatmaps"), @"Restore all recently deleted beatmaps"); + /// + /// "Delete ALL mod presets" + /// + public static LocalisableString DeleteAllModPresets => new TranslatableString(getKey(@"delete_all_mod_presets"), @"Delete ALL mod presets"); + + /// + /// "Restore all recently deleted mod presets" + /// + public static LocalisableString RestoreAllRecentlyDeletedModPresets => new TranslatableString(getKey(@"restore_all_recently_deleted_mod_presets"), @"Restore all recently deleted mod presets"); + + /// + /// "Deleted all collections!" + /// + public static LocalisableString DeletedAllCollections => new TranslatableString(getKey(@"deleted_all_collections"), @"Deleted all collections!"); + + /// + /// "Deleted all mod presets!" + /// + public static LocalisableString DeletedAllModPresets => new TranslatableString(getKey(@"deleted_all_mod_presets"), @"Deleted all mod presets!"); + + /// + /// "Restored all deleted mod presets!" + /// + public static LocalisableString RestoredAllDeletedModPresets => new TranslatableString(getKey(@"restored_all_deleted_mod_presets"), @"Restored all deleted mod presets!"); + + /// + /// "Please select your osu!stable install location" + /// + public static LocalisableString StableDirectorySelectHeader => new TranslatableString(getKey(@"stable_directory_select_header"), @"Please select your osu!stable install location"); + private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/ModSelectOverlayStrings.cs b/osu.Game/Localisation/ModSelectOverlayStrings.cs index e9af7147e3..d6a01c4794 100644 --- a/osu.Game/Localisation/ModSelectOverlayStrings.cs +++ b/osu.Game/Localisation/ModSelectOverlayStrings.cs @@ -24,6 +24,16 @@ namespace osu.Game.Localisation /// public static LocalisableString ModCustomisation => new TranslatableString(getKey(@"mod_customisation"), @"Mod Customisation"); + /// + /// "Personal Presets" + /// + public static LocalisableString PersonalPresets => new TranslatableString(getKey(@"personal_presets"), @"Personal Presets"); + + /// + /// "Add preset" + /// + public static LocalisableString AddPreset => new TranslatableString(getKey(@"add_preset"), @"Add preset"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/MouseSettingsStrings.cs b/osu.Game/Localisation/MouseSettingsStrings.cs index fd7225ad2e..1772f03b29 100644 --- a/osu.Game/Localisation/MouseSettingsStrings.cs +++ b/osu.Game/Localisation/MouseSettingsStrings.cs @@ -64,6 +64,21 @@ namespace osu.Game.Localisation /// public static LocalisableString HighPrecisionPlatformWarning => new TranslatableString(getKey(@"high_precision_platform_warning"), @"This setting has known issues on your platform. If you encounter problems, it is recommended to adjust sensitivity externally and keep this disabled for now."); + /// + /// "Always" + /// + public static LocalisableString AlwaysConfine => new TranslatableString(getKey(@"always_confine"), @"Always"); + + /// + /// "During Gameplay" + /// + public static LocalisableString ConfineDuringGameplay => new TranslatableString(getKey(@"confine_during_gameplay"), @"During Gameplay"); + + /// + /// "Never" + /// + public static LocalisableString NeverConfine => new TranslatableString(getKey(@"never_confine"), @"Never"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/MultiplayerTeamResultsScreenStrings.cs b/osu.Game/Localisation/MultiplayerTeamResultsScreenStrings.cs index 111c068bbd..92cedce3e0 100644 --- a/osu.Game/Localisation/MultiplayerTeamResultsScreenStrings.cs +++ b/osu.Game/Localisation/MultiplayerTeamResultsScreenStrings.cs @@ -21,4 +21,4 @@ namespace osu.Game.Localisation private static string getKey(string key) => $@"{prefix}:{key}"; } -} \ No newline at end of file +} diff --git a/osu.Game/Localisation/NotificationsStrings.cs b/osu.Game/Localisation/NotificationsStrings.cs index 382e0d81f4..5e2600bc50 100644 --- a/osu.Game/Localisation/NotificationsStrings.cs +++ b/osu.Game/Localisation/NotificationsStrings.cs @@ -15,10 +15,54 @@ namespace osu.Game.Localisation public static LocalisableString HeaderTitle => new TranslatableString(getKey(@"header_title"), @"notifications"); /// - /// "waiting for 'ya" + /// "waiting for 'ya" /// public static LocalisableString HeaderDescription => new TranslatableString(getKey(@"header_description"), @"waiting for 'ya"); - private static string getKey(string key) => $"{prefix}:{key}"; + /// + /// "Running Tasks" + /// + public static LocalisableString RunningTasks => new TranslatableString(getKey(@"running_tasks"), @"Running Tasks"); + + /// + /// "Clear All" + /// + public static LocalisableString ClearAll => new TranslatableString(getKey(@"clear_all"), @"Clear All"); + + /// + /// "Cancel All" + /// + public static LocalisableString CancelAll => new TranslatableString(getKey(@"cancel_all"), @"Cancel All"); + + /// + /// "Your battery level is low! Charge your device to prevent interruptions during gameplay." + /// + public static LocalisableString BatteryLow => new TranslatableString(getKey(@"battery_low"), @"Your battery level is low! Charge your device to prevent interruptions during gameplay."); + + /// + /// "Your game volume is too low to hear anything! Click here to restore it." + /// + public static LocalisableString GameVolumeTooLow => new TranslatableString(getKey(@"game_volume_too_low"), @"Your game volume is too low to hear anything! Click here to restore it."); + + /// + /// "The current ruleset doesn't have an autoplay mod available!" + /// + 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. + /// + /// 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. + +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); + + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/OnlineSettingsStrings.cs b/osu.Game/Localisation/OnlineSettingsStrings.cs index 6862f4ac2c..3200b1c75c 100644 --- a/osu.Game/Localisation/OnlineSettingsStrings.cs +++ b/osu.Game/Localisation/OnlineSettingsStrings.cs @@ -64,6 +64,21 @@ namespace osu.Game.Localisation /// public static LocalisableString ShowExplicitContent => new TranslatableString(getKey(@"show_explicit_content"), @"Show explicit content in search results"); + /// + /// "Hide identifiable information" + /// + public static LocalisableString HideIdentifiableInformation => new TranslatableString(getKey(@"hide_identifiable_information"), @"Hide identifiable information"); + + /// + /// "Full" + /// + public static LocalisableString DiscordPresenceFull => new TranslatableString(getKey(@"discord_presence_full"), @"Full"); + + /// + /// "Off" + /// + public static LocalisableString DiscordPresenceOff => new TranslatableString(getKey(@"discord_presence_off"), @"Off"); + private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/PopupDialogStrings.cs b/osu.Game/Localisation/PopupDialogStrings.cs new file mode 100644 index 0000000000..b2e9673cbe --- /dev/null +++ b/osu.Game/Localisation/PopupDialogStrings.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class PopupDialogStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.PopupDialog"; + + /// + /// "Are you sure you want to update this beatmap?" + /// + public static LocalisableString UpdateLocallyModifiedText => new TranslatableString(getKey(@"update_locally_modified_text"), @"Are you sure you want to update this beatmap?"); + + /// + /// "This will discard all local changes you have on that beatmap." + /// + public static LocalisableString UpdateLocallyModifiedDescription => new TranslatableString(getKey(@"update_locally_modified_description"), @"This will discard all local changes you have on that beatmap."); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/ResourceManagerLocalisationStore.cs b/osu.Game/Localisation/ResourceManagerLocalisationStore.cs index 0fb85e4a19..3fa86c188c 100644 --- a/osu.Game/Localisation/ResourceManagerLocalisationStore.cs +++ b/osu.Game/Localisation/ResourceManagerLocalisationStore.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Globalization; @@ -63,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 a356c9e20b..1b0df6ecf6 100644 --- a/osu.Game/Localisation/RulesetSettingsStrings.cs +++ b/osu.Game/Localisation/RulesetSettingsStrings.cs @@ -14,6 +14,71 @@ namespace osu.Game.Localisation /// public static LocalisableString Rulesets => new TranslatableString(getKey(@"rulesets"), @"Rulesets"); + /// + /// "Snaking in sliders" + /// + public static LocalisableString SnakingInSliders => new TranslatableString(getKey(@"snaking_in_sliders"), @"Snaking in sliders"); + + /// + /// "Snaking out sliders" + /// + public static LocalisableString SnakingOutSliders => new TranslatableString(getKey(@"snaking_out_sliders"), @"Snaking out sliders"); + + /// + /// "Cursor trail" + /// + public static LocalisableString CursorTrail => new TranslatableString(getKey(@"cursor_trail"), @"Cursor trail"); + + /// + /// "Playfield border style" + /// + public static LocalisableString PlayfieldBorderStyle => new TranslatableString(getKey(@"playfield_border_style"), @"Playfield border style"); + + /// + /// "None" + /// + public static LocalisableString BorderNone => new TranslatableString(getKey(@"no_borders"), @"None"); + + /// + /// "Corners" + /// + public static LocalisableString BorderCorners => new TranslatableString(getKey(@"corner_borders"), @"Corners"); + + /// + /// "Full" + /// + public static LocalisableString BorderFull => new TranslatableString(getKey(@"full_borders"), @"Full"); + + /// + /// "Scrolling direction" + /// + public static LocalisableString ScrollingDirection => new TranslatableString(getKey(@"scrolling_direction"), @"Scrolling direction"); + + /// + /// "Up" + /// + public static LocalisableString ScrollingDirectionUp => new TranslatableString(getKey(@"scrolling_up"), @"Up"); + + /// + /// "Down" + /// + public static LocalisableString ScrollingDirectionDown => new TranslatableString(getKey(@"scrolling_down"), @"Down"); + + /// + /// "Scroll speed" + /// + public static LocalisableString ScrollSpeed => new TranslatableString(getKey(@"scroll_speed"), @"Scroll speed"); + + /// + /// "Timing-based note colouring" + /// + public static LocalisableString TimingBasedColouring => new TranslatableString(getKey(@"Timing_based_colouring"), @"Timing-based note colouring"); + + /// + /// "{0}ms (speed {1})" + /// + public static LocalisableString ScrollSpeedTooltip(double arg0, int arg1) => new TranslatableString(getKey(@"ruleset"), @"{0}ms (speed {1})", arg0, arg1); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/SkinComponents/BeatmapAttributeTextStrings.cs b/osu.Game/Localisation/SkinComponents/BeatmapAttributeTextStrings.cs new file mode 100644 index 0000000000..b2e2285faf --- /dev/null +++ b/osu.Game/Localisation/SkinComponents/BeatmapAttributeTextStrings.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation.SkinComponents +{ + public static class BeatmapAttributeTextStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.SkinComponents.BeatmapAttributeText"; + + /// + /// "Attribute" + /// + public static LocalisableString Attribute => new TranslatableString(getKey(@"attribute"), "Attribute"); + + /// + /// "The attribute to be displayed." + /// + public static LocalisableString AttributeDescription => new TranslatableString(getKey(@"attribute_description"), "The attribute to be displayed."); + + /// + /// "Template" + /// + public static LocalisableString Template => new TranslatableString(getKey(@"template"), "Template"); + + /// + /// "Supports {{Label}} and {{Value}}, but also including arbitrary attributes like {{StarRating}} (see attribute list for supported values)." + /// + public static LocalisableString TemplateDescription => new TranslatableString(getKey(@"template_description"), @"Supports {{Label}} and {{Value}}, but also including arbitrary attributes like {{StarRating}} (see attribute list for supported values)."); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs b/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs new file mode 100644 index 0000000000..7c11ea6ac6 --- /dev/null +++ b/osu.Game/Localisation/SkinComponents/SkinnableComponentStrings.cs @@ -0,0 +1,54 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation.SkinComponents +{ + public static class SkinnableComponentStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.SkinComponents.SkinnableComponentStrings"; + + /// + /// "Sprite name" + /// + public static LocalisableString SpriteName => new TranslatableString(getKey(@"sprite_name"), "Sprite name"); + + /// + /// "The filename of the sprite" + /// + public static LocalisableString SpriteNameDescription => new TranslatableString(getKey(@"sprite_name_description"), "The filename of the sprite"); + + /// + /// "Font" + /// + public static LocalisableString Font => new TranslatableString(getKey(@"font"), "Font"); + + /// + /// "The font to use." + /// + public static LocalisableString FontDescription => new TranslatableString(getKey(@"font_description"), "The font to use."); + + /// + /// "Text" + /// + public static LocalisableString TextElementText => new TranslatableString(getKey(@"text_element_text"), "Text"); + + /// + /// "The text to be displayed." + /// + public static LocalisableString TextElementTextDescription => new TranslatableString(getKey(@"text_element_text_description"), "The text to be displayed."); + + /// + /// "Corner radius" + /// + public static LocalisableString CornerRadius => new TranslatableString(getKey(@"corner_radius"), "Corner radius"); + + /// + /// "How rounded the corners should be." + /// + public static LocalisableString CornerRadiusDescription => new TranslatableString(getKey(@"corner_radius_description"), "How rounded the corners should be."); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/SkinEditorStrings.cs b/osu.Game/Localisation/SkinEditorStrings.cs new file mode 100644 index 0000000000..3c1d1ff40d --- /dev/null +++ b/osu.Game/Localisation/SkinEditorStrings.cs @@ -0,0 +1,54 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class SkinEditorStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.SkinEditor"; + + /// + /// "Skin editor" + /// + public static LocalisableString SkinEditor => new TranslatableString(getKey(@"skin_editor"), @"Skin editor"); + + /// + /// "Components" + /// + public static LocalisableString Components => new TranslatableString(getKey(@"components"), @"Components"); + + /// + /// "Scene library" + /// + public static LocalisableString SceneLibrary => new TranslatableString(getKey(@"scene_library"), @"Scene library"); + + /// + /// "Song Select" + /// + public static LocalisableString SongSelect => new TranslatableString(getKey(@"song_select"), @"Song Select"); + + /// + /// "Gameplay" + /// + public static LocalisableString Gameplay => new TranslatableString(getKey(@"gameplay"), @"Gameplay"); + + /// + /// "Settings ({0})" + /// + public static LocalisableString Settings(string arg0) => new TranslatableString(getKey(@"settings"), @"Settings ({0})", arg0); + + /// + /// "Currently editing" + /// + public static LocalisableString CurrentlyEditing => new TranslatableString(getKey(@"currently_editing"), @"Currently editing"); + + /// + /// "All layout elements for layers in the current screen will be reset to defaults." + /// + public static LocalisableString RevertToDefaultDescription => new TranslatableString(getKey(@"revert_to_default_description"), @"All layout elements for layers in the current screen will be reset to defaults."); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/SkinSettingsStrings.cs b/osu.Game/Localisation/SkinSettingsStrings.cs index 81035c5a5e..4b6b0ce1d6 100644 --- a/osu.Game/Localisation/SkinSettingsStrings.cs +++ b/osu.Game/Localisation/SkinSettingsStrings.cs @@ -34,6 +34,11 @@ namespace osu.Game.Localisation /// public static LocalisableString AutoCursorSize => new TranslatableString(getKey(@"auto_cursor_size"), @"Adjust gameplay cursor size based on current beatmap"); + /// + /// "Show gameplay cursor during touch input" + /// + public static LocalisableString GameplayCursorDuringTouch => new TranslatableString(getKey(@"gameplay_cursor_during_touch"), @"Show gameplay cursor during touch input"); + /// /// "Beatmap skins" /// diff --git a/osu.Game/Localisation/SongSelectStrings.cs b/osu.Game/Localisation/SongSelectStrings.cs new file mode 100644 index 0000000000..e1ac328420 --- /dev/null +++ b/osu.Game/Localisation/SongSelectStrings.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class SongSelectStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.SongSelect"; + + /// + /// "Local" + /// + public static LocalisableString LocallyModified => new TranslatableString(getKey(@"locally_modified"), @"Local"); + + /// + /// "Has been locally modified" + /// + public static LocalisableString LocallyModifiedTooltip => new TranslatableString(getKey(@"locally_modified_tooltip"), @"Has been locally modified"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/TabletSettingsStrings.cs b/osu.Game/Localisation/TabletSettingsStrings.cs index 5bdca09e4a..6c2e3c1f9c 100644 --- a/osu.Game/Localisation/TabletSettingsStrings.cs +++ b/osu.Game/Localisation/TabletSettingsStrings.cs @@ -19,6 +19,11 @@ namespace osu.Game.Localisation /// public static LocalisableString NoTabletDetected => new TranslatableString(getKey(@"no_tablet_detected"), @"No tablet detected!"); + /// + /// "If your tablet is not detected, please read [this FAQ]({0}) for troubleshooting steps." + /// + public static LocalisableString NoTabletDetectedDescription(string url) => new TranslatableString(getKey(@"no_tablet_detected_description"), @"If your tablet is not detected, please read [this FAQ]({0}) for troubleshooting steps.", url); + /// /// "Reset to full area" /// @@ -56,4 +61,4 @@ namespace osu.Game.Localisation private static string getKey(string key) => $@"{prefix}:{key}"; } -} \ No newline at end of file +} diff --git a/osu.Game/Localisation/ToastStrings.cs b/osu.Game/Localisation/ToastStrings.cs index 52e75425bf..da798a3937 100644 --- a/osu.Game/Localisation/ToastStrings.cs +++ b/osu.Game/Localisation/ToastStrings.cs @@ -34,6 +34,21 @@ namespace osu.Game.Localisation /// public static LocalisableString RestartTrack => new TranslatableString(getKey(@"restart_track"), @"Restart track"); + /// + /// "Beatmap saved" + /// + public static LocalisableString BeatmapSaved => new TranslatableString(getKey(@"beatmap_saved"), @"Beatmap saved"); + + /// + /// "Skin saved" + /// + public static LocalisableString SkinSaved => new TranslatableString(getKey(@"skin_saved"), @"Skin saved"); + + /// + /// "URL copied" + /// + public static LocalisableString UrlCopied => new TranslatableString(getKey(@"url_copied"), @"URL copied"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/ToolbarStrings.cs b/osu.Game/Localisation/ToolbarStrings.cs new file mode 100644 index 0000000000..e71a3fff9b --- /dev/null +++ b/osu.Game/Localisation/ToolbarStrings.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class ToolbarStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.Toolbar"; + + /// + /// "Connection interrupted, will try to reconnect..." + /// + public static LocalisableString AttemptingToReconnect => new TranslatableString(getKey(@"attempting_to_reconnect"), @"Connection interrupted, will try to reconnect..."); + + /// + /// "Connecting..." + /// + public static LocalisableString Connecting => new TranslatableString(getKey(@"connecting"), @"Connecting..."); + + /// + /// "home" + /// + public static LocalisableString HomeHeaderTitle => new TranslatableString(getKey(@"home_header_title"), @"home"); + + /// + /// "return to the main menu" + /// + public static LocalisableString HomeHeaderDescription => new TranslatableString(getKey(@"home_header_description"), @"return to the main menu"); + + /// + /// "play some {0}" + /// + public static LocalisableString PlaySomeRuleset(string arg0) => new TranslatableString(getKey(@"play_some_ruleset"), @"play some {0}", arg0); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/UserInterfaceStrings.cs b/osu.Game/Localisation/UserInterfaceStrings.cs index 4be403edb4..ea664d7b50 100644 --- a/osu.Game/Localisation/UserInterfaceStrings.cs +++ b/osu.Game/Localisation/UserInterfaceStrings.cs @@ -104,11 +104,56 @@ namespace osu.Game.Localisation /// public static LocalisableString RandomSelectionAlgorithm => new TranslatableString(getKey(@"random_selection_algorithm"), @"Random selection algorithm"); + /// + /// "Mod select hotkey style" + /// + public static LocalisableString ModSelectHotkeyStyle => new TranslatableString(getKey(@"mod_select_hotkey_style"), @"Mod select hotkey style"); + /// /// "no limit" /// public static LocalisableString NoLimit => new TranslatableString(getKey(@"no_limit"), @"no limit"); + /// + /// "Beatmap (with storyboard / video)" + /// + public static LocalisableString BeatmapWithStoryboard => new TranslatableString(getKey(@"beatmap_with_storyboard"), @"Beatmap (with storyboard / video)"); + + /// + /// "Always" + /// + public static LocalisableString AlwaysSeasonalBackground => new TranslatableString(getKey(@"always_seasonal_backgrounds"), @"Always"); + + /// + /// "Never" + /// + public static LocalisableString NeverSeasonalBackground => new TranslatableString(getKey(@"never_seasonal_backgrounds"), @"Never"); + + /// + /// "Sometimes" + /// + public static LocalisableString SometimesSeasonalBackground => new TranslatableString(getKey(@"sometimes_seasonal_backgrounds"), @"Sometimes"); + + /// + /// "Sequential" + /// + public static LocalisableString SequentialHotkeyStyle => new TranslatableString(getKey(@"mods_sequential_hotkeys"), @"Sequential"); + + /// + /// "Classic" + /// + public static LocalisableString ClassicHotkeyStyle => new TranslatableString(getKey(@"mods_classic_hotkeys"), @"Classic"); + + /// + /// "Never repeat" + /// + public static LocalisableString NeverRepeat => new TranslatableString(getKey(@"never_repeat_random"), @"Never repeat"); + + /// + /// "True Random" + /// + public static LocalisableString TrueRandom => new TranslatableString(getKey(@"true_random"), @"True Random"); + private static string getKey(string key) => $"{prefix}:{key}"; } } diff --git a/osu.Game/Migrations/20171019041408_InitialCreate.Designer.cs b/osu.Game/Migrations/20171019041408_InitialCreate.Designer.cs deleted file mode 100644 index c751530bf4..0000000000 --- a/osu.Game/Migrations/20171019041408_InitialCreate.Designer.cs +++ /dev/null @@ -1,293 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage; -using osu.Game.Database; -using System; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20171019041408_InitialCreate")] - partial class InitialCreate - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.0.0-rtm-26452"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash"); - - b.HasIndex("MetadataID"); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20171019041408_InitialCreate.cs b/osu.Game/Migrations/20171019041408_InitialCreate.cs deleted file mode 100644 index 08ab64fd08..0000000000 --- a/osu.Game/Migrations/20171019041408_InitialCreate.cs +++ /dev/null @@ -1,314 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class InitialCreate : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "BeatmapDifficulty", - columns: table => new - { - ID = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - ApproachRate = table.Column(type: "REAL", nullable: false), - CircleSize = table.Column(type: "REAL", nullable: false), - DrainRate = table.Column(type: "REAL", nullable: false), - OverallDifficulty = table.Column(type: "REAL", nullable: false), - SliderMultiplier = table.Column(type: "REAL", nullable: false), - SliderTickRate = table.Column(type: "REAL", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_BeatmapDifficulty", x => x.ID); - }); - - migrationBuilder.CreateTable( - name: "BeatmapMetadata", - columns: table => new - { - ID = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Artist = table.Column(type: "TEXT", nullable: true), - ArtistUnicode = table.Column(type: "TEXT", nullable: true), - AudioFile = table.Column(type: "TEXT", nullable: true), - Author = table.Column(type: "TEXT", nullable: true), - BackgroundFile = table.Column(type: "TEXT", nullable: true), - PreviewTime = table.Column(type: "INTEGER", nullable: false), - Source = table.Column(type: "TEXT", nullable: true), - Tags = table.Column(type: "TEXT", nullable: true), - Title = table.Column(type: "TEXT", nullable: true), - TitleUnicode = table.Column(type: "TEXT", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_BeatmapMetadata", x => x.ID); - }); - - migrationBuilder.CreateTable( - name: "FileInfo", - columns: table => new - { - ID = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Hash = table.Column(type: "TEXT", nullable: true), - ReferenceCount = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_FileInfo", x => x.ID); - }); - - migrationBuilder.CreateTable( - name: "KeyBinding", - columns: table => new - { - ID = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Action = table.Column(type: "INTEGER", nullable: false), - Keys = table.Column(type: "TEXT", nullable: true), - RulesetID = table.Column(type: "INTEGER", nullable: true), - Variant = table.Column(type: "INTEGER", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_KeyBinding", x => x.ID); - }); - - migrationBuilder.CreateTable( - name: "RulesetInfo", - columns: table => new - { - ID = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Available = table.Column(type: "INTEGER", nullable: false), - InstantiationInfo = table.Column(type: "TEXT", nullable: true), - Name = table.Column(type: "TEXT", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_RulesetInfo", x => x.ID); - }); - - migrationBuilder.CreateTable( - name: "BeatmapSetInfo", - columns: table => new - { - ID = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - DeletePending = table.Column(type: "INTEGER", nullable: false), - Hash = table.Column(type: "TEXT", nullable: true), - MetadataID = table.Column(type: "INTEGER", nullable: true), - OnlineBeatmapSetID = table.Column(type: "INTEGER", nullable: true), - Protected = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_BeatmapSetInfo", x => x.ID); - table.ForeignKey( - name: "FK_BeatmapSetInfo_BeatmapMetadata_MetadataID", - column: x => x.MetadataID, - principalTable: "BeatmapMetadata", - principalColumn: "ID", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "BeatmapInfo", - columns: table => new - { - ID = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - AudioLeadIn = table.Column(type: "INTEGER", nullable: false), - BaseDifficultyID = table.Column(type: "INTEGER", nullable: false), - BeatDivisor = table.Column(type: "INTEGER", nullable: false), - BeatmapSetInfoID = table.Column(type: "INTEGER", nullable: false), - Countdown = table.Column(type: "INTEGER", nullable: false), - DistanceSpacing = table.Column(type: "REAL", nullable: false), - GridSize = table.Column(type: "INTEGER", nullable: false), - Hash = table.Column(type: "TEXT", nullable: true), - Hidden = table.Column(type: "INTEGER", nullable: false), - LetterboxInBreaks = table.Column(type: "INTEGER", nullable: false), - MD5Hash = table.Column(type: "TEXT", nullable: true), - MetadataID = table.Column(type: "INTEGER", nullable: true), - OnlineBeatmapID = table.Column(type: "INTEGER", nullable: true), - Path = table.Column(type: "TEXT", nullable: true), - RulesetID = table.Column(type: "INTEGER", nullable: false), - SpecialStyle = table.Column(type: "INTEGER", nullable: false), - StackLeniency = table.Column(type: "REAL", nullable: false), - StarDifficulty = table.Column(type: "REAL", nullable: false), - StoredBookmarks = table.Column(type: "TEXT", nullable: true), - TimelineZoom = table.Column(type: "REAL", nullable: false), - Version = table.Column(type: "TEXT", nullable: true), - WidescreenStoryboard = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_BeatmapInfo", x => x.ID); - table.ForeignKey( - name: "FK_BeatmapInfo_BeatmapDifficulty_BaseDifficultyID", - column: x => x.BaseDifficultyID, - principalTable: "BeatmapDifficulty", - principalColumn: "ID", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_BeatmapInfo_BeatmapSetInfo_BeatmapSetInfoID", - column: x => x.BeatmapSetInfoID, - principalTable: "BeatmapSetInfo", - principalColumn: "ID", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_BeatmapInfo_BeatmapMetadata_MetadataID", - column: x => x.MetadataID, - principalTable: "BeatmapMetadata", - principalColumn: "ID", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_BeatmapInfo_RulesetInfo_RulesetID", - column: x => x.RulesetID, - principalTable: "RulesetInfo", - principalColumn: "ID", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "BeatmapSetFileInfo", - columns: table => new - { - ID = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - BeatmapSetInfoID = table.Column(type: "INTEGER", nullable: false), - FileInfoID = table.Column(type: "INTEGER", nullable: false), - Filename = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_BeatmapSetFileInfo", x => x.ID); - table.ForeignKey( - name: "FK_BeatmapSetFileInfo_BeatmapSetInfo_BeatmapSetInfoID", - column: x => x.BeatmapSetInfoID, - principalTable: "BeatmapSetInfo", - principalColumn: "ID", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_BeatmapSetFileInfo_FileInfo_FileInfoID", - column: x => x.FileInfoID, - principalTable: "FileInfo", - principalColumn: "ID", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_BaseDifficultyID", - table: "BeatmapInfo", - column: "BaseDifficultyID"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_BeatmapSetInfoID", - table: "BeatmapInfo", - column: "BeatmapSetInfoID"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_Hash", - table: "BeatmapInfo", - column: "Hash"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_MD5Hash", - table: "BeatmapInfo", - column: "MD5Hash"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_MetadataID", - table: "BeatmapInfo", - column: "MetadataID"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_RulesetID", - table: "BeatmapInfo", - column: "RulesetID"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapSetFileInfo_BeatmapSetInfoID", - table: "BeatmapSetFileInfo", - column: "BeatmapSetInfoID"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapSetFileInfo_FileInfoID", - table: "BeatmapSetFileInfo", - column: "FileInfoID"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapSetInfo_DeletePending", - table: "BeatmapSetInfo", - column: "DeletePending"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapSetInfo_Hash", - table: "BeatmapSetInfo", - column: "Hash"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapSetInfo_MetadataID", - table: "BeatmapSetInfo", - column: "MetadataID"); - - migrationBuilder.CreateIndex( - name: "IX_FileInfo_Hash", - table: "FileInfo", - column: "Hash", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_FileInfo_ReferenceCount", - table: "FileInfo", - column: "ReferenceCount"); - - migrationBuilder.CreateIndex( - name: "IX_KeyBinding_Action", - table: "KeyBinding", - column: "Action"); - - migrationBuilder.CreateIndex( - name: "IX_KeyBinding_Variant", - table: "KeyBinding", - column: "Variant"); - - migrationBuilder.CreateIndex( - name: "IX_RulesetInfo_Available", - table: "RulesetInfo", - column: "Available"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "BeatmapInfo"); - - migrationBuilder.DropTable( - name: "BeatmapSetFileInfo"); - - migrationBuilder.DropTable( - name: "KeyBinding"); - - migrationBuilder.DropTable( - name: "BeatmapDifficulty"); - - migrationBuilder.DropTable( - name: "RulesetInfo"); - - migrationBuilder.DropTable( - name: "BeatmapSetInfo"); - - migrationBuilder.DropTable( - name: "FileInfo"); - - migrationBuilder.DropTable( - name: "BeatmapMetadata"); - } - } -} diff --git a/osu.Game/Migrations/20171025071459_AddMissingIndexRules.Designer.cs b/osu.Game/Migrations/20171025071459_AddMissingIndexRules.Designer.cs deleted file mode 100644 index 4cd234f2ef..0000000000 --- a/osu.Game/Migrations/20171025071459_AddMissingIndexRules.Designer.cs +++ /dev/null @@ -1,299 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage; -using osu.Game.Database; -using System; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20171025071459_AddMissingIndexRules")] - partial class AddMissingIndexRules - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.0.0-rtm-26452"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MD5Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20171025071459_AddMissingIndexRules.cs b/osu.Game/Migrations/20171025071459_AddMissingIndexRules.cs deleted file mode 100644 index 4ec3952941..0000000000 --- a/osu.Game/Migrations/20171025071459_AddMissingIndexRules.cs +++ /dev/null @@ -1,83 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddMissingIndexRules : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_BeatmapSetInfo_Hash", - table: "BeatmapSetInfo"); - - migrationBuilder.DropIndex( - name: "IX_BeatmapInfo_Hash", - table: "BeatmapInfo"); - - migrationBuilder.DropIndex( - name: "IX_BeatmapInfo_MD5Hash", - table: "BeatmapInfo"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapSetInfo_Hash", - table: "BeatmapSetInfo", - column: "Hash", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapSetInfo_OnlineBeatmapSetID", - table: "BeatmapSetInfo", - column: "OnlineBeatmapSetID", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_Hash", - table: "BeatmapInfo", - column: "Hash", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_MD5Hash", - table: "BeatmapInfo", - column: "MD5Hash", - unique: true); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_BeatmapSetInfo_Hash", - table: "BeatmapSetInfo"); - - migrationBuilder.DropIndex( - name: "IX_BeatmapSetInfo_OnlineBeatmapSetID", - table: "BeatmapSetInfo"); - - migrationBuilder.DropIndex( - name: "IX_BeatmapInfo_Hash", - table: "BeatmapInfo"); - - migrationBuilder.DropIndex( - name: "IX_BeatmapInfo_MD5Hash", - table: "BeatmapInfo"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapSetInfo_Hash", - table: "BeatmapSetInfo", - column: "Hash"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_Hash", - table: "BeatmapInfo", - column: "Hash"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_MD5Hash", - table: "BeatmapInfo", - column: "MD5Hash"); - } - } -} diff --git a/osu.Game/Migrations/20171119065731_AddBeatmapOnlineIDUniqueConstraint.Designer.cs b/osu.Game/Migrations/20171119065731_AddBeatmapOnlineIDUniqueConstraint.Designer.cs deleted file mode 100644 index 006acf12cd..0000000000 --- a/osu.Game/Migrations/20171119065731_AddBeatmapOnlineIDUniqueConstraint.Designer.cs +++ /dev/null @@ -1,302 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage; -using osu.Game.Database; -using System; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20171119065731_AddBeatmapOnlineIDUniqueConstraint")] - partial class AddBeatmapOnlineIDUniqueConstraint - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.0.0-rtm-26452"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MD5Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20171119065731_AddBeatmapOnlineIDUniqueConstraint.cs b/osu.Game/Migrations/20171119065731_AddBeatmapOnlineIDUniqueConstraint.cs deleted file mode 100644 index 6aba12f86f..0000000000 --- a/osu.Game/Migrations/20171119065731_AddBeatmapOnlineIDUniqueConstraint.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddBeatmapOnlineIDUniqueConstraint : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_OnlineBeatmapID", - table: "BeatmapInfo", - column: "OnlineBeatmapID", - unique: true); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_BeatmapInfo_OnlineBeatmapID", - table: "BeatmapInfo"); - } - } -} diff --git a/osu.Game/Migrations/20171209034410_AddRulesetInfoShortName.Designer.cs b/osu.Game/Migrations/20171209034410_AddRulesetInfoShortName.Designer.cs deleted file mode 100644 index fc2496bc24..0000000000 --- a/osu.Game/Migrations/20171209034410_AddRulesetInfoShortName.Designer.cs +++ /dev/null @@ -1,307 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage; -using osu.Game.Database; -using System; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20171209034410_AddRulesetInfoShortName")] - partial class AddRulesetInfoShortName - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.0.0-rtm-26452"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MD5Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20171209034410_AddRulesetInfoShortName.cs b/osu.Game/Migrations/20171209034410_AddRulesetInfoShortName.cs deleted file mode 100644 index 5688455f79..0000000000 --- a/osu.Game/Migrations/20171209034410_AddRulesetInfoShortName.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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddRulesetInfoShortName : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "ShortName", - table: "RulesetInfo", - type: "TEXT", - nullable: true); - - migrationBuilder.CreateIndex( - name: "IX_RulesetInfo_ShortName", - table: "RulesetInfo", - column: "ShortName", - unique: true); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_RulesetInfo_ShortName", - table: "RulesetInfo"); - - migrationBuilder.DropColumn( - name: "ShortName", - table: "RulesetInfo"); - } - } -} diff --git a/osu.Game/Migrations/20180125143340_Settings.Designer.cs b/osu.Game/Migrations/20180125143340_Settings.Designer.cs deleted file mode 100644 index 4bb599eec1..0000000000 --- a/osu.Game/Migrations/20180125143340_Settings.Designer.cs +++ /dev/null @@ -1,329 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage; -using osu.Game.Database; -using System; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20180125143340_Settings")] - partial class Settings - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.0.0-rtm-26452"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MD5Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntKey") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20180125143340_Settings.cs b/osu.Game/Migrations/20180125143340_Settings.cs deleted file mode 100644 index 1feb37531f..0000000000 --- a/osu.Game/Migrations/20180125143340_Settings.cs +++ /dev/null @@ -1,58 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class Settings : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_KeyBinding_Variant", - table: "KeyBinding"); - - migrationBuilder.CreateTable( - name: "Settings", - columns: table => new - { - ID = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Key = table.Column(type: "TEXT", nullable: false), - RulesetID = table.Column(type: "INTEGER", nullable: true), - Value = table.Column(type: "TEXT", nullable: true), - Variant = table.Column(type: "INTEGER", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Settings", x => x.ID); - }); - - migrationBuilder.CreateIndex( - name: "IX_KeyBinding_RulesetID_Variant", - table: "KeyBinding", - columns: new[] { "RulesetID", "Variant" }); - - migrationBuilder.CreateIndex( - name: "IX_Settings_RulesetID_Variant", - table: "Settings", - columns: new[] { "RulesetID", "Variant" }); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Settings"); - - migrationBuilder.DropIndex( - name: "IX_KeyBinding_RulesetID_Variant", - table: "KeyBinding"); - - migrationBuilder.CreateIndex( - name: "IX_KeyBinding_Variant", - table: "KeyBinding", - column: "Variant"); - } - } -} diff --git a/osu.Game/Migrations/20180131154205_AddMuteBinding.cs b/osu.Game/Migrations/20180131154205_AddMuteBinding.cs deleted file mode 100644 index 8646d1d76b..0000000000 --- a/osu.Game/Migrations/20180131154205_AddMuteBinding.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Infrastructure; -using osu.Game.Database; -using osu.Game.Input.Bindings; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20180131154205_AddMuteBinding")] - public partial class AddMuteBinding : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql($"UPDATE KeyBinding SET Action = Action + 1 WHERE RulesetID IS NULL AND Variant IS NULL AND Action >= {(int)GlobalAction.ToggleMute}"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql($"DELETE FROM KeyBinding WHERE RulesetID IS NULL AND Variant IS NULL AND Action = {(int)GlobalAction.ToggleMute}"); - migrationBuilder.Sql($"UPDATE KeyBinding SET Action = Action - 1 WHERE RulesetID IS NULL AND Variant IS NULL AND Action > {(int)GlobalAction.ToggleMute}"); - } - } -} diff --git a/osu.Game/Migrations/20180219060912_AddSkins.Designer.cs b/osu.Game/Migrations/20180219060912_AddSkins.Designer.cs deleted file mode 100644 index cdc4ef2e66..0000000000 --- a/osu.Game/Migrations/20180219060912_AddSkins.Designer.cs +++ /dev/null @@ -1,379 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage; -using osu.Game.Database; -using System; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20180219060912_AddSkins")] - partial class AddSkins - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.0.0-rtm-26452"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MD5Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntKey") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20180219060912_AddSkins.cs b/osu.Game/Migrations/20180219060912_AddSkins.cs deleted file mode 100644 index 319748bed6..0000000000 --- a/osu.Game/Migrations/20180219060912_AddSkins.cs +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddSkins : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "SkinInfo", - columns: table => new - { - ID = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Creator = table.Column(type: "TEXT", nullable: true), - DeletePending = table.Column(type: "INTEGER", nullable: false), - Name = table.Column(type: "TEXT", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_SkinInfo", x => x.ID); - }); - - migrationBuilder.CreateTable( - name: "SkinFileInfo", - columns: table => new - { - ID = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - FileInfoID = table.Column(type: "INTEGER", nullable: false), - Filename = table.Column(type: "TEXT", nullable: false), - SkinInfoID = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_SkinFileInfo", x => x.ID); - table.ForeignKey( - name: "FK_SkinFileInfo_FileInfo_FileInfoID", - column: x => x.FileInfoID, - principalTable: "FileInfo", - principalColumn: "ID", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_SkinFileInfo_SkinInfo_SkinInfoID", - column: x => x.SkinInfoID, - principalTable: "SkinInfo", - principalColumn: "ID", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_SkinFileInfo_FileInfoID", - table: "SkinFileInfo", - column: "FileInfoID"); - - migrationBuilder.CreateIndex( - name: "IX_SkinFileInfo_SkinInfoID", - table: "SkinFileInfo", - column: "SkinInfoID"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "SkinFileInfo"); - - migrationBuilder.DropTable( - name: "SkinInfo"); - } - } -} diff --git a/osu.Game/Migrations/20180529055154_RemoveUniqueHashConstraints.Designer.cs b/osu.Game/Migrations/20180529055154_RemoveUniqueHashConstraints.Designer.cs deleted file mode 100644 index f28408bfb3..0000000000 --- a/osu.Game/Migrations/20180529055154_RemoveUniqueHashConstraints.Designer.cs +++ /dev/null @@ -1,377 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage; -using osu.Game.Database; -using System; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20180529055154_RemoveUniqueHashConstraints")] - partial class RemoveUniqueHashConstraints - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.0.3-rtm-10026"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntKey") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20180529055154_RemoveUniqueHashConstraints.cs b/osu.Game/Migrations/20180529055154_RemoveUniqueHashConstraints.cs deleted file mode 100644 index 91eabe8868..0000000000 --- a/osu.Game/Migrations/20180529055154_RemoveUniqueHashConstraints.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class RemoveUniqueHashConstraints : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_BeatmapInfo_Hash", - table: "BeatmapInfo"); - - migrationBuilder.DropIndex( - name: "IX_BeatmapInfo_MD5Hash", - table: "BeatmapInfo"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_Hash", - table: "BeatmapInfo", - column: "Hash"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_MD5Hash", - table: "BeatmapInfo", - column: "MD5Hash"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_BeatmapInfo_Hash", - table: "BeatmapInfo"); - - migrationBuilder.DropIndex( - name: "IX_BeatmapInfo_MD5Hash", - table: "BeatmapInfo"); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_Hash", - table: "BeatmapInfo", - column: "Hash", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_BeatmapInfo_MD5Hash", - table: "BeatmapInfo", - column: "MD5Hash", - unique: true); - } - } -} diff --git a/osu.Game/Migrations/20180621044111_UpdateTaikoDefaultBindings.Designer.cs b/osu.Game/Migrations/20180621044111_UpdateTaikoDefaultBindings.Designer.cs deleted file mode 100644 index aaa11e88b6..0000000000 --- a/osu.Game/Migrations/20180621044111_UpdateTaikoDefaultBindings.Designer.cs +++ /dev/null @@ -1,376 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20180621044111_UpdateTaikoDefaultBindings")] - partial class UpdateTaikoDefaultBindings - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.1.1-rtm-30846"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntKey") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20180621044111_UpdateTaikoDefaultBindings.cs b/osu.Game/Migrations/20180621044111_UpdateTaikoDefaultBindings.cs deleted file mode 100644 index d888ccd5a2..0000000000 --- a/osu.Game/Migrations/20180621044111_UpdateTaikoDefaultBindings.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class UpdateTaikoDefaultBindings : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql("DELETE FROM KeyBinding WHERE RulesetID = 1"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - // we can't really tell if these should be restored or not, so let's just not do so. - } - } -} diff --git a/osu.Game/Migrations/20180628011956_RemoveNegativeSetIDs.Designer.cs b/osu.Game/Migrations/20180628011956_RemoveNegativeSetIDs.Designer.cs deleted file mode 100644 index 7eeacd56d7..0000000000 --- a/osu.Game/Migrations/20180628011956_RemoveNegativeSetIDs.Designer.cs +++ /dev/null @@ -1,376 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20180628011956_RemoveNegativeSetIDs")] - partial class RemoveNegativeSetIDs - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.1.1-rtm-30846"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntKey") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20180628011956_RemoveNegativeSetIDs.cs b/osu.Game/Migrations/20180628011956_RemoveNegativeSetIDs.cs deleted file mode 100644 index fdea636ac6..0000000000 --- a/osu.Game/Migrations/20180628011956_RemoveNegativeSetIDs.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class RemoveNegativeSetIDs : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - // There was a change that beatmaps were being loaded with "-1" online IDs, which is completely incorrect. - // This ensures there will not be unique key conflicts as a result of these incorrectly imported beatmaps. - migrationBuilder.Sql("UPDATE BeatmapSetInfo SET OnlineBeatmapSetID = null WHERE OnlineBeatmapSetID <= 0"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - } - } -} diff --git a/osu.Game/Migrations/20180913080842_AddRankStatus.Designer.cs b/osu.Game/Migrations/20180913080842_AddRankStatus.Designer.cs deleted file mode 100644 index 5ab43da046..0000000000 --- a/osu.Game/Migrations/20180913080842_AddRankStatus.Designer.cs +++ /dev/null @@ -1,380 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20180913080842_AddRankStatus")] - partial class AddRankStatus - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.1.2-rtm-30932"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntKey") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20180913080842_AddRankStatus.cs b/osu.Game/Migrations/20180913080842_AddRankStatus.cs deleted file mode 100644 index bb147dff84..0000000000 --- a/osu.Game/Migrations/20180913080842_AddRankStatus.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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddRankStatus : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "Status", - table: "BeatmapSetInfo", - nullable: false, - defaultValue: -3); // NONE - - migrationBuilder.AddColumn( - name: "Status", - table: "BeatmapInfo", - nullable: false, - defaultValue: -3); // NONE - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "Status", - table: "BeatmapSetInfo"); - - migrationBuilder.DropColumn( - name: "Status", - table: "BeatmapInfo"); - } - } -} diff --git a/osu.Game/Migrations/20181007180454_StandardizePaths.Designer.cs b/osu.Game/Migrations/20181007180454_StandardizePaths.Designer.cs deleted file mode 100644 index b387a45ecf..0000000000 --- a/osu.Game/Migrations/20181007180454_StandardizePaths.Designer.cs +++ /dev/null @@ -1,380 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20181007180454_StandardizePaths")] - partial class StandardizePaths - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.1.3-rtm-32065"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntKey") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20181007180454_StandardizePaths.cs b/osu.Game/Migrations/20181007180454_StandardizePaths.cs deleted file mode 100644 index 30f27043a0..0000000000 --- a/osu.Game/Migrations/20181007180454_StandardizePaths.cs +++ /dev/null @@ -1,27 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class StandardizePaths : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - string windowsStyle = @"\"; - string standardized = "/"; - - // Escaping \ does not seem to be needed. - migrationBuilder.Sql($"UPDATE `BeatmapInfo` SET `Path` = REPLACE(`Path`, '{windowsStyle}', '{standardized}')"); - migrationBuilder.Sql($"UPDATE `BeatmapMetadata` SET `AudioFile` = REPLACE(`AudioFile`, '{windowsStyle}', '{standardized}')"); - migrationBuilder.Sql($"UPDATE `BeatmapMetadata` SET `BackgroundFile` = REPLACE(`BackgroundFile`, '{windowsStyle}', '{standardized}')"); - migrationBuilder.Sql($"UPDATE `BeatmapSetFileInfo` SET `Filename` = REPLACE(`Filename`, '{windowsStyle}', '{standardized}')"); - migrationBuilder.Sql($"UPDATE `SkinFileInfo` SET `Filename` = REPLACE(`Filename`, '{windowsStyle}', '{standardized}')"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - } - } -} diff --git a/osu.Game/Migrations/20181128100659_AddSkinInfoHash.Designer.cs b/osu.Game/Migrations/20181128100659_AddSkinInfoHash.Designer.cs deleted file mode 100644 index 120674671a..0000000000 --- a/osu.Game/Migrations/20181128100659_AddSkinInfoHash.Designer.cs +++ /dev/null @@ -1,387 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20181128100659_AddSkinInfoHash")] - partial class AddSkinInfoHash - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.1.4-rtm-31024"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntKey") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20181128100659_AddSkinInfoHash.cs b/osu.Game/Migrations/20181128100659_AddSkinInfoHash.cs deleted file mode 100644 index ee825a1e9c..0000000000 --- a/osu.Game/Migrations/20181128100659_AddSkinInfoHash.cs +++ /dev/null @@ -1,44 +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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddSkinInfoHash : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "Hash", - table: "SkinInfo", - nullable: true); - - migrationBuilder.CreateIndex( - name: "IX_SkinInfo_DeletePending", - table: "SkinInfo", - column: "DeletePending"); - - migrationBuilder.CreateIndex( - name: "IX_SkinInfo_Hash", - table: "SkinInfo", - column: "Hash", - unique: true); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_SkinInfo_DeletePending", - table: "SkinInfo"); - - migrationBuilder.DropIndex( - name: "IX_SkinInfo_Hash", - table: "SkinInfo"); - - migrationBuilder.DropColumn( - name: "Hash", - table: "SkinInfo"); - } - } -} diff --git a/osu.Game/Migrations/20181130113755_AddScoreInfoTables.Designer.cs b/osu.Game/Migrations/20181130113755_AddScoreInfoTables.Designer.cs deleted file mode 100644 index eee53182ce..0000000000 --- a/osu.Game/Migrations/20181130113755_AddScoreInfoTables.Designer.cs +++ /dev/null @@ -1,484 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20181130113755_AddScoreInfoTables")] - partial class AddScoreInfoTables - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.1.4-rtm-31024"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntKey") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany() - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20181130113755_AddScoreInfoTables.cs b/osu.Game/Migrations/20181130113755_AddScoreInfoTables.cs deleted file mode 100644 index 58980132f3..0000000000 --- a/osu.Game/Migrations/20181130113755_AddScoreInfoTables.cs +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddScoreInfoTables : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "ScoreInfo", - columns: table => new - { - ID = table.Column(nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Rank = table.Column(nullable: false), - TotalScore = table.Column(nullable: false), - Accuracy = table.Column(type: "DECIMAL(1,4)", nullable: false), - PP = table.Column(nullable: true), - MaxCombo = table.Column(nullable: false), - Combo = table.Column(nullable: false), - RulesetID = table.Column(nullable: false), - Mods = table.Column(nullable: true), - User = table.Column(nullable: true), - BeatmapInfoID = table.Column(nullable: false), - OnlineScoreID = table.Column(nullable: true), - Date = table.Column(nullable: false), - Statistics = table.Column(nullable: true), - Hash = table.Column(nullable: true), - DeletePending = table.Column(nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_ScoreInfo", x => x.ID); - table.ForeignKey( - name: "FK_ScoreInfo_BeatmapInfo_BeatmapInfoID", - column: x => x.BeatmapInfoID, - principalTable: "BeatmapInfo", - principalColumn: "ID", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_ScoreInfo_RulesetInfo_RulesetID", - column: x => x.RulesetID, - principalTable: "RulesetInfo", - principalColumn: "ID", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "ScoreFileInfo", - columns: table => new - { - ID = table.Column(nullable: false) - .Annotation("Sqlite:Autoincrement", true), - FileInfoID = table.Column(nullable: false), - Filename = table.Column(nullable: false), - ScoreInfoID = table.Column(nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_ScoreFileInfo", x => x.ID); - table.ForeignKey( - name: "FK_ScoreFileInfo_FileInfo_FileInfoID", - column: x => x.FileInfoID, - principalTable: "FileInfo", - principalColumn: "ID", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_ScoreFileInfo_ScoreInfo_ScoreInfoID", - column: x => x.ScoreInfoID, - principalTable: "ScoreInfo", - principalColumn: "ID", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateIndex( - name: "IX_ScoreFileInfo_FileInfoID", - table: "ScoreFileInfo", - column: "FileInfoID"); - - migrationBuilder.CreateIndex( - name: "IX_ScoreFileInfo_ScoreInfoID", - table: "ScoreFileInfo", - column: "ScoreInfoID"); - - migrationBuilder.CreateIndex( - name: "IX_ScoreInfo_BeatmapInfoID", - table: "ScoreInfo", - column: "BeatmapInfoID"); - - migrationBuilder.CreateIndex( - name: "IX_ScoreInfo_OnlineScoreID", - table: "ScoreInfo", - column: "OnlineScoreID", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_ScoreInfo_RulesetID", - table: "ScoreInfo", - column: "RulesetID"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "ScoreFileInfo"); - - migrationBuilder.DropTable( - name: "ScoreInfo"); - } - } -} diff --git a/osu.Game/Migrations/20190225062029_AddUserIDColumn.Designer.cs b/osu.Game/Migrations/20190225062029_AddUserIDColumn.Designer.cs deleted file mode 100644 index 8e1e3a59f3..0000000000 --- a/osu.Game/Migrations/20190225062029_AddUserIDColumn.Designer.cs +++ /dev/null @@ -1,487 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20190225062029_AddUserIDColumn")] - partial class AddUserIDColumn - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.1-servicing-10028"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntKey") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20190225062029_AddUserIDColumn.cs b/osu.Game/Migrations/20190225062029_AddUserIDColumn.cs deleted file mode 100644 index f2eef600dc..0000000000 --- a/osu.Game/Migrations/20190225062029_AddUserIDColumn.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddUserIDColumn : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "UserID", - table: "ScoreInfo", - nullable: true); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "UserID", - table: "ScoreInfo"); - } - } -} diff --git a/osu.Game/Migrations/20190525060824_SkinSettings.Designer.cs b/osu.Game/Migrations/20190525060824_SkinSettings.Designer.cs deleted file mode 100644 index 348c42adb9..0000000000 --- a/osu.Game/Migrations/20190525060824_SkinSettings.Designer.cs +++ /dev/null @@ -1,498 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20190525060824_SkinSettings")] - partial class SkinSettings - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.4-servicing-10062"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("SkinInfoID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("SkinInfoID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Settings") - .HasForeignKey("SkinInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20190525060824_SkinSettings.cs b/osu.Game/Migrations/20190525060824_SkinSettings.cs deleted file mode 100644 index 7779b55bb7..0000000000 --- a/osu.Game/Migrations/20190525060824_SkinSettings.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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class SkinSettings : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql(@"create table Settings_dg_tmp - ( - ID INTEGER not null - constraint PK_Settings - primary key autoincrement, - Key TEXT not null, - RulesetID INTEGER, - Value TEXT, - Variant INTEGER, - SkinInfoID int - constraint Settings_SkinInfo_ID_fk - references SkinInfo - on delete restrict - ); - - insert into Settings_dg_tmp(ID, Key, RulesetID, Value, Variant) select ID, Key, RulesetID, Value, Variant from Settings; - - drop table Settings; - - alter table Settings_dg_tmp rename to Settings; - - create index IX_Settings_RulesetID_Variant - on Settings (RulesetID, Variant); - - create index Settings_SkinInfoID_index - on Settings (SkinInfoID); - - "); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_Settings_SkinInfo_SkinInfoID", - table: "Settings"); - - migrationBuilder.DropIndex( - name: "IX_Settings_SkinInfoID", - table: "Settings"); - - migrationBuilder.DropColumn( - name: "SkinInfoID", - table: "Settings"); - } - } -} diff --git a/osu.Game/Migrations/20190605091246_AddDateAddedColumnToBeatmapSet.Designer.cs b/osu.Game/Migrations/20190605091246_AddDateAddedColumnToBeatmapSet.Designer.cs deleted file mode 100644 index 9477369aa0..0000000000 --- a/osu.Game/Migrations/20190605091246_AddDateAddedColumnToBeatmapSet.Designer.cs +++ /dev/null @@ -1,489 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20190605091246_AddDateAddedColumnToBeatmapSet")] - partial class AddDateAddedColumnToBeatmapSet - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.4-servicing-10062"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DateAdded"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20190605091246_AddDateAddedColumnToBeatmapSet.cs b/osu.Game/Migrations/20190605091246_AddDateAddedColumnToBeatmapSet.cs deleted file mode 100644 index 0620a0624f..0000000000 --- a/osu.Game/Migrations/20190605091246_AddDateAddedColumnToBeatmapSet.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddDateAddedColumnToBeatmapSet : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "DateAdded", - table: "BeatmapSetInfo", - nullable: false, - defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "DateAdded", - table: "BeatmapSetInfo"); - } - } -} diff --git a/osu.Game/Migrations/20190708070844_AddBPMAndLengthColumns.Designer.cs b/osu.Game/Migrations/20190708070844_AddBPMAndLengthColumns.Designer.cs deleted file mode 100644 index c5fcc16f84..0000000000 --- a/osu.Game/Migrations/20190708070844_AddBPMAndLengthColumns.Designer.cs +++ /dev/null @@ -1,504 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20190708070844_AddBPMAndLengthColumns")] - partial class AddBPMAndLengthColumns - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.4-servicing-10062"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BPM"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("Length"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DateAdded"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("SkinInfoID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("SkinInfoID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Settings") - .HasForeignKey("SkinInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20190708070844_AddBPMAndLengthColumns.cs b/osu.Game/Migrations/20190708070844_AddBPMAndLengthColumns.cs deleted file mode 100644 index f8ce354aa1..0000000000 --- a/osu.Game/Migrations/20190708070844_AddBPMAndLengthColumns.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 Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddBPMAndLengthColumns : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "BPM", - table: "BeatmapInfo", - nullable: false, - defaultValue: 0.0); - - migrationBuilder.AddColumn( - name: "Length", - table: "BeatmapInfo", - nullable: false, - defaultValue: 0.0); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "BPM", - table: "BeatmapInfo"); - - migrationBuilder.DropColumn( - name: "Length", - table: "BeatmapInfo"); - } - } -} diff --git a/osu.Game/Migrations/20190913104727_AddBeatmapVideo.Designer.cs b/osu.Game/Migrations/20190913104727_AddBeatmapVideo.Designer.cs deleted file mode 100644 index 826233a2b0..0000000000 --- a/osu.Game/Migrations/20190913104727_AddBeatmapVideo.Designer.cs +++ /dev/null @@ -1,506 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20190913104727_AddBeatmapVideo")] - partial class AddBeatmapVideo - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BPM"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("Length"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.Property("VideoFile"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DateAdded"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("SkinInfoID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("SkinInfoID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Settings") - .HasForeignKey("SkinInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20190913104727_AddBeatmapVideo.cs b/osu.Game/Migrations/20190913104727_AddBeatmapVideo.cs deleted file mode 100644 index af82b4db20..0000000000 --- a/osu.Game/Migrations/20190913104727_AddBeatmapVideo.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddBeatmapVideo : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "VideoFile", - table: "BeatmapMetadata", - nullable: true); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "VideoFile", - table: "BeatmapMetadata"); - } - } -} diff --git a/osu.Game/Migrations/20200302094919_RefreshVolumeBindings.Designer.cs b/osu.Game/Migrations/20200302094919_RefreshVolumeBindings.Designer.cs deleted file mode 100644 index 22316b0380..0000000000 --- a/osu.Game/Migrations/20200302094919_RefreshVolumeBindings.Designer.cs +++ /dev/null @@ -1,506 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20200302094919_RefreshVolumeBindings")] - partial class RefreshVolumeBindings - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BPM"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("Length"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.Property("VideoFile"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DateAdded"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("SkinInfoID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("SkinInfoID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Settings") - .HasForeignKey("SkinInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20200302094919_RefreshVolumeBindings.cs b/osu.Game/Migrations/20200302094919_RefreshVolumeBindings.cs deleted file mode 100644 index 3d2ddbf6fc..0000000000 --- a/osu.Game/Migrations/20200302094919_RefreshVolumeBindings.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class RefreshVolumeBindings : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql("DELETE FROM KeyBinding WHERE action in (6,7)"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - } - } -} diff --git a/osu.Game/Migrations/20201019224408_AddEpilepsyWarning.Designer.cs b/osu.Game/Migrations/20201019224408_AddEpilepsyWarning.Designer.cs deleted file mode 100644 index 1c05de832e..0000000000 --- a/osu.Game/Migrations/20201019224408_AddEpilepsyWarning.Designer.cs +++ /dev/null @@ -1,508 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20201019224408_AddEpilepsyWarning")] - partial class AddEpilepsyWarning - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BPM"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("EpilepsyWarning"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("Length"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.Property("VideoFile"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DateAdded"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("SkinInfoID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("SkinInfoID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Settings") - .HasForeignKey("SkinInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20201019224408_AddEpilepsyWarning.cs b/osu.Game/Migrations/20201019224408_AddEpilepsyWarning.cs deleted file mode 100644 index 58a35a7bf3..0000000000 --- a/osu.Game/Migrations/20201019224408_AddEpilepsyWarning.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddEpilepsyWarning : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "EpilepsyWarning", - table: "BeatmapInfo", - nullable: false, - defaultValue: false); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "EpilepsyWarning", - table: "BeatmapInfo"); - } - } -} diff --git a/osu.Game/Migrations/20210412045700_RefreshVolumeBindingsAgain.Designer.cs b/osu.Game/Migrations/20210412045700_RefreshVolumeBindingsAgain.Designer.cs deleted file mode 100644 index 2c100d39b9..0000000000 --- a/osu.Game/Migrations/20210412045700_RefreshVolumeBindingsAgain.Designer.cs +++ /dev/null @@ -1,506 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20210412045700_RefreshVolumeBindingsAgain")] - partial class RefreshVolumeBindingsAgain - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BPM"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("Length"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.Property("VideoFile"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DateAdded"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("SkinInfoID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("SkinInfoID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Settings") - .HasForeignKey("SkinInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20210412045700_RefreshVolumeBindingsAgain.cs b/osu.Game/Migrations/20210412045700_RefreshVolumeBindingsAgain.cs deleted file mode 100644 index 4d3941dd20..0000000000 --- a/osu.Game/Migrations/20210412045700_RefreshVolumeBindingsAgain.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class RefreshVolumeBindingsAgain : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql("DELETE FROM KeyBinding WHERE action in (6,7)"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - } - } -} diff --git a/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.Designer.cs b/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.Designer.cs deleted file mode 100644 index b808c648da..0000000000 --- a/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.Designer.cs +++ /dev/null @@ -1,508 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20210511060743_AddSkinInstantiationInfo")] - partial class AddSkinInstantiationInfo - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BPM"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("EpilepsyWarning"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("Length"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DateAdded"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("SkinInfoID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("SkinInfoID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Settings") - .HasForeignKey("SkinInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.cs b/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.cs deleted file mode 100644 index 887635fa85..0000000000 --- a/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddSkinInstantiationInfo : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "InstantiationInfo", - table: "SkinInfo", - nullable: true); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "InstantiationInfo", - table: "SkinInfo"); - } - } -} diff --git a/osu.Game/Migrations/20210514062639_AddAuthorIdToBeatmapMetadata.Designer.cs b/osu.Game/Migrations/20210514062639_AddAuthorIdToBeatmapMetadata.Designer.cs deleted file mode 100644 index 89bab3a0fa..0000000000 --- a/osu.Game/Migrations/20210514062639_AddAuthorIdToBeatmapMetadata.Designer.cs +++ /dev/null @@ -1,511 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20210514062639_AddAuthorIdToBeatmapMetadata")] - partial class AddAuthorIdToBeatmapMetadata - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BPM"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("DistanceSpacing"); - - b.Property("EpilepsyWarning"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("Length"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorID") - .HasColumnName("AuthorID"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DateAdded"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("SkinInfoID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("SkinInfoID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Settings") - .HasForeignKey("SkinInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20210514062639_AddAuthorIdToBeatmapMetadata.cs b/osu.Game/Migrations/20210514062639_AddAuthorIdToBeatmapMetadata.cs deleted file mode 100644 index 7b579e27b9..0000000000 --- a/osu.Game/Migrations/20210514062639_AddAuthorIdToBeatmapMetadata.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddAuthorIdToBeatmapMetadata : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "AuthorID", - table: "BeatmapMetadata", - nullable: false, - defaultValue: 0); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "AuthorID", - table: "BeatmapMetadata"); - } - } -} diff --git a/osu.Game/Migrations/20210824185035_AddCountdownSettings.Designer.cs b/osu.Game/Migrations/20210824185035_AddCountdownSettings.Designer.cs deleted file mode 100644 index afeb42130d..0000000000 --- a/osu.Game/Migrations/20210824185035_AddCountdownSettings.Designer.cs +++ /dev/null @@ -1,513 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20210824185035_AddCountdownSettings")] - partial class AddCountdownSettings - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BPM"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("CountdownOffset"); - - b.Property("DistanceSpacing"); - - b.Property("EpilepsyWarning"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("Length"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorID") - .HasColumnName("AuthorID"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DateAdded"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("SkinInfoID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("SkinInfoID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Settings") - .HasForeignKey("SkinInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20210824185035_AddCountdownSettings.cs b/osu.Game/Migrations/20210824185035_AddCountdownSettings.cs deleted file mode 100644 index d1b09e2c1d..0000000000 --- a/osu.Game/Migrations/20210824185035_AddCountdownSettings.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddCountdownSettings : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "CountdownOffset", - table: "BeatmapInfo", - nullable: false, - defaultValue: 0); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "CountdownOffset", - table: "BeatmapInfo"); - } - } -} diff --git a/osu.Game/Migrations/20210912144011_AddSamplesMatchPlaybackRate.Designer.cs b/osu.Game/Migrations/20210912144011_AddSamplesMatchPlaybackRate.Designer.cs deleted file mode 100644 index 6e53d7fae0..0000000000 --- a/osu.Game/Migrations/20210912144011_AddSamplesMatchPlaybackRate.Designer.cs +++ /dev/null @@ -1,515 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20210912144011_AddSamplesMatchPlaybackRate")] - partial class AddSamplesMatchPlaybackRate - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BPM"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("CountdownOffset"); - - b.Property("DistanceSpacing"); - - b.Property("EpilepsyWarning"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("Length"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SamplesMatchPlaybackRate"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorID") - .HasColumnName("AuthorID"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DateAdded"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("SkinInfoID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("SkinInfoID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Settings") - .HasForeignKey("SkinInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Migrations/20210912144011_AddSamplesMatchPlaybackRate.cs b/osu.Game/Migrations/20210912144011_AddSamplesMatchPlaybackRate.cs deleted file mode 100644 index f6fc1f4420..0000000000 --- a/osu.Game/Migrations/20210912144011_AddSamplesMatchPlaybackRate.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using Microsoft.EntityFrameworkCore.Migrations; - -namespace osu.Game.Migrations -{ - public partial class AddSamplesMatchPlaybackRate : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "SamplesMatchPlaybackRate", - table: "BeatmapInfo", - nullable: false, - defaultValue: false); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "SamplesMatchPlaybackRate", - table: "BeatmapInfo"); - } - } -} diff --git a/osu.Game/Migrations/20211020081609_ResetSkinHashes.cs b/osu.Game/Migrations/20211020081609_ResetSkinHashes.cs deleted file mode 100644 index 6d53c019ec..0000000000 --- a/osu.Game/Migrations/20211020081609_ResetSkinHashes.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - [Migration("20211020081609_ResetSkinHashes")] - public partial class ResetSkinHashes : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql($"UPDATE SkinInfo SET Hash = null"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - } - } -} diff --git a/osu.Game/Migrations/OsuDbContextModelSnapshot.cs b/osu.Game/Migrations/OsuDbContextModelSnapshot.cs deleted file mode 100644 index 036c26cb0a..0000000000 --- a/osu.Game/Migrations/OsuDbContextModelSnapshot.cs +++ /dev/null @@ -1,513 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using osu.Game.Database; - -namespace osu.Game.Migrations -{ - [DbContext(typeof(OsuDbContext))] - partial class OsuDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("ApproachRate"); - - b.Property("CircleSize"); - - b.Property("DrainRate"); - - b.Property("OverallDifficulty"); - - b.Property("SliderMultiplier"); - - b.Property("SliderTickRate"); - - b.HasKey("ID"); - - b.ToTable("BeatmapDifficulty"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("AudioLeadIn"); - - b.Property("BPM"); - - b.Property("BaseDifficultyID"); - - b.Property("BeatDivisor"); - - b.Property("BeatmapSetInfoID"); - - b.Property("Countdown"); - - b.Property("CountdownOffset"); - - b.Property("DistanceSpacing"); - - b.Property("EpilepsyWarning"); - - b.Property("GridSize"); - - b.Property("Hash"); - - b.Property("Hidden"); - - b.Property("Length"); - - b.Property("LetterboxInBreaks"); - - b.Property("MD5Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapID"); - - b.Property("Path"); - - b.Property("RulesetID"); - - b.Property("SamplesMatchPlaybackRate"); - - b.Property("SpecialStyle"); - - b.Property("StackLeniency"); - - b.Property("StarDifficulty"); - - b.Property("Status"); - - b.Property("StoredBookmarks"); - - b.Property("TimelineZoom"); - - b.Property("Version"); - - b.Property("WidescreenStoryboard"); - - b.HasKey("ID"); - - b.HasIndex("BaseDifficultyID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("Hash"); - - b.HasIndex("MD5Hash"); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("BeatmapInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Artist"); - - b.Property("ArtistUnicode"); - - b.Property("AudioFile"); - - b.Property("AuthorID") - .HasColumnName("AuthorID"); - - b.Property("AuthorString") - .HasColumnName("Author"); - - b.Property("BackgroundFile"); - - b.Property("PreviewTime"); - - b.Property("Source"); - - b.Property("Tags"); - - b.Property("Title"); - - b.Property("TitleUnicode"); - - b.HasKey("ID"); - - b.ToTable("BeatmapMetadata"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("BeatmapSetInfoID"); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.HasKey("ID"); - - b.HasIndex("BeatmapSetInfoID"); - - b.HasIndex("FileInfoID"); - - b.ToTable("BeatmapSetFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("DateAdded"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MetadataID"); - - b.Property("OnlineBeatmapSetID"); - - b.Property("Protected"); - - b.Property("Status"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("MetadataID"); - - b.HasIndex("OnlineBeatmapSetID") - .IsUnique(); - - b.ToTable("BeatmapSetInfo"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Key") - .HasColumnName("Key"); - - b.Property("RulesetID"); - - b.Property("SkinInfoID"); - - b.Property("StringValue") - .HasColumnName("Value"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("SkinInfoID"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("osu.Game.IO.FileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Hash"); - - b.Property("ReferenceCount"); - - b.HasKey("ID"); - - b.HasIndex("Hash") - .IsUnique(); - - b.HasIndex("ReferenceCount"); - - b.ToTable("FileInfo"); - }); - - modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("IntAction") - .HasColumnName("Action"); - - b.Property("KeysString") - .HasColumnName("Keys"); - - b.Property("RulesetID"); - - b.Property("Variant"); - - b.HasKey("ID"); - - b.HasIndex("IntAction"); - - b.HasIndex("RulesetID", "Variant"); - - b.ToTable("KeyBinding"); - }); - - modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Available"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.Property("ShortName"); - - b.HasKey("ID"); - - b.HasIndex("Available"); - - b.HasIndex("ShortName") - .IsUnique(); - - b.ToTable("RulesetInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("ScoreInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("ScoreInfoID"); - - b.ToTable("ScoreFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Accuracy") - .HasColumnType("DECIMAL(1,4)"); - - b.Property("BeatmapInfoID"); - - b.Property("Combo"); - - b.Property("Date"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("MaxCombo"); - - b.Property("ModsJson") - .HasColumnName("Mods"); - - b.Property("OnlineScoreID"); - - b.Property("PP"); - - b.Property("Rank"); - - b.Property("RulesetID"); - - b.Property("StatisticsJson") - .HasColumnName("Statistics"); - - b.Property("TotalScore"); - - b.Property("UserID") - .HasColumnName("UserID"); - - b.Property("UserString") - .HasColumnName("User"); - - b.HasKey("ID"); - - b.HasIndex("BeatmapInfoID"); - - b.HasIndex("OnlineScoreID") - .IsUnique(); - - b.HasIndex("RulesetID"); - - b.ToTable("ScoreInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("FileInfoID"); - - b.Property("Filename") - .IsRequired(); - - b.Property("SkinInfoID"); - - b.HasKey("ID"); - - b.HasIndex("FileInfoID"); - - b.HasIndex("SkinInfoID"); - - b.ToTable("SkinFileInfo"); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => - { - b.Property("ID") - .ValueGeneratedOnAdd(); - - b.Property("Creator"); - - b.Property("DeletePending"); - - b.Property("Hash"); - - b.Property("InstantiationInfo"); - - b.Property("Name"); - - b.HasKey("ID"); - - b.HasIndex("DeletePending"); - - b.HasIndex("Hash") - .IsUnique(); - - b.ToTable("SkinInfo"); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") - .WithMany() - .HasForeignKey("BaseDifficultyID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") - .WithMany("Beatmaps") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("Beatmaps") - .HasForeignKey("MetadataID"); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") - .WithMany("Files") - .HasForeignKey("BeatmapSetInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") - .WithMany("BeatmapSets") - .HasForeignKey("MetadataID"); - }); - - modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => - { - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Settings") - .HasForeignKey("SkinInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Scoring.ScoreInfo") - .WithMany("Files") - .HasForeignKey("ScoreInfoID"); - }); - - modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => - { - b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") - .WithMany("Scores") - .HasForeignKey("BeatmapInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") - .WithMany() - .HasForeignKey("RulesetID") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => - { - b.HasOne("osu.Game.IO.FileInfo", "FileInfo") - .WithMany() - .HasForeignKey("FileInfoID") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("osu.Game.Skinning.SkinInfo") - .WithMany("Files") - .HasForeignKey("SkinInfoID") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/osu.Game/Models/RealmFile.cs b/osu.Game/Models/RealmFile.cs index 9e8da7cdc4..f96b717937 100644 --- a/osu.Game/Models/RealmFile.cs +++ b/osu.Game/Models/RealmFile.cs @@ -5,8 +5,6 @@ using osu.Framework.Testing; using osu.Game.IO; using Realms; -#nullable enable - namespace osu.Game.Models { [ExcludeFromDynamicCompile] diff --git a/osu.Game/Models/RealmNamedFileUsage.cs b/osu.Game/Models/RealmNamedFileUsage.cs index 17e32510a8..c4310c4edb 100644 --- a/osu.Game/Models/RealmNamedFileUsage.cs +++ b/osu.Game/Models/RealmNamedFileUsage.cs @@ -1,14 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using JetBrains.Annotations; using osu.Framework.Testing; using osu.Game.Database; using osu.Game.IO; using Realms; -#nullable enable - namespace osu.Game.Models { [ExcludeFromDynamicCompile] @@ -21,8 +20,8 @@ namespace osu.Game.Models public RealmNamedFileUsage(RealmFile file, string filename) { - File = file; - Filename = filename; + File = file ?? throw new ArgumentNullException(nameof(file)); + Filename = filename ?? throw new ArgumentNullException(nameof(filename)); } [UsedImplicitly] diff --git a/osu.Game/Models/RealmUser.cs b/osu.Game/Models/RealmUser.cs index 18c849cf0a..6997f04f44 100644 --- a/osu.Game/Models/RealmUser.cs +++ b/osu.Game/Models/RealmUser.cs @@ -15,9 +15,19 @@ namespace osu.Game.Models public string Username { get; set; } = string.Empty; + [Ignored] + public CountryCode CountryCode + { + get => Enum.TryParse(CountryString, out CountryCode country) ? country : CountryCode.Unknown; + set => CountryString = value.ToString(); + } + + [MapTo(nameof(CountryCode))] + public string CountryString { get; set; } = default(CountryCode).ToString(); + public bool IsBot => false; - public bool Equals(RealmUser other) + public bool Equals(RealmUser? other) { if (ReferenceEquals(null, other)) return false; if (ReferenceEquals(this, other)) return true; diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 62ddd49881..94bb77d6ec 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; @@ -18,11 +20,13 @@ using osu.Framework.Logging; using osu.Game.Configuration; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Notifications; +using osu.Game.Online.Notifications.WebSocket; using osu.Game.Users; namespace osu.Game.Online.API { - public class APIAccess : Component, IAPIProvider + public partial class APIAccess : Component, IAPIProvider { private readonly OsuConfigManager config; @@ -36,7 +40,7 @@ namespace osu.Game.Online.API public string WebsiteRootUrl { get; } - public int APIVersion => 20220217; // We may want to pull this from the game version eventually. + public int APIVersion => 20220705; // We may want to pull this from the game version eventually. public Exception LastLoginError { get; private set; } @@ -102,114 +106,39 @@ namespace osu.Game.Online.API /// private int failureCount; + /// + /// The main API thread loop, which will continue to run until the game is shut down. + /// private void run() { while (!cancellationToken.IsCancellationRequested) { - switch (State.Value) + if (state.Value == APIState.Failing) { - case APIState.Failing: - //todo: replace this with a ping request. - log.Add(@"In a failing state, waiting a bit before we try again..."); - Thread.Sleep(5000); + // To recover from a failing state, falling through and running the full reconnection process seems safest for now. + // This could probably be replaced with a ping-style request if we want to avoid the reconnection overheads. + log.Add($@"{nameof(APIAccess)} is in a failing state, waiting a bit before we try again..."); + Thread.Sleep(5000); + } - if (!IsLoggedIn) goto case APIState.Connecting; + // Ensure that we have valid credentials. + // If not, setting the offline state will allow the game to prompt the user to provide new credentials. + if (!HasLogin) + { + state.Value = APIState.Offline; + Thread.Sleep(50); + continue; + } - if (queue.Count == 0) - { - log.Add(@"Queueing a ping request"); - Queue(new GetUserRequest()); - } + Debug.Assert(HasLogin); - break; + // Ensure that we are in an online state. If not, attempt a connect. + if (state.Value != APIState.Online) + { + attemptConnect(); - case APIState.Offline: - case APIState.Connecting: - // work to restore a connection... - if (!HasLogin) - { - state.Value = APIState.Offline; - Thread.Sleep(50); - continue; - } - - state.Value = APIState.Connecting; - - // save the username at this point, if the user requested for it to be. - config.SetValue(OsuSetting.Username, config.Get(OsuSetting.SaveUsername) ? ProvidedUsername : string.Empty); - - if (!authentication.HasValidAccessToken) - { - LastLoginError = null; - - try - { - authentication.AuthenticateWithLogin(ProvidedUsername, password); - } - catch (Exception e) - { - //todo: this fails even on network-related issues. we should probably handle those differently. - LastLoginError = e; - log.Add(@"Login failed!"); - password = null; - authentication.Clear(); - continue; - } - } - - var userReq = new GetUserRequest(); - - userReq.Failure += ex => - { - if (ex is WebException webException && webException.Message == @"Unauthorized") - { - log.Add(@"Login no longer valid"); - Logout(); - } - else - failConnectionProcess(); - }; - userReq.Success += u => - { - localUser.Value = u; - - // todo: save/pull from settings - localUser.Value.Status.Value = new UserStatusOnline(); - - failureCount = 0; - }; - - if (!handleRequest(userReq)) - { - failConnectionProcess(); - continue; - } - - // getting user's friends is considered part of the connection process. - var friendsReq = new GetFriendsRequest(); - - friendsReq.Failure += _ => failConnectionProcess(); - friendsReq.Success += res => - { - friends.AddRange(res); - - //we're connected! - state.Value = APIState.Online; - }; - - if (!handleRequest(friendsReq)) - { - failConnectionProcess(); - continue; - } - - // The Success callback event is fired on the main thread, so we should wait for that to run before proceeding. - // Without this, we will end up circulating this Connecting loop multiple times and queueing up many web requests - // before actually going online. - while (State.Value > APIState.Offline && State.Value < APIState.Online) - Thread.Sleep(500); - - break; + if (state.Value != APIState.Online) + continue; } // hard bail if we can't get a valid access token. @@ -219,31 +148,136 @@ namespace osu.Game.Online.API continue; } - while (true) - { - APIRequest req; - - lock (queue) - { - if (queue.Count == 0) break; - - req = queue.Dequeue(); - } - - handleRequest(req); - } - + processQueuedRequests(); Thread.Sleep(50); } + } - void failConnectionProcess() + /// + /// Dequeue from the queue and run each request synchronously until the queue is empty. + /// + private void processQueuedRequests() + { + while (true) { - // if something went wrong during the connection process, we want to reset the state (but only if still connecting). - if (State.Value == APIState.Connecting) - state.Value = APIState.Failing; + APIRequest req; + + lock (queue) + { + if (queue.Count == 0) return; + + req = queue.Dequeue(); + } + + handleRequest(req); } } + /// + /// From a non-connected state, perform a full connection flow, obtaining OAuth tokens and populating the local user and friends. + /// + /// + /// This method takes control of and transitions from to either + /// - (successful connection) + /// - (failed connection but retrying) + /// - (failed and can't retry, clear credentials and require user interaction) + /// + /// Whether the connection attempt was successful. + private void attemptConnect() + { + state.Value = APIState.Connecting; + + if (localUser.IsDefault) + { + // Show a placeholder user if saved credentials are available. + // This is useful for storing local scores and showing a placeholder username after starting the game, + // until a valid connection has been established. + setLocalUser(new APIUser + { + Username = ProvidedUsername, + }); + } + + // save the username at this point, if the user requested for it to be. + config.SetValue(OsuSetting.Username, config.Get(OsuSetting.SaveUsername) ? ProvidedUsername : string.Empty); + + if (!authentication.HasValidAccessToken) + { + LastLoginError = null; + + try + { + authentication.AuthenticateWithLogin(ProvidedUsername, password); + } + catch (Exception e) + { + //todo: this fails even on network-related issues. we should probably handle those differently. + LastLoginError = e; + log.Add($@"Login failed for username {ProvidedUsername} ({LastLoginError.Message})!"); + + Logout(); + return; + } + } + + var userReq = new GetUserRequest(); + userReq.Failure += ex => + { + if (ex is APIException) + { + LastLoginError = ex; + log.Add($@"Login failed for username {ProvidedUsername} on user retrieval ({LastLoginError.Message})!"); + Logout(); + } + else if (ex is WebException webException && webException.Message == @"Unauthorized") + { + log.Add(@"Login no longer valid"); + Logout(); + } + else + { + state.Value = APIState.Failing; + } + }; + userReq.Success += user => + { + // todo: save/pull from settings + user.Status.Value = new UserStatusOnline(); + + setLocalUser(user); + + // we're connected! + state.Value = APIState.Online; + failureCount = 0; + }; + + if (!handleRequest(userReq)) + { + state.Value = APIState.Failing; + return; + } + + var friendsReq = new GetFriendsRequest(); + friendsReq.Failure += _ => state.Value = APIState.Failing; + friendsReq.Success += res => + { + friends.Clear(); + friends.AddRange(res); + }; + + if (!handleRequest(friendsReq)) + { + state.Value = APIState.Failing; + return; + } + + // The Success callback event is fired on the main thread, so we should wait for that to run before proceeding. + // Without this, we will end up circulating this Connecting loop multiple times and queueing up many web requests + // before actually going online. + while (State.Value == APIState.Connecting && !cancellationToken.IsCancellationRequested) + Thread.Sleep(500); + } + public void Perform(APIRequest request) { try @@ -271,6 +305,9 @@ namespace osu.Game.Online.API public IHubClientConnector GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => new HubClientConnector(clientName, endpoint, this, versionHash, preferMessagePack); + public NotificationsClientConnector GetNotificationsConnector() => + new WebSocketNotificationsClientConnector(this); + public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password) { Debug.Assert(State.Value == APIState.Offline); @@ -292,12 +329,35 @@ namespace osu.Game.Online.API { try { - return JObject.Parse(req.GetResponseString().AsNonNull()).SelectToken("form_error", true).AsNonNull().ToObject(); + return JObject.Parse(req.GetResponseString().AsNonNull()).SelectToken(@"form_error", true).AsNonNull().ToObject(); } catch { - // if we couldn't deserialize the error message let's throw the original exception outwards. - e.Rethrow(); + try + { + // attempt to parse a non-form error message + var response = JObject.Parse(req.GetResponseString().AsNonNull()); + + string redirect = (string)response.SelectToken(@"url", true); + string message = (string)response.SelectToken(@"error", false); + + if (!string.IsNullOrEmpty(redirect)) + { + return new RegistrationRequest.RegistrationRequestErrors + { + Redirect = redirect, + Message = message, + }; + } + + // if we couldn't deserialize the error message let's throw the original exception outwards. + e.Rethrow(); + } + catch + { + // if we couldn't deserialize the error message let's throw the original exception outwards. + e.Rethrow(); + } } } @@ -319,8 +379,7 @@ namespace osu.Game.Online.API if (req.CompletionState != APIRequestCompletionState.Completed) return false; - // we could still be in initialisation, at which point we don't want to say we're Online yet. - if (IsLoggedIn) state.Value = APIState.Online; + // Reset failure count if this request succeeded. failureCount = 0; return true; } @@ -387,14 +446,14 @@ namespace osu.Game.Online.API failureCount++; log.Add($@"API failure count is now {failureCount}"); - if (failureCount >= 3 && State.Value == APIState.Online) + if (failureCount >= 3) { state.Value = APIState.Failing; flushQueue(); } } - public bool IsLoggedIn => localUser.Value.Id > 1; // TODO: should this also be true if attempting to connect? + public bool IsLoggedIn => State.Value > APIState.Offline; public void Queue(APIRequest request) { @@ -434,7 +493,7 @@ namespace osu.Game.Online.API // Scheduled prior to state change such that the state changed event is invoked with the correct user and their friends present Schedule(() => { - localUser.Value = createGuestUser(); + setLocalUser(createGuestUser()); friends.Clear(); }); @@ -444,6 +503,8 @@ namespace osu.Game.Online.API private static APIUser createGuestUser() => new GuestUser(); + private void setLocalUser(APIUser user) => Scheduler.Add(() => localUser.Value = user, false); + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Online/API/APIDownloadRequest.cs b/osu.Game/Online/API/APIDownloadRequest.cs index 11753e05ba..c48372278a 100644 --- a/osu.Game/Online/API/APIDownloadRequest.cs +++ b/osu.Game/Online/API/APIDownloadRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.IO; using osu.Framework.IO.Network; diff --git a/osu.Game/Online/API/APIException.cs b/osu.Game/Online/API/APIException.cs index 54d68d8f0d..21a9c761c7 100644 --- a/osu.Game/Online/API/APIException.cs +++ b/osu.Game/Online/API/APIException.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; namespace osu.Game.Online.API diff --git a/osu.Game/Online/API/APIMessagesRequest.cs b/osu.Game/Online/API/APIMessagesRequest.cs index 3ad6b1d7c8..5bb3e29621 100644 --- a/osu.Game/Online/API/APIMessagesRequest.cs +++ b/osu.Game/Online/API/APIMessagesRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.IO.Network; using osu.Game.Online.Chat; diff --git a/osu.Game/Online/API/APIMod.cs b/osu.Game/Online/API/APIMod.cs index 524f7b7108..45128375ab 100644 --- a/osu.Game/Online/API/APIMod.cs +++ b/osu.Game/Online/API/APIMod.cs @@ -4,12 +4,12 @@ using System; using System.Collections.Generic; using System.Linq; -using Humanizer; using MessagePack; using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Framework.Logging; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -20,7 +20,7 @@ namespace osu.Game.Online.API { [JsonProperty("acronym")] [Key(0)] - public string Acronym { get; set; } + public string Acronym { get; set; } = string.Empty; [JsonProperty("settings")] [Key(1)] @@ -39,16 +39,16 @@ namespace osu.Game.Online.API foreach (var (_, property) in mod.GetSettingsSourceProperties()) { - var bindable = (IBindable)property.GetValue(mod); + var bindable = (IBindable)property.GetValue(mod)!; if (!bindable.IsDefault) - Settings.Add(property.Name.Underscore(), bindable.GetUnderlyingSettingValue()); + Settings.Add(property.Name.ToSnakeCase(), bindable.GetUnderlyingSettingValue()); } } public Mod ToMod(Ruleset ruleset) { - Mod resultMod = ruleset.CreateModFromAcronym(Acronym); + Mod? resultMod = ruleset.CreateModFromAcronym(Acronym); if (resultMod == null) { @@ -60,17 +60,26 @@ namespace osu.Game.Online.API { foreach (var (_, property) in resultMod.GetSettingsSourceProperties()) { - if (!Settings.TryGetValue(property.Name.Underscore(), out object settingValue)) + if (!Settings.TryGetValue(property.Name.ToSnakeCase(), out object? settingValue)) continue; - resultMod.CopyAdjustedSetting((IBindable)property.GetValue(resultMod), settingValue); + try + { + resultMod.CopyAdjustedSetting((IBindable)property.GetValue(resultMod)!, settingValue); + } + catch (Exception ex) + { + Logger.Log($"Failed to copy mod setting value '{settingValue}' to \"{property.Name}\": {ex.Message}"); + } } } return resultMod; } - public bool Equals(APIMod other) + public bool ShouldSerializeSettings() => Settings.Count > 0; + + public bool Equals(APIMod? other) { if (ReferenceEquals(null, other)) return false; if (ReferenceEquals(this, other)) return true; diff --git a/osu.Game/Online/API/APIRequest.cs b/osu.Game/Online/API/APIRequest.cs index 451ea117d5..dc6a3fe3d5 100644 --- a/osu.Game/Online/API/APIRequest.cs +++ b/osu.Game/Online/API/APIRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Globalization; using JetBrains.Annotations; diff --git a/osu.Game/Online/API/APIRequestCompletionState.cs b/osu.Game/Online/API/APIRequestCompletionState.cs index 84c9974dd8..52eb669a7d 100644 --- a/osu.Game/Online/API/APIRequestCompletionState.cs +++ b/osu.Game/Online/API/APIRequestCompletionState.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Online.API { public enum APIRequestCompletionState diff --git a/osu.Game/Online/API/ArchiveDownloadRequest.cs b/osu.Game/Online/API/ArchiveDownloadRequest.cs index 0bf238109e..ff96b39e08 100644 --- a/osu.Game/Online/API/ArchiveDownloadRequest.cs +++ b/osu.Game/Online/API/ArchiveDownloadRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; namespace osu.Game.Online.API diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index d3707d977c..abe2755654 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.cs @@ -1,22 +1,28 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Threading; using System.Threading.Tasks; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Notifications; +using osu.Game.Tests; using osu.Game.Users; namespace osu.Game.Online.API { - public class DummyAPIAccess : Component, IAPIProvider + public partial class DummyAPIAccess : Component, IAPIProvider { + public const int DUMMY_USER_ID = 1001; + public Bindable LocalUser { get; } = new Bindable(new APIUser { Username = @"Dummy", - Id = 1001, + Id = DUMMY_USER_ID, }); public BindableList Friends { get; } = new BindableList(); @@ -111,6 +117,8 @@ namespace osu.Game.Online.API public IHubClientConnector GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => null; + public NotificationsClientConnector GetNotificationsConnector() => new PollingNotificationsClientConnector(this); + public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password) { Thread.Sleep(200); @@ -124,5 +132,13 @@ namespace osu.Game.Online.API IBindable IAPIProvider.Activity => Activity; public void FailNextLogin() => shouldFailNextLogin = true; + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + // Ensure (as much as we can) that any pending tasks are run. + Scheduler.Update(); + } } } diff --git a/osu.Game/Online/API/IAPIProvider.cs b/osu.Game/Online/API/IAPIProvider.cs index 470d46cd7f..6054effaa1 100644 --- a/osu.Game/Online/API/IAPIProvider.cs +++ b/osu.Game/Online/API/IAPIProvider.cs @@ -1,12 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - using System; using System.Threading.Tasks; using osu.Framework.Bindables; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Notifications; using osu.Game.Users; namespace osu.Game.Online.API @@ -15,19 +14,16 @@ namespace osu.Game.Online.API { /// /// The local user. - /// This is not thread-safe and should be scheduled locally if consumed from a drawable component. /// IBindable LocalUser { get; } /// /// The user's friends. - /// This is not thread-safe and should be scheduled locally if consumed from a drawable component. /// IBindableList Friends { get; } /// /// The current user's activity. - /// This is not thread-safe and should be scheduled locally if consumed from a drawable component. /// IBindable Activity { get; } @@ -117,6 +113,11 @@ namespace osu.Game.Online.API /// Whether to use MessagePack for serialisation if available on this platform. IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack = true); + /// + /// Constructs a new . + /// + NotificationsClientConnector GetNotificationsConnector(); + /// /// Create a new user account. This is a blocking operation. /// diff --git a/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs b/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs index a7c63c17f9..df64984c7a 100644 --- a/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs +++ b/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Buffers; using System.Collections.Generic; using System.Text; diff --git a/osu.Game/Online/API/OAuth.cs b/osu.Game/Online/API/OAuth.cs index 1feb3076d1..58306c1938 100644 --- a/osu.Game/Online/API/OAuth.cs +++ b/osu.Game/Online/API/OAuth.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using System.Net.Http; diff --git a/osu.Game/Online/API/OAuthToken.cs b/osu.Game/Online/API/OAuthToken.cs index f103d0917d..63c88b875b 100644 --- a/osu.Game/Online/API/OAuthToken.cs +++ b/osu.Game/Online/API/OAuthToken.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Globalization; using Newtonsoft.Json; diff --git a/osu.Game/Online/API/OsuJsonWebRequest.cs b/osu.Game/Online/API/OsuJsonWebRequest.cs index 4a45a8b261..2d402edd3f 100644 --- a/osu.Game/Online/API/OsuJsonWebRequest.cs +++ b/osu.Game/Online/API/OsuJsonWebRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.IO.Network; namespace osu.Game.Online.API diff --git a/osu.Game/Online/API/OsuWebRequest.cs b/osu.Game/Online/API/OsuWebRequest.cs index 1d27899473..9a7cf45a2f 100644 --- a/osu.Game/Online/API/OsuWebRequest.cs +++ b/osu.Game/Online/API/OsuWebRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.IO.Network; namespace osu.Game.Online.API diff --git a/osu.Game/Online/API/RegistrationRequest.cs b/osu.Game/Online/API/RegistrationRequest.cs index f650e5c93b..78633f70b7 100644 --- a/osu.Game/Online/API/RegistrationRequest.cs +++ b/osu.Game/Online/API/RegistrationRequest.cs @@ -1,15 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using Newtonsoft.Json; namespace osu.Game.Online.API { public class RegistrationRequest : OsuWebRequest { - internal string Username; - internal string Email; - internal string Password; + internal string Username = string.Empty; + internal string Email = string.Empty; + internal string Password = string.Empty; protected override void PrePerform() { @@ -22,18 +23,28 @@ namespace osu.Game.Online.API public class RegistrationRequestErrors { - public UserErrors User; + /// + /// An optional error message. + /// + public string? Message; + + /// + /// An optional URL which the user should be directed towards to complete registration. + /// + public string? Redirect; + + public UserErrors? User; public class UserErrors { [JsonProperty("username")] - public string[] Username; + public string[] Username = Array.Empty(); [JsonProperty("user_email")] - public string[] Email; + public string[] Email = Array.Empty(); [JsonProperty("password")] - public string[] Password; + public string[] Password = Array.Empty(); } } } diff --git a/osu.Game/Online/API/Requests/ChatAckRequest.cs b/osu.Game/Online/API/Requests/ChatAckRequest.cs new file mode 100644 index 0000000000..306b5acc1d --- /dev/null +++ b/osu.Game/Online/API/Requests/ChatAckRequest.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net.Http; +using osu.Framework.IO.Network; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + /// + /// A request which should be sent occasionally while interested in chat and online state. + /// + /// This will: + /// - Mark the user as "online" (for 10 minutes since the last invocation). + /// - Return any silences since the last invocation (if either or is not null). + /// + /// For silence handling, a should be provided as soon as a message is received by the client. + /// From that point forward, should be preferred after the first + /// arrives in a response from the ack request. Specifying both parameters will prioritise the latter. + /// + public class ChatAckRequest : APIRequest + { + public long? SinceMessageId; + public uint? SinceSilenceId; + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Post; + if (SinceMessageId != null) + req.AddParameter(@"since", SinceMessageId.ToString()); + if (SinceSilenceId != null) + req.AddParameter(@"history_since", SinceSilenceId.Value.ToString()); + return req; + } + + protected override string Target => "chat/ack"; + } +} diff --git a/osu.Game/Online/API/Requests/CommentDeleteRequest.cs b/osu.Game/Online/API/Requests/CommentDeleteRequest.cs new file mode 100644 index 0000000000..b150a6d5fc --- /dev/null +++ b/osu.Game/Online/API/Requests/CommentDeleteRequest.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net.Http; +using osu.Framework.IO.Network; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class CommentDeleteRequest : APIRequest + { + public readonly long CommentId; + + public CommentDeleteRequest(long id) + { + CommentId = id; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Delete; + return req; + } + + protected override string Target => $@"comments/{CommentId}"; + } +} diff --git a/osu.Game/Online/API/Requests/CommentPostRequest.cs b/osu.Game/Online/API/Requests/CommentPostRequest.cs new file mode 100644 index 0000000000..45e5eb1a94 --- /dev/null +++ b/osu.Game/Online/API/Requests/CommentPostRequest.cs @@ -0,0 +1,41 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net.Http; +using osu.Framework.IO.Network; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class CommentPostRequest : APIRequest + { + public readonly CommentableType Commentable; + public readonly long CommentableId; + public readonly string Message; + public readonly long? ParentCommentId; + + public CommentPostRequest(CommentableType commentable, long commentableId, string message, long? parentCommentId = null) + { + Commentable = commentable; + CommentableId = commentableId; + Message = message; + ParentCommentId = parentCommentId; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Post; + + req.AddParameter(@"comment[commentable_type]", Commentable.ToString().ToLowerInvariant()); + req.AddParameter(@"comment[commentable_id]", $"{CommentableId}"); + req.AddParameter(@"comment[message]", Message); + if (ParentCommentId.HasValue) + req.AddParameter(@"comment[parent_id]", $"{ParentCommentId}"); + + return req; + } + + protected override string Target => "comments"; + } +} diff --git a/osu.Game/Online/API/Requests/CommentReportRequest.cs b/osu.Game/Online/API/Requests/CommentReportRequest.cs new file mode 100644 index 0000000000..3f57756ced --- /dev/null +++ b/osu.Game/Online/API/Requests/CommentReportRequest.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.Comments; + +namespace osu.Game.Online.API.Requests +{ + public class CommentReportRequest : APIRequest + { + public readonly long CommentID; + public readonly CommentReportReason Reason; + public readonly string Comment; + + public CommentReportRequest(long commentID, CommentReportReason reason, string comment) + { + CommentID = commentID; + Reason = reason; + Comment = comment; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Post; + + req.AddParameter(@"reportable_type", @"comment"); + req.AddParameter(@"reportable_id", $"{CommentID}"); + req.AddParameter(@"reason", Reason.ToString()); + req.AddParameter(@"comments", Comment); + + return req; + } + + protected override string Target => @"reports"; + } +} diff --git a/osu.Game/Online/API/Requests/CommentVoteRequest.cs b/osu.Game/Online/API/Requests/CommentVoteRequest.cs index 06a3b1126e..a835b0365c 100644 --- a/osu.Game/Online/API/Requests/CommentVoteRequest.cs +++ b/osu.Game/Online/API/Requests/CommentVoteRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.IO.Network; using osu.Game.Online.API.Requests.Responses; using System.Net.Http; diff --git a/osu.Game/Online/API/Requests/CreateChannelRequest.cs b/osu.Game/Online/API/Requests/CreateChannelRequest.cs index 041ad26267..130210b1c3 100644 --- a/osu.Game/Online/API/Requests/CreateChannelRequest.cs +++ b/osu.Game/Online/API/Requests/CreateChannelRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using System.Net.Http; using osu.Framework.IO.Network; diff --git a/osu.Game/Online/API/Requests/CreateNewPrivateMessageRequest.cs b/osu.Game/Online/API/Requests/CreateNewPrivateMessageRequest.cs index e5761149e7..6b7192dbf4 100644 --- a/osu.Game/Online/API/Requests/CreateNewPrivateMessageRequest.cs +++ b/osu.Game/Online/API/Requests/CreateNewPrivateMessageRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Net.Http; using osu.Framework.IO.Network; using osu.Game.Online.API.Requests.Responses; @@ -26,6 +28,7 @@ namespace osu.Game.Online.API.Requests req.AddParameter(@"target_id", user.Id.ToString()); req.AddParameter(@"message", message.Content); req.AddParameter(@"is_action", message.IsAction.ToString().ToLowerInvariant()); + req.AddParameter(@"uuid", message.Uuid); return req; } diff --git a/osu.Game/Online/API/Requests/CreateNewPrivateMessageResponse.cs b/osu.Game/Online/API/Requests/CreateNewPrivateMessageResponse.cs index f749b27114..1af61770df 100644 --- a/osu.Game/Online/API/Requests/CreateNewPrivateMessageResponse.cs +++ b/osu.Game/Online/API/Requests/CreateNewPrivateMessageResponse.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Newtonsoft.Json; using osu.Game.Online.Chat; diff --git a/osu.Game/Online/API/Requests/Cursor.cs b/osu.Game/Online/API/Requests/Cursor.cs index 3de8db770c..c7bb119bd8 100644 --- a/osu.Game/Online/API/Requests/Cursor.cs +++ b/osu.Game/Online/API/Requests/Cursor.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using JetBrains.Annotations; using Newtonsoft.Json; diff --git a/osu.Game/Online/API/Requests/DownloadBeatmapSetRequest.cs b/osu.Game/Online/API/Requests/DownloadBeatmapSetRequest.cs index 5254dc3cf8..f190b6e821 100644 --- a/osu.Game/Online/API/Requests/DownloadBeatmapSetRequest.cs +++ b/osu.Game/Online/API/Requests/DownloadBeatmapSetRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.IO.Network; using osu.Game.Beatmaps; diff --git a/osu.Game/Online/API/Requests/DownloadReplayRequest.cs b/osu.Game/Online/API/Requests/DownloadReplayRequest.cs index 77174f0bb5..5635c4728e 100644 --- a/osu.Game/Online/API/Requests/DownloadReplayRequest.cs +++ b/osu.Game/Online/API/Requests/DownloadReplayRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Scoring; namespace osu.Game.Online.API.Requests diff --git a/osu.Game/Online/API/Requests/GetBeatmapRequest.cs b/osu.Game/Online/API/Requests/GetBeatmapRequest.cs index 671f543422..3383d21dfc 100644 --- a/osu.Game/Online/API/Requests/GetBeatmapRequest.cs +++ b/osu.Game/Online/API/Requests/GetBeatmapRequest.cs @@ -5,33 +5,29 @@ using osu.Framework.IO.Network; using osu.Game.Beatmaps; using osu.Game.Online.API.Requests.Responses; -#nullable enable - namespace osu.Game.Online.API.Requests { public class GetBeatmapRequest : APIRequest { - private readonly IBeatmapInfo beatmapInfo; - - private readonly string filename; + public readonly IBeatmapInfo BeatmapInfo; + public readonly string Filename; public GetBeatmapRequest(IBeatmapInfo beatmapInfo) { - this.beatmapInfo = beatmapInfo; - - filename = (beatmapInfo as BeatmapInfo)?.Path ?? string.Empty; + BeatmapInfo = beatmapInfo; + Filename = (beatmapInfo as BeatmapInfo)?.Path ?? string.Empty; } protected override WebRequest CreateWebRequest() { var request = base.CreateWebRequest(); - if (beatmapInfo.OnlineID > 0) - request.AddParameter(@"id", beatmapInfo.OnlineID.ToString()); - if (!string.IsNullOrEmpty(beatmapInfo.MD5Hash)) - request.AddParameter(@"checksum", beatmapInfo.MD5Hash); - if (!string.IsNullOrEmpty(filename)) - request.AddParameter(@"filename", filename); + if (BeatmapInfo.OnlineID > 0) + request.AddParameter(@"id", BeatmapInfo.OnlineID.ToString()); + if (!string.IsNullOrEmpty(BeatmapInfo.MD5Hash)) + request.AddParameter(@"checksum", BeatmapInfo.MD5Hash); + if (!string.IsNullOrEmpty(Filename)) + request.AddParameter(@"filename", Filename); return request; } diff --git a/osu.Game/Online/API/Requests/GetBeatmapSetRequest.cs b/osu.Game/Online/API/Requests/GetBeatmapSetRequest.cs index 158ae03b8d..f3690c934d 100644 --- a/osu.Game/Online/API/Requests/GetBeatmapSetRequest.cs +++ b/osu.Game/Online/API/Requests/GetBeatmapSetRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Online.API.Requests diff --git a/osu.Game/Online/API/Requests/GetBeatmapsRequest.cs b/osu.Game/Online/API/Requests/GetBeatmapsRequest.cs index 22af022659..e118d1bddc 100644 --- a/osu.Game/Online/API/Requests/GetBeatmapsRequest.cs +++ b/osu.Game/Online/API/Requests/GetBeatmapsRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; diff --git a/osu.Game/Online/API/Requests/GetBeatmapsResponse.cs b/osu.Game/Online/API/Requests/GetBeatmapsResponse.cs index c450c3269c..c91aa5002c 100644 --- a/osu.Game/Online/API/Requests/GetBeatmapsResponse.cs +++ b/osu.Game/Online/API/Requests/GetBeatmapsResponse.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using Newtonsoft.Json; using osu.Game.Online.API.Requests.Responses; diff --git a/osu.Game/Online/API/Requests/GetChangelogBuildRequest.cs b/osu.Game/Online/API/Requests/GetChangelogBuildRequest.cs index baa15c70c4..2d2d241b86 100644 --- a/osu.Game/Online/API/Requests/GetChangelogBuildRequest.cs +++ b/osu.Game/Online/API/Requests/GetChangelogBuildRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Online.API.Requests diff --git a/osu.Game/Online/API/Requests/GetChangelogRequest.cs b/osu.Game/Online/API/Requests/GetChangelogRequest.cs index 97799ff66a..82ed42615f 100644 --- a/osu.Game/Online/API/Requests/GetChangelogRequest.cs +++ b/osu.Game/Online/API/Requests/GetChangelogRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Online.API.Requests diff --git a/osu.Game/Online/API/Requests/GetChannelRequest.cs b/osu.Game/Online/API/Requests/GetChannelRequest.cs new file mode 100644 index 0000000000..5bc9cb519a --- /dev/null +++ b/osu.Game/Online/API/Requests/GetChannelRequest.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class GetChannelRequest : APIRequest + { + private readonly long channelId; + + public GetChannelRequest(long channelId) + { + this.channelId = channelId; + } + + protected override string Target => $"chat/channels/{channelId}"; + } +} diff --git a/osu.Game/Online/API/Requests/GetCommentsRequest.cs b/osu.Game/Online/API/Requests/GetCommentsRequest.cs index 24dae4adf1..1aa08f2ed8 100644 --- a/osu.Game/Online/API/Requests/GetCommentsRequest.cs +++ b/osu.Game/Online/API/Requests/GetCommentsRequest.cs @@ -1,8 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.IO.Network; -using Humanizer; +using osu.Game.Extensions; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Comments; @@ -30,7 +32,7 @@ namespace osu.Game.Online.API.Requests var req = base.CreateWebRequest(); req.AddParameter("commentable_id", commentableId.ToString()); - req.AddParameter("commentable_type", type.ToString().Underscore().ToLowerInvariant()); + req.AddParameter("commentable_type", type.ToString().ToSnakeCase().ToLowerInvariant()); req.AddParameter("page", page.ToString()); req.AddParameter("sort", sort.ToString().ToLowerInvariant()); diff --git a/osu.Game/Online/API/Requests/GetCountriesResponse.cs b/osu.Game/Online/API/Requests/GetCountriesResponse.cs index 6624344b44..117993b98c 100644 --- a/osu.Game/Online/API/Requests/GetCountriesResponse.cs +++ b/osu.Game/Online/API/Requests/GetCountriesResponse.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using Newtonsoft.Json; using osu.Game.Users; diff --git a/osu.Game/Online/API/Requests/GetCountryRankingsRequest.cs b/osu.Game/Online/API/Requests/GetCountryRankingsRequest.cs index d8a1198627..9d037ab116 100644 --- a/osu.Game/Online/API/Requests/GetCountryRankingsRequest.cs +++ b/osu.Game/Online/API/Requests/GetCountryRankingsRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets; namespace osu.Game.Online.API.Requests diff --git a/osu.Game/Online/API/Requests/GetFriendsRequest.cs b/osu.Game/Online/API/Requests/GetFriendsRequest.cs index 63a221d91a..640ddcbb9e 100644 --- a/osu.Game/Online/API/Requests/GetFriendsRequest.cs +++ b/osu.Game/Online/API/Requests/GetFriendsRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Game.Online.API.Requests.Responses; diff --git a/osu.Game/Online/API/Requests/GetMessagesRequest.cs b/osu.Game/Online/API/Requests/GetMessagesRequest.cs index 651f8a06c5..2f9879c63f 100644 --- a/osu.Game/Online/API/Requests/GetMessagesRequest.cs +++ b/osu.Game/Online/API/Requests/GetMessagesRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Game.Online.Chat; diff --git a/osu.Game/Online/API/Requests/GetNewsRequest.cs b/osu.Game/Online/API/Requests/GetNewsRequest.cs index 992ccc6d59..64bed344bb 100644 --- a/osu.Game/Online/API/Requests/GetNewsRequest.cs +++ b/osu.Game/Online/API/Requests/GetNewsRequest.cs @@ -9,9 +9,9 @@ namespace osu.Game.Online.API.Requests public class GetNewsRequest : APIRequest { private readonly int? year; - private readonly Cursor cursor; + private readonly Cursor? cursor; - public GetNewsRequest(int? year = null, Cursor cursor = null) + public GetNewsRequest(int? year = null, Cursor? cursor = null) { this.year = year; this.cursor = cursor; @@ -20,7 +20,9 @@ namespace osu.Game.Online.API.Requests protected override WebRequest CreateWebRequest() { var req = base.CreateWebRequest(); - req.AddCursor(cursor); + + if (cursor != null) + req.AddCursor(cursor); if (year.HasValue) req.AddParameter("year", year.Value.ToString()); diff --git a/osu.Game/Online/API/Requests/GetNewsResponse.cs b/osu.Game/Online/API/Requests/GetNewsResponse.cs index 98f76d105c..b62d83557e 100644 --- a/osu.Game/Online/API/Requests/GetNewsResponse.cs +++ b/osu.Game/Online/API/Requests/GetNewsResponse.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using Newtonsoft.Json; using osu.Game.Online.API.Requests.Responses; diff --git a/osu.Game/Online/API/Requests/GetNotificationsRequest.cs b/osu.Game/Online/API/Requests/GetNotificationsRequest.cs new file mode 100644 index 0000000000..afd4da296e --- /dev/null +++ b/osu.Game/Online/API/Requests/GetNotificationsRequest.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class GetNotificationsRequest : APIRequest + { + protected override string Target => @"notifications"; + } +} diff --git a/osu.Game/Online/API/Requests/GetRankingsRequest.cs b/osu.Game/Online/API/Requests/GetRankingsRequest.cs index ddc3298ca7..f42da69dcc 100644 --- a/osu.Game/Online/API/Requests/GetRankingsRequest.cs +++ b/osu.Game/Online/API/Requests/GetRankingsRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.IO.Network; using osu.Game.Rulesets; diff --git a/osu.Game/Online/API/Requests/GetScoresRequest.cs b/osu.Game/Online/API/Requests/GetScoresRequest.cs index 8e447390e6..f2a2daccb5 100644 --- a/osu.Game/Online/API/Requests/GetScoresRequest.cs +++ b/osu.Game/Online/API/Requests/GetScoresRequest.cs @@ -9,17 +9,20 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets.Mods; using System.Text; using System.Collections.Generic; +using System.Linq; namespace osu.Game.Online.API.Requests { - public class GetScoresRequest : APIRequest + public class GetScoresRequest : APIRequest, IEquatable { + public const int MAX_SCORES_PER_REQUEST = 50; + private readonly IBeatmapInfo beatmapInfo; private readonly BeatmapLeaderboardScope scope; private readonly IRulesetInfo ruleset; private readonly IEnumerable mods; - public GetScoresRequest(IBeatmapInfo beatmapInfo, IRulesetInfo ruleset, BeatmapLeaderboardScope scope = BeatmapLeaderboardScope.Global, IEnumerable mods = null) + public GetScoresRequest(IBeatmapInfo beatmapInfo, IRulesetInfo ruleset, BeatmapLeaderboardScope scope = BeatmapLeaderboardScope.Global, IEnumerable? mods = null) { if (beatmapInfo.OnlineID <= 0) throw new InvalidOperationException($"Cannot lookup a beatmap's scores without having a populated {nameof(IBeatmapInfo.OnlineID)}."); @@ -33,7 +36,7 @@ namespace osu.Game.Online.API.Requests this.mods = mods ?? Array.Empty(); } - protected override string Target => $@"beatmaps/{beatmapInfo.OnlineID}/scores{createQueryParameters()}"; + protected override string Target => $@"beatmaps/{beatmapInfo.OnlineID}/solo-scores{createQueryParameters()}"; private string createQueryParameters() { @@ -47,5 +50,16 @@ namespace osu.Game.Online.API.Requests return query.ToString(); } + + public bool Equals(GetScoresRequest? other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + + return beatmapInfo.Equals(other.beatmapInfo) + && scope == other.scope + && ruleset.Equals(other.ruleset) + && mods.SequenceEqual(other.mods); + } } } diff --git a/osu.Game/Online/API/Requests/GetSeasonalBackgroundsRequest.cs b/osu.Game/Online/API/Requests/GetSeasonalBackgroundsRequest.cs index 941b47244a..0ecce90749 100644 --- a/osu.Game/Online/API/Requests/GetSeasonalBackgroundsRequest.cs +++ b/osu.Game/Online/API/Requests/GetSeasonalBackgroundsRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Online.API.Requests diff --git a/osu.Game/Online/API/Requests/GetSpotlightRankingsRequest.cs b/osu.Game/Online/API/Requests/GetSpotlightRankingsRequest.cs index 20856c2768..2d8a8b3b61 100644 --- a/osu.Game/Online/API/Requests/GetSpotlightRankingsRequest.cs +++ b/osu.Game/Online/API/Requests/GetSpotlightRankingsRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.IO.Network; using osu.Game.Overlays.Rankings; using osu.Game.Rulesets; @@ -24,7 +26,7 @@ namespace osu.Game.Online.API.Requests var req = base.CreateWebRequest(); req.AddParameter("spotlight", spotlight.ToString()); - req.AddParameter("filter", sort.ToString().ToLower()); + req.AddParameter("filter", sort.ToString().ToLowerInvariant()); return req; } diff --git a/osu.Game/Online/API/Requests/GetSpotlightRankingsResponse.cs b/osu.Game/Online/API/Requests/GetSpotlightRankingsResponse.cs index 2259314a9f..5bc7bd5e08 100644 --- a/osu.Game/Online/API/Requests/GetSpotlightRankingsResponse.cs +++ b/osu.Game/Online/API/Requests/GetSpotlightRankingsResponse.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using Newtonsoft.Json; using osu.Game.Online.API.Requests.Responses; diff --git a/osu.Game/Online/API/Requests/GetSpotlightsRequest.cs b/osu.Game/Online/API/Requests/GetSpotlightsRequest.cs index 6fafb3933c..93a63afcaa 100644 --- a/osu.Game/Online/API/Requests/GetSpotlightsRequest.cs +++ b/osu.Game/Online/API/Requests/GetSpotlightsRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using Newtonsoft.Json; using osu.Game.Online.API.Requests.Responses; diff --git a/osu.Game/Online/API/Requests/GetTopUsersRequest.cs b/osu.Game/Online/API/Requests/GetTopUsersRequest.cs index dbbd2119db..7f05cd5eab 100644 --- a/osu.Game/Online/API/Requests/GetTopUsersRequest.cs +++ b/osu.Game/Online/API/Requests/GetTopUsersRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Online.API.Requests { public class GetTopUsersRequest : APIRequest diff --git a/osu.Game/Online/API/Requests/GetTopUsersResponse.cs b/osu.Game/Online/API/Requests/GetTopUsersResponse.cs index b37b8b3499..6e0e8f11ae 100644 --- a/osu.Game/Online/API/Requests/GetTopUsersResponse.cs +++ b/osu.Game/Online/API/Requests/GetTopUsersResponse.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using Newtonsoft.Json; using osu.Game.Users; diff --git a/osu.Game/Online/API/Requests/GetUpdatesRequest.cs b/osu.Game/Online/API/Requests/GetUpdatesRequest.cs index 488bb8bbf4..529c579996 100644 --- a/osu.Game/Online/API/Requests/GetUpdatesRequest.cs +++ b/osu.Game/Online/API/Requests/GetUpdatesRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using osu.Framework.IO.Network; using osu.Game.Online.Chat; @@ -23,6 +25,7 @@ namespace osu.Game.Online.API.Requests var req = base.CreateWebRequest(); if (channel != null) req.AddParameter(@"channel", channel.Id.ToString()); req.AddParameter(@"since", since.ToString()); + req.AddParameter(@"includes[]", "presence"); return req; } diff --git a/osu.Game/Online/API/Requests/GetUpdatesResponse.cs b/osu.Game/Online/API/Requests/GetUpdatesResponse.cs index 4ac806bedc..7e030ce922 100644 --- a/osu.Game/Online/API/Requests/GetUpdatesResponse.cs +++ b/osu.Game/Online/API/Requests/GetUpdatesResponse.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using Newtonsoft.Json; using osu.Game.Online.Chat; @@ -14,5 +16,7 @@ namespace osu.Game.Online.API.Requests [JsonProperty] public List Messages; + + // TODO: Handle Silences here (will need to add to includes[] in the request). } } diff --git a/osu.Game/Online/API/Requests/GetUserBeatmapsRequest.cs b/osu.Game/Online/API/Requests/GetUserBeatmapsRequest.cs index 205fdc9f2b..e4134980b1 100644 --- a/osu.Game/Online/API/Requests/GetUserBeatmapsRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserBeatmapsRequest.cs @@ -1,8 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using Humanizer; +#nullable disable + using System.Collections.Generic; +using osu.Game.Extensions; using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Online.API.Requests @@ -20,7 +22,7 @@ namespace osu.Game.Online.API.Requests this.type = type; } - protected override string Target => $@"users/{userId}/beatmapsets/{type.ToString().Underscore()}"; + protected override string Target => $@"users/{userId}/beatmapsets/{type.ToString().ToSnakeCase()}"; } public enum BeatmapSetType @@ -30,6 +32,7 @@ namespace osu.Game.Online.API.Requests Loved, Pending, Guest, - Graveyard + Graveyard, + Nominated, } } diff --git a/osu.Game/Online/API/Requests/GetUserKudosuHistoryRequest.cs b/osu.Game/Online/API/Requests/GetUserKudosuHistoryRequest.cs index 67d3ad26b0..3d0ee23080 100644 --- a/osu.Game/Online/API/Requests/GetUserKudosuHistoryRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserKudosuHistoryRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Game.Online.API.Requests.Responses; diff --git a/osu.Game/Online/API/Requests/GetUserMostPlayedBeatmapsRequest.cs b/osu.Game/Online/API/Requests/GetUserMostPlayedBeatmapsRequest.cs index bef3df42fb..e5e65415f7 100644 --- a/osu.Game/Online/API/Requests/GetUserMostPlayedBeatmapsRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserMostPlayedBeatmapsRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Game.Online.API.Requests.Responses; diff --git a/osu.Game/Online/API/Requests/GetUserRankingsRequest.cs b/osu.Game/Online/API/Requests/GetUserRankingsRequest.cs index bccc3bc0c3..c27a83b695 100644 --- a/osu.Game/Online/API/Requests/GetUserRankingsRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserRankingsRequest.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.IO.Network; using osu.Game.Rulesets; +using osu.Game.Users; namespace osu.Game.Online.API.Requests { @@ -10,21 +13,21 @@ namespace osu.Game.Online.API.Requests { public readonly UserRankingsType Type; - private readonly string country; + private readonly CountryCode countryCode; - public GetUserRankingsRequest(RulesetInfo ruleset, UserRankingsType type = UserRankingsType.Performance, int page = 1, string country = null) + public GetUserRankingsRequest(RulesetInfo ruleset, UserRankingsType type = UserRankingsType.Performance, int page = 1, CountryCode countryCode = CountryCode.Unknown) : base(ruleset, page) { Type = type; - this.country = country; + this.countryCode = countryCode; } protected override WebRequest CreateWebRequest() { var req = base.CreateWebRequest(); - if (country != null) - req.AddParameter("country", country); + if (countryCode != CountryCode.Unknown) + req.AddParameter("country", countryCode.ToString()); return req; } diff --git a/osu.Game/Online/API/Requests/GetUserRecentActivitiesRequest.cs b/osu.Game/Online/API/Requests/GetUserRecentActivitiesRequest.cs index 79f0549d4a..82cf0a508a 100644 --- a/osu.Game/Online/API/Requests/GetUserRecentActivitiesRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserRecentActivitiesRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Game.Online.API.Requests.Responses; diff --git a/osu.Game/Online/API/Requests/GetUserRequest.cs b/osu.Game/Online/API/Requests/GetUserRequest.cs index 28da5222f9..7dcf75950e 100644 --- a/osu.Game/Online/API/Requests/GetUserRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; @@ -43,7 +45,7 @@ namespace osu.Game.Online.API.Requests Ruleset = ruleset; } - protected override string Target => Lookup != null ? $@"users/{Lookup}/{Ruleset?.ShortName}?key={lookupType.ToString().ToLower()}" : $@"me/{Ruleset?.ShortName}"; + protected override string Target => Lookup != null ? $@"users/{Lookup}/{Ruleset?.ShortName}?key={lookupType.ToString().ToLowerInvariant()}" : $@"me/{Ruleset?.ShortName}"; private enum LookupType { diff --git a/osu.Game/Online/API/Requests/GetUserScoresRequest.cs b/osu.Game/Online/API/Requests/GetUserScoresRequest.cs index 7250929f11..8ef797f799 100644 --- a/osu.Game/Online/API/Requests/GetUserScoresRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserScoresRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.IO.Network; using osu.Game.Online.API.Requests.Responses; @@ -8,7 +10,7 @@ using osu.Game.Rulesets; namespace osu.Game.Online.API.Requests { - public class GetUserScoresRequest : PaginatedAPIRequest> + public class GetUserScoresRequest : PaginatedAPIRequest> { private readonly long userId; private readonly ScoreType type; diff --git a/osu.Game/Online/API/Requests/GetUsersRequest.cs b/osu.Game/Online/API/Requests/GetUsersRequest.cs index 969d7fdba3..b57bb215aa 100644 --- a/osu.Game/Online/API/Requests/GetUsersRequest.cs +++ b/osu.Game/Online/API/Requests/GetUsersRequest.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; namespace osu.Game.Online.API.Requests { public class GetUsersRequest : APIRequest { - private readonly int[] userIds; + public readonly int[] UserIds; private const int max_ids_per_request = 50; @@ -16,9 +18,9 @@ namespace osu.Game.Online.API.Requests if (userIds.Length > max_ids_per_request) throw new ArgumentException($"{nameof(GetUsersRequest)} calls only support up to {max_ids_per_request} IDs at once"); - this.userIds = userIds; + UserIds = userIds; } - protected override string Target => "users/?ids[]=" + string.Join("&ids[]=", userIds); + protected override string Target => "users/?ids[]=" + string.Join("&ids[]=", UserIds); } } diff --git a/osu.Game/Online/API/Requests/GetUsersResponse.cs b/osu.Game/Online/API/Requests/GetUsersResponse.cs index 022050de5c..b49bef589a 100644 --- a/osu.Game/Online/API/Requests/GetUsersResponse.cs +++ b/osu.Game/Online/API/Requests/GetUsersResponse.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using Newtonsoft.Json; using osu.Game.Online.API.Requests.Responses; diff --git a/osu.Game/Online/API/Requests/GetWikiRequest.cs b/osu.Game/Online/API/Requests/GetWikiRequest.cs index 09571ab0a8..7c84e1f790 100644 --- a/osu.Game/Online/API/Requests/GetWikiRequest.cs +++ b/osu.Game/Online/API/Requests/GetWikiRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Extensions; using osu.Game.Localisation; using osu.Game.Online.API.Requests.Responses; @@ -9,15 +11,16 @@ namespace osu.Game.Online.API.Requests { public class GetWikiRequest : APIRequest { - private readonly string path; + public readonly string Path; + private readonly Language language; public GetWikiRequest(string path, Language language = Language.en) { - this.path = path; + Path = path; this.language = language; } - protected override string Target => $"wiki/{language.ToCultureCode()}/{path}"; + protected override string Target => $"wiki/{language.ToCultureCode()}/{Path}"; } } diff --git a/osu.Game/Online/API/Requests/JoinChannelRequest.cs b/osu.Game/Online/API/Requests/JoinChannelRequest.cs index 33eab7e355..30b8fafd57 100644 --- a/osu.Game/Online/API/Requests/JoinChannelRequest.cs +++ b/osu.Game/Online/API/Requests/JoinChannelRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Net.Http; using osu.Framework.IO.Network; using osu.Game.Online.Chat; diff --git a/osu.Game/Online/API/Requests/LeaveChannelRequest.cs b/osu.Game/Online/API/Requests/LeaveChannelRequest.cs index 7dfc9a0aed..4e77055e67 100644 --- a/osu.Game/Online/API/Requests/LeaveChannelRequest.cs +++ b/osu.Game/Online/API/Requests/LeaveChannelRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Net.Http; using osu.Framework.IO.Network; using osu.Game.Online.Chat; diff --git a/osu.Game/Online/API/Requests/ListChannelsRequest.cs b/osu.Game/Online/API/Requests/ListChannelsRequest.cs index 9660695c14..6f8fb427dc 100644 --- a/osu.Game/Online/API/Requests/ListChannelsRequest.cs +++ b/osu.Game/Online/API/Requests/ListChannelsRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Game.Online.Chat; diff --git a/osu.Game/Online/API/Requests/MarkChannelAsReadRequest.cs b/osu.Game/Online/API/Requests/MarkChannelAsReadRequest.cs index b24669e6d5..afdc8a47f4 100644 --- a/osu.Game/Online/API/Requests/MarkChannelAsReadRequest.cs +++ b/osu.Game/Online/API/Requests/MarkChannelAsReadRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Net.Http; using osu.Framework.IO.Network; using osu.Game.Online.Chat; diff --git a/osu.Game/Online/API/Requests/PaginatedAPIRequest.cs b/osu.Game/Online/API/Requests/PaginatedAPIRequest.cs index 3d719de958..4af1f58180 100644 --- a/osu.Game/Online/API/Requests/PaginatedAPIRequest.cs +++ b/osu.Game/Online/API/Requests/PaginatedAPIRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Globalization; using osu.Framework.IO.Network; diff --git a/osu.Game/Online/API/Requests/PaginationParameters.cs b/osu.Game/Online/API/Requests/PaginationParameters.cs index 3593a4fe83..6dacb009bd 100644 --- a/osu.Game/Online/API/Requests/PaginationParameters.cs +++ b/osu.Game/Online/API/Requests/PaginationParameters.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Online.API.Requests { /// diff --git a/osu.Game/Online/API/Requests/PostBeatmapFavouriteRequest.cs b/osu.Game/Online/API/Requests/PostBeatmapFavouriteRequest.cs index 9fdc3382aa..1438c9c436 100644 --- a/osu.Game/Online/API/Requests/PostBeatmapFavouriteRequest.cs +++ b/osu.Game/Online/API/Requests/PostBeatmapFavouriteRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.IO.Network; using System.Net.Http; diff --git a/osu.Game/Online/API/Requests/PostMessageRequest.cs b/osu.Game/Online/API/Requests/PostMessageRequest.cs index 5d508a4cdf..e3709d8f13 100644 --- a/osu.Game/Online/API/Requests/PostMessageRequest.cs +++ b/osu.Game/Online/API/Requests/PostMessageRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Net.Http; using osu.Framework.IO.Network; using osu.Game.Online.Chat; @@ -23,6 +25,7 @@ namespace osu.Game.Online.API.Requests req.Method = HttpMethod.Post; req.AddParameter(@"is_action", Message.IsAction.ToString().ToLowerInvariant()); req.AddParameter(@"message", Message.Content); + req.AddParameter(@"uuid", Message.Uuid); return req; } diff --git a/osu.Game/Online/API/Requests/ResponseWithCursor.cs b/osu.Game/Online/API/Requests/ResponseWithCursor.cs index d52e999722..66e8094442 100644 --- a/osu.Game/Online/API/Requests/ResponseWithCursor.cs +++ b/osu.Game/Online/API/Requests/ResponseWithCursor.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Newtonsoft.Json; namespace osu.Game.Online.API.Requests diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs index f5795141c5..7d6740ee46 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs @@ -7,8 +7,6 @@ using osu.Game.Beatmaps; using osu.Game.Extensions; using osu.Game.Rulesets; -#nullable enable - namespace osu.Game.Online.API.Requests.Responses { public class APIBeatmap : IBeatmapInfo, IBeatmapOnlineInfo @@ -65,12 +63,18 @@ namespace osu.Game.Online.API.Requests.Responses set => Length = TimeSpan.FromSeconds(value).TotalMilliseconds; } + [JsonProperty(@"convert")] + public bool Convert { get; set; } + [JsonProperty(@"count_circles")] public int CircleCount { get; set; } [JsonProperty(@"count_sliders")] public int SliderCount { get; set; } + [JsonProperty(@"count_spinners")] + public int SpinnerCount { get; set; } + [JsonProperty(@"version")] public string DifficultyName { get; set; } = string.Empty; @@ -80,6 +84,9 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"max_combo")] public int? MaxCombo { get; set; } + [JsonProperty(@"last_updated")] + public DateTimeOffset LastUpdated { get; set; } + public double BPM { get; set; } #region Implementation of IBeatmapInfo @@ -107,7 +114,7 @@ namespace osu.Game.Online.API.Requests.Responses public bool Equals(IBeatmapInfo? other) => other is APIBeatmap b && this.MatchesOnlineID(b); - private class APIRuleset : IRulesetInfo + public class APIRuleset : IRulesetInfo { public int OnlineID { get; set; } = -1; @@ -139,7 +146,7 @@ namespace osu.Game.Online.API.Requests.Responses public bool Equals(IRulesetInfo? other) => other is APIRuleset r && this.MatchesOnlineID(r); - public int CompareTo(IRulesetInfo other) + public int CompareTo(IRulesetInfo? other) { if (!(other is APIRuleset ruleset)) throw new ArgumentException($@"Object is not of type {nameof(APIRuleset)}.", nameof(other)); diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs index 79c65fa79e..d98715a42d 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs @@ -9,8 +9,6 @@ using osu.Game.Database; using osu.Game.Extensions; using osu.Game.Models; -#nullable enable - namespace osu.Game.Online.API.Requests.Responses { public class APIBeatmapSet : IBeatmapSetOnlineInfo, IBeatmapSetInfo @@ -113,6 +111,12 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"language")] public BeatmapSetOnlineLanguage Language { get; set; } + [JsonProperty(@"current_nominations")] + public BeatmapSetOnlineNomination[]? CurrentNominations { get; set; } + + [JsonProperty(@"related_users")] + public APIUser[]? RelatedUsers { get; set; } + public string Source { get; set; } = string.Empty; [JsonProperty(@"tags")] @@ -121,6 +125,9 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"beatmaps")] public APIBeatmap[] Beatmaps { get; set; } = Array.Empty(); + [JsonProperty(@"converts")] + public APIBeatmap[]? Converts { get; set; } + private BeatmapMetadata metadata => new BeatmapMetadata { Title = Title, @@ -151,5 +158,8 @@ namespace osu.Game.Online.API.Requests.Responses #endregion public bool Equals(IBeatmapSetInfo? other) => other is APIBeatmapSet b && this.MatchesOnlineID(b); + + // ReSharper disable once NonReadonlyMemberInGetHashCode + public override int GetHashCode() => OnlineID.GetHashCode(); } } diff --git a/osu.Game/Online/API/Requests/Responses/APIChangelogBuild.cs b/osu.Game/Online/API/Requests/Responses/APIChangelogBuild.cs index 56005e15f8..38c727396a 100644 --- a/osu.Game/Online/API/Requests/Responses/APIChangelogBuild.cs +++ b/osu.Game/Online/API/Requests/Responses/APIChangelogBuild.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Newtonsoft.Json; using System; using System.Collections.Generic; diff --git a/osu.Game/Online/API/Requests/Responses/APIChangelogEntry.cs b/osu.Game/Online/API/Requests/Responses/APIChangelogEntry.cs index 1ff7523ba6..2355de8f3c 100644 --- a/osu.Game/Online/API/Requests/Responses/APIChangelogEntry.cs +++ b/osu.Game/Online/API/Requests/Responses/APIChangelogEntry.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using Newtonsoft.Json; diff --git a/osu.Game/Online/API/Requests/Responses/APIChangelogIndex.cs b/osu.Game/Online/API/Requests/Responses/APIChangelogIndex.cs index 778e8754fe..0728fc1c7e 100644 --- a/osu.Game/Online/API/Requests/Responses/APIChangelogIndex.cs +++ b/osu.Game/Online/API/Requests/Responses/APIChangelogIndex.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using Newtonsoft.Json; diff --git a/osu.Game/Online/API/Requests/Responses/APIChangelogUser.cs b/osu.Game/Online/API/Requests/Responses/APIChangelogUser.cs index 024e1ce048..071f9f8517 100644 --- a/osu.Game/Online/API/Requests/Responses/APIChangelogUser.cs +++ b/osu.Game/Online/API/Requests/Responses/APIChangelogUser.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Newtonsoft.Json; namespace osu.Game.Online.API.Requests.Responses diff --git a/osu.Game/Online/API/Requests/Responses/APIChatChannel.cs b/osu.Game/Online/API/Requests/Responses/APIChatChannel.cs index fc3b2a8e31..a04dc89986 100644 --- a/osu.Game/Online/API/Requests/Responses/APIChatChannel.cs +++ b/osu.Game/Online/API/Requests/Responses/APIChatChannel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using Newtonsoft.Json; using osu.Game.Online.Chat; diff --git a/osu.Game/Online/API/Requests/Responses/APIKudosuHistory.cs b/osu.Game/Online/API/Requests/Responses/APIKudosuHistory.cs index d596ddc560..583def8eda 100644 --- a/osu.Game/Online/API/Requests/Responses/APIKudosuHistory.cs +++ b/osu.Game/Online/API/Requests/Responses/APIKudosuHistory.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using Newtonsoft.Json; diff --git a/osu.Game/Online/API/Requests/Responses/APINewsPost.cs b/osu.Game/Online/API/Requests/Responses/APINewsPost.cs index ced08f0bf2..ff12a83af2 100644 --- a/osu.Game/Online/API/Requests/Responses/APINewsPost.cs +++ b/osu.Game/Online/API/Requests/Responses/APINewsPost.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Newtonsoft.Json; using System; using System.Net; diff --git a/osu.Game/Online/API/Requests/Responses/APINewsSidebar.cs b/osu.Game/Online/API/Requests/Responses/APINewsSidebar.cs index b8d6469a1d..a94dfbd923 100644 --- a/osu.Game/Online/API/Requests/Responses/APINewsSidebar.cs +++ b/osu.Game/Online/API/Requests/Responses/APINewsSidebar.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Newtonsoft.Json; using System.Collections.Generic; diff --git a/osu.Game/Online/API/Requests/Responses/APINotification.cs b/osu.Game/Online/API/Requests/Responses/APINotification.cs new file mode 100644 index 0000000000..de856c0333 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APINotification.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace osu.Game.Online.API.Requests.Responses +{ + [JsonObject(MemberSerialization.OptIn)] + public class APINotification + { + [JsonProperty(@"id")] + public long Id { get; set; } + + [JsonProperty(@"name")] + public string Name { get; set; } = null!; + + [JsonProperty(@"created_at")] + public DateTimeOffset? CreatedAt { get; set; } + + [JsonProperty(@"object_type")] + public string ObjectType { get; set; } = null!; + + [JsonProperty(@"object_id")] + public string ObjectId { get; set; } = null!; + + [JsonProperty(@"source_user_id")] + public long? SourceUserId { get; set; } + + [JsonProperty(@"is_read")] + public bool IsRead { get; set; } + + [JsonProperty(@"details")] + public JObject? Details { get; set; } + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APINotificationsBundle.cs b/osu.Game/Online/API/Requests/Responses/APINotificationsBundle.cs new file mode 100644 index 0000000000..ae299e2614 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APINotificationsBundle.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 Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + [JsonObject(MemberSerialization.OptIn)] + public class APINotificationsBundle + { + [JsonProperty(@"has_more")] + public bool HasMore { get; set; } + + [JsonProperty(@"notifications")] + public APINotification[] Notifications { get; set; } = null!; + + [JsonProperty(@"notification_endpoint")] + public string Endpoint { get; set; } = null!; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APIPlayStyle.cs b/osu.Game/Online/API/Requests/Responses/APIPlayStyle.cs index a9d66f3d6a..4a877f392a 100644 --- a/osu.Game/Online/API/Requests/Responses/APIPlayStyle.cs +++ b/osu.Game/Online/API/Requests/Responses/APIPlayStyle.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; diff --git a/osu.Game/Online/API/Requests/Responses/APIRankHistory.cs b/osu.Game/Online/API/Requests/Responses/APIRankHistory.cs index 064badcccb..b3af2e1a3e 100644 --- a/osu.Game/Online/API/Requests/Responses/APIRankHistory.cs +++ b/osu.Game/Online/API/Requests/Responses/APIRankHistory.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Newtonsoft.Json; namespace osu.Game.Online.API.Requests.Responses diff --git a/osu.Game/Online/API/Requests/Responses/APIRecentActivity.cs b/osu.Game/Online/API/Requests/Responses/APIRecentActivity.cs index 8695d09570..c6a8a85407 100644 --- a/osu.Game/Online/API/Requests/Responses/APIRecentActivity.cs +++ b/osu.Game/Online/API/Requests/Responses/APIRecentActivity.cs @@ -1,9 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; -using Humanizer; using Newtonsoft.Json; +using osu.Game.Extensions; using osu.Game.Scoring; namespace osu.Game.Online.API.Requests.Responses @@ -19,7 +21,7 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty] private string type { - set => Type = (RecentActivityType)Enum.Parse(typeof(RecentActivityType), value.Pascalize()); + set => Type = Enum.Parse(value.ToPascalCase()); } public RecentActivityType Type; @@ -27,7 +29,7 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty] private string scoreRank { - set => ScoreRank = (ScoreRank)Enum.Parse(typeof(ScoreRank), value); + set => ScoreRank = Enum.Parse(value); } public ScoreRank ScoreRank; diff --git a/osu.Game/Online/API/Requests/Responses/APIScore.cs b/osu.Game/Online/API/Requests/Responses/APIScore.cs deleted file mode 100644 index d8f4ba835d..0000000000 --- a/osu.Game/Online/API/Requests/Responses/APIScore.cs +++ /dev/null @@ -1,160 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -using osu.Game.Beatmaps; -using osu.Game.Database; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osu.Game.Scoring; -using osu.Game.Scoring.Legacy; -using osu.Game.Users; - -namespace osu.Game.Online.API.Requests.Responses -{ - public class APIScore : IScoreInfo - { - [JsonProperty(@"score")] - public long TotalScore { get; set; } - - [JsonProperty(@"max_combo")] - public int MaxCombo { get; set; } - - [JsonProperty(@"user")] - public APIUser User { get; set; } - - [JsonProperty(@"id")] - public long OnlineID { get; set; } - - [JsonProperty(@"replay")] - public bool HasReplay { get; set; } - - [JsonProperty(@"created_at")] - public DateTimeOffset Date { get; set; } - - [JsonProperty(@"beatmap")] - [CanBeNull] - public APIBeatmap Beatmap { get; set; } - - [JsonProperty("accuracy")] - public double Accuracy { get; set; } - - [JsonProperty(@"pp")] - public double? PP { get; set; } - - [JsonProperty(@"beatmapset")] - [CanBeNull] - public APIBeatmapSet BeatmapSet - { - set - { - // in the deserialisation case we need to ferry this data across. - // the order of properties returned by the API guarantees that the beatmap is populated by this point. - if (!(Beatmap is APIBeatmap apiBeatmap)) - throw new InvalidOperationException("Beatmap set metadata arrived before beatmap metadata in response"); - - apiBeatmap.BeatmapSet = value; - } - } - - [JsonProperty("statistics")] - public Dictionary Statistics { get; set; } - - [JsonProperty(@"mode_int")] - public int RulesetID { get; set; } - - [JsonProperty(@"mods")] - private string[] mods { set => Mods = value.Select(acronym => new APIMod { Acronym = acronym }); } - - [NotNull] - public IEnumerable Mods { get; set; } = Array.Empty(); - - [JsonProperty("rank")] - [JsonConverter(typeof(StringEnumConverter))] - public ScoreRank Rank { get; set; } - - /// - /// Create a from an API score instance. - /// - /// A ruleset store, used to populate a ruleset instance in the returned score. - /// An optional beatmap, copied into the returned score (for cases where the API does not populate the beatmap). - /// - public ScoreInfo CreateScoreInfo(RulesetStore rulesets, BeatmapInfo beatmap = null) - { - var ruleset = rulesets.GetRuleset(RulesetID) ?? throw new InvalidOperationException(); - - var rulesetInstance = ruleset.CreateInstance(); - - var modInstances = Mods.Select(apiMod => rulesetInstance.CreateModFromAcronym(apiMod.Acronym)).Where(m => m != null).ToArray(); - - // all API scores provided by this class are considered to be legacy. - modInstances = modInstances.Append(rulesetInstance.CreateMod()).ToArray(); - - var scoreInfo = new ScoreInfo - { - TotalScore = TotalScore, - MaxCombo = MaxCombo, - BeatmapInfo = beatmap ?? new BeatmapInfo(), - User = User, - Accuracy = Accuracy, - OnlineID = OnlineID, - Date = Date, - PP = PP, - Hash = HasReplay ? "online" : string.Empty, // todo: temporary? - Rank = Rank, - Ruleset = ruleset, - Mods = modInstances, - }; - - if (Statistics != null) - { - foreach (var kvp in Statistics) - { - switch (kvp.Key) - { - case @"count_geki": - scoreInfo.SetCountGeki(kvp.Value); - break; - - case @"count_300": - scoreInfo.SetCount300(kvp.Value); - break; - - case @"count_katu": - scoreInfo.SetCountKatu(kvp.Value); - break; - - case @"count_100": - scoreInfo.SetCount100(kvp.Value); - break; - - case @"count_50": - scoreInfo.SetCount50(kvp.Value); - break; - - case @"count_miss": - scoreInfo.SetCountMiss(kvp.Value); - break; - } - } - } - - return scoreInfo; - } - - public IRulesetInfo Ruleset => new RulesetInfo { OnlineID = RulesetID }; - IEnumerable IHasNamedFiles.Files => throw new NotImplementedException(); - - #region Implementation of IScoreInfo - - IBeatmapInfo IScoreInfo.Beatmap => Beatmap; - IUser IScoreInfo.User => User; - - #endregion - } -} diff --git a/osu.Game/Online/API/Requests/Responses/APIScoreWithPosition.cs b/osu.Game/Online/API/Requests/Responses/APIScoreWithPosition.cs index d3c9ba0c7e..494826f534 100644 --- a/osu.Game/Online/API/Requests/Responses/APIScoreWithPosition.cs +++ b/osu.Game/Online/API/Requests/Responses/APIScoreWithPosition.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Newtonsoft.Json; using osu.Game.Beatmaps; using osu.Game.Rulesets; @@ -14,11 +16,11 @@ namespace osu.Game.Online.API.Requests.Responses public int? Position; [JsonProperty(@"score")] - public APIScore Score; + public SoloScoreInfo Score; public ScoreInfo CreateScoreInfo(RulesetStore rulesets, BeatmapInfo beatmap = null) { - var score = Score.CreateScoreInfo(rulesets, beatmap); + var score = Score.ToScoreInfo(rulesets, beatmap); score.Position = Position; return score; } diff --git a/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs b/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs index 283ebf2411..4ef39be5e5 100644 --- a/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs +++ b/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using Newtonsoft.Json; @@ -9,9 +11,9 @@ namespace osu.Game.Online.API.Requests.Responses public class APIScoresCollection { [JsonProperty(@"scores")] - public List Scores; + public List Scores; - [JsonProperty(@"userScore")] + [JsonProperty(@"user_score")] public APIScoreWithPosition UserScore; } } diff --git a/osu.Game/Online/API/Requests/Responses/APISeasonalBackgrounds.cs b/osu.Game/Online/API/Requests/Responses/APISeasonalBackgrounds.cs index 8e395f7397..c70dcc0e0b 100644 --- a/osu.Game/Online/API/Requests/Responses/APISeasonalBackgrounds.cs +++ b/osu.Game/Online/API/Requests/Responses/APISeasonalBackgrounds.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using Newtonsoft.Json; diff --git a/osu.Game/Online/API/Requests/Responses/APISpotlight.cs b/osu.Game/Online/API/Requests/Responses/APISpotlight.cs index 4f63ebe3df..6eb51a0fb3 100644 --- a/osu.Game/Online/API/Requests/Responses/APISpotlight.cs +++ b/osu.Game/Online/API/Requests/Responses/APISpotlight.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using Newtonsoft.Json; diff --git a/osu.Game/Online/API/Requests/Responses/APIUpdateStream.cs b/osu.Game/Online/API/Requests/Responses/APIUpdateStream.cs index 5af7d6a01c..76d1941d9d 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUpdateStream.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUpdateStream.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using Newtonsoft.Json; diff --git a/osu.Game/Online/API/Requests/Responses/APIUser.cs b/osu.Game/Online/API/Requests/Responses/APIUser.cs index 41f486c709..e63395fe26 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUser.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUser.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -32,8 +34,19 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"previous_usernames")] public string[] PreviousUsernames; + private CountryCode? countryCode; + + public CountryCode CountryCode + { + get => countryCode ??= (Enum.TryParse(country?.Code, out CountryCode result) ? result : default); + set => countryCode = value; + } + +#pragma warning disable 649 + [CanBeNull] [JsonProperty(@"country")] - public Country Country; + private Country country; +#pragma warning restore 649 public readonly Bindable Status = new Bindable(); @@ -151,6 +164,9 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"guest_beatmapset_count")] public int GuestBeatmapsetCount; + [JsonProperty(@"nominated_beatmapset_count")] + public int NominatedBeatmapsetCount; + [JsonProperty(@"scores_best_count")] public int ScoresBestCount; @@ -169,7 +185,7 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"playstyle")] private string[] playStyle { - set => PlayStyles = value?.Select(str => Enum.Parse(typeof(APIPlayStyle), str, true)).Cast().ToArray(); + set => PlayStyles = value?.Select(str => Enum.Parse(str, true)).ToArray(); } public APIPlayStyle[] PlayStyles; @@ -215,9 +231,13 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"rank_history")] private APIRankHistory rankHistory { - set => statistics.RankHistory = value; + set => Statistics.RankHistory = value; } + [JsonProperty(@"active_tournament_banner")] + [CanBeNull] + public TournamentBanner TournamentBanner; + [JsonProperty("badges")] public Badge[] Badges; @@ -239,6 +259,9 @@ namespace osu.Game.Online.API.Requests.Responses [CanBeNull] public Dictionary RulesetsStatistics { get; set; } + [JsonProperty("groups")] + public APIUserGroup[] Groups; + public override string ToString() => Username; /// @@ -254,5 +277,13 @@ namespace osu.Game.Online.API.Requests.Responses public int OnlineID => Id; public bool Equals(APIUser other) => this.MatchesOnlineID(other); + +#pragma warning disable 649 + private class Country + { + [JsonProperty(@"code")] + public string Code; + } +#pragma warning restore 649 } } diff --git a/osu.Game/Online/API/Requests/Responses/APIUserAchievement.cs b/osu.Game/Online/API/Requests/Responses/APIUserAchievement.cs index cfba9118c2..65001dd6cf 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUserAchievement.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUserAchievement.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using Newtonsoft.Json; diff --git a/osu.Game/Online/API/Requests/Responses/APIUserContainer.cs b/osu.Game/Online/API/Requests/Responses/APIUserContainer.cs index 9eb4a2a4e3..b2d544c71f 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUserContainer.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUserContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Newtonsoft.Json; namespace osu.Game.Online.API.Requests.Responses diff --git a/osu.Game/Online/API/Requests/Responses/APIUserGroup.cs b/osu.Game/Online/API/Requests/Responses/APIUserGroup.cs new file mode 100644 index 0000000000..89631d3d7a --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APIUserGroup.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 Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APIUserGroup + { + [JsonProperty(@"colour")] + public string? Colour { get; set; } + + [JsonProperty(@"has_listing")] + public bool HasListings { get; set; } + + [JsonProperty(@"has_playmodes")] + public bool HasPlaymodes { get; set; } + + [JsonProperty(@"id")] + public int Id { get; set; } + + [JsonProperty(@"identifier")] + public string Identifier { get; set; } = null!; + + [JsonProperty(@"is_probationary")] + public bool IsProbationary { get; set; } + + [JsonProperty(@"name")] + public string Name { get; set; } = null!; + + [JsonProperty(@"short_name")] + public string ShortName { get; set; } = null!; + + [JsonProperty(@"playmodes")] + public string[]? Playmodes { get; set; } + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APIUserHistoryCount.cs b/osu.Game/Online/API/Requests/Responses/APIUserHistoryCount.cs index 1226c88c08..6eb3c8b8a4 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUserHistoryCount.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUserHistoryCount.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using Newtonsoft.Json; diff --git a/osu.Game/Online/API/Requests/Responses/APIUserMostPlayedBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIUserMostPlayedBeatmap.cs index 19c581bf95..6d5fd59f9c 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUserMostPlayedBeatmap.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUserMostPlayedBeatmap.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Newtonsoft.Json; namespace osu.Game.Online.API.Requests.Responses diff --git a/osu.Game/Online/API/Requests/Responses/APIUserScoreAggregate.cs b/osu.Game/Online/API/Requests/Responses/APIUserScoreAggregate.cs index a298a8625a..c870157fec 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUserScoreAggregate.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUserScoreAggregate.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using Newtonsoft.Json; using osu.Game.Rulesets.Mods; @@ -8,6 +10,9 @@ using osu.Game.Scoring; namespace osu.Game.Online.API.Requests.Responses { + /// + /// Represents an aggregate score for a user based off all beatmaps that have been played in the playlist. + /// public class APIUserScoreAggregate { [JsonProperty("attempts")] diff --git a/osu.Game/Online/API/Requests/Responses/APIWikiPage.cs b/osu.Game/Online/API/Requests/Responses/APIWikiPage.cs index 957396b17a..ea2457bf1b 100644 --- a/osu.Game/Online/API/Requests/Responses/APIWikiPage.cs +++ b/osu.Game/Online/API/Requests/Responses/APIWikiPage.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using Newtonsoft.Json; diff --git a/osu.Game/Online/API/Requests/Responses/ChatAckResponse.cs b/osu.Game/Online/API/Requests/Responses/ChatAckResponse.cs new file mode 100644 index 0000000000..6ed22a19b2 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/ChatAckResponse.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + [JsonObject(MemberSerialization.OptIn)] + public class ChatAckResponse + { + [JsonProperty("silences")] + public List Silences { get; set; } = null!; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/ChatSilence.cs b/osu.Game/Online/API/Requests/Responses/ChatSilence.cs new file mode 100644 index 0000000000..afb44e385e --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/ChatSilence.cs @@ -0,0 +1,17 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + [JsonObject(MemberSerialization.OptIn)] + public class ChatSilence + { + [JsonProperty("id")] + public uint Id { get; set; } + + [JsonProperty("user_id")] + public int UserId { get; set; } + } +} diff --git a/osu.Game/Online/API/Requests/Responses/Comment.cs b/osu.Game/Online/API/Requests/Responses/Comment.cs index 13379a40a6..907632186c 100644 --- a/osu.Game/Online/API/Requests/Responses/Comment.cs +++ b/osu.Game/Online/API/Requests/Responses/Comment.cs @@ -14,18 +14,18 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"parent_id")] public long? ParentId { get; set; } - public Comment ParentComment { get; set; } + public Comment? ParentComment { get; set; } [JsonProperty(@"user_id")] public long? UserId { get; set; } - public APIUser User { get; set; } + public APIUser? User { get; set; } [JsonProperty(@"message")] - public string Message { get; set; } + public string Message { get; set; } = null!; [JsonProperty(@"message_html")] - public string MessageHtml { get; set; } + public string? MessageHtml { get; set; } [JsonProperty(@"replies_count")] public int RepliesCount { get; set; } @@ -34,13 +34,13 @@ namespace osu.Game.Online.API.Requests.Responses public int VotesCount { get; set; } [JsonProperty(@"commenatble_type")] - public string CommentableType { get; set; } + public string CommentableType { get; set; } = null!; [JsonProperty(@"commentable_id")] public int CommentableId { get; set; } [JsonProperty(@"legacy_name")] - public string LegacyName { get; set; } + public string? LegacyName { get; set; } [JsonProperty(@"created_at")] public DateTimeOffset CreatedAt { get; set; } @@ -60,7 +60,7 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"pinned")] public bool Pinned { get; set; } - public APIUser EditedUser { get; set; } + public APIUser? EditedUser { get; set; } public bool IsTopLevel => !ParentId.HasValue; diff --git a/osu.Game/Online/API/Requests/Responses/CommentBundle.cs b/osu.Game/Online/API/Requests/Responses/CommentBundle.cs index 8436381090..ae8b850723 100644 --- a/osu.Game/Online/API/Requests/Responses/CommentBundle.cs +++ b/osu.Game/Online/API/Requests/Responses/CommentBundle.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Newtonsoft.Json; using System.Collections.Generic; using System.Linq; diff --git a/osu.Game/Online/API/Requests/Responses/GetChannelResponse.cs b/osu.Game/Online/API/Requests/Responses/GetChannelResponse.cs new file mode 100644 index 0000000000..24b886e74d --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/GetChannelResponse.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using Newtonsoft.Json; +using osu.Game.Online.Chat; + +namespace osu.Game.Online.API.Requests.Responses +{ + [JsonObject(MemberSerialization.OptIn)] + public class GetChannelResponse + { + [JsonProperty(@"channel")] + public Channel Channel { get; set; } = null!; + + [JsonProperty(@"users")] + public List Users { get; set; } = null!; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs new file mode 100644 index 0000000000..15f4bace96 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs @@ -0,0 +1,229 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; + +namespace osu.Game.Online.API.Requests.Responses +{ + [Serializable] + public class SoloScoreInfo : IHasOnlineID + { + [JsonProperty("beatmap_id")] + public int BeatmapID { get; set; } + + [JsonProperty("ruleset_id")] + public int RulesetID { get; set; } + + [JsonProperty("build_id")] + public int? BuildID { get; set; } + + [JsonProperty("passed")] + public bool Passed { get; set; } + + [JsonProperty("total_score")] + public long TotalScore { get; set; } + + [JsonProperty("accuracy")] + public double Accuracy { get; set; } + + [JsonProperty("user_id")] + public int UserID { get; set; } + + // TODO: probably want to update this column to match user stats (short)? + [JsonProperty("max_combo")] + public int MaxCombo { get; set; } + + [JsonConverter(typeof(StringEnumConverter))] + // ScoreRank is aligned to make 0 equal D. We still want to serialise this (even when DefaultValueHandling.Ignore is used). + [JsonProperty("rank", DefaultValueHandling = DefaultValueHandling.Include)] + public ScoreRank Rank { get; set; } + + [JsonProperty("started_at")] + public DateTimeOffset? StartedAt { get; set; } + + [JsonProperty("ended_at")] + public DateTimeOffset EndedAt { get; set; } + + [JsonProperty("mods")] + public APIMod[] Mods { get; set; } = Array.Empty(); + + [JsonIgnore] + [JsonProperty("created_at")] + public DateTimeOffset CreatedAt { get; set; } + + [JsonIgnore] + [JsonProperty("updated_at")] + public DateTimeOffset UpdatedAt { get; set; } + + [JsonIgnore] + [JsonProperty("deleted_at")] + public DateTimeOffset? DeletedAt { get; set; } + + [JsonProperty("statistics")] + public Dictionary Statistics { get; set; } = new Dictionary(); + + [JsonProperty("maximum_statistics")] + public Dictionary MaximumStatistics { get; set; } = new Dictionary(); + + /// + /// Used to preserve the total score for legacy scores. + /// + [JsonProperty("legacy_total_score")] + public int? LegacyTotalScore { get; set; } + + [JsonProperty("legacy_score_id")] + public ulong? LegacyScoreId { get; set; } + + #region osu-web API additions (not stored to database). + + [JsonProperty("id")] + public ulong? ID { get; set; } + + [JsonProperty("user")] + public APIUser? User { get; set; } + + [JsonProperty("beatmap")] + public APIBeatmap? Beatmap { get; set; } + + [JsonProperty("beatmapset")] + public APIBeatmapSet? BeatmapSet + { + set + { + // in the deserialisation case we need to ferry this data across. + // the order of properties returned by the API guarantees that the beatmap is populated by this point. + if (!(Beatmap is APIBeatmap apiBeatmap)) + throw new InvalidOperationException("Beatmap set metadata arrived before beatmap metadata in response"); + + apiBeatmap.BeatmapSet = value; + } + } + + [JsonProperty("pp")] + public double? PP { get; set; } + + [JsonProperty("has_replay")] + public bool HasReplay { get; set; } + + // These properties are calculated or not relevant to any external usage. + public bool ShouldSerializeID() => false; + public bool ShouldSerializeUser() => false; + public bool ShouldSerializeBeatmap() => false; + public bool ShouldSerializeBeatmapSet() => false; + public bool ShouldSerializePP() => false; + public bool ShouldSerializeOnlineID() => false; + public bool ShouldSerializeHasReplay() => false; + + // These fields only need to be serialised if they hold values. + // Generally this is required because this model may be used by server-side components, but + // we don't want to bother sending these fields in score submission requests, for instance. + public bool ShouldSerializeEndedAt() => EndedAt != default; + public bool ShouldSerializeStartedAt() => StartedAt != default; + public bool ShouldSerializeLegacyScoreId() => LegacyScoreId != null; + public bool ShouldSerializeLegacyTotalScore() => LegacyTotalScore != null; + public bool ShouldSerializeMods() => Mods.Length > 0; + public bool ShouldSerializeUserID() => UserID > 0; + public bool ShouldSerializeBeatmapID() => BeatmapID > 0; + public bool ShouldSerializeBuildID() => BuildID != null; + + #endregion + + public override string ToString() => $"score_id: {ID} user_id: {UserID}"; + + /// + /// Create a from an API score instance. + /// + /// A ruleset store, used to populate a ruleset instance in the returned score. + /// An optional beatmap, copied into the returned score (for cases where the API does not populate the beatmap). + /// + public ScoreInfo ToScoreInfo(RulesetStore rulesets, BeatmapInfo? beatmap = null) + { + var ruleset = rulesets.GetRuleset(RulesetID) ?? throw new InvalidOperationException($"Ruleset with ID of {RulesetID} not found locally"); + + var rulesetInstance = ruleset.CreateInstance(); + + var mods = Mods.Select(apiMod => apiMod.ToMod(rulesetInstance)).ToArray(); + + var scoreInfo = ToScoreInfo(mods, beatmap); + scoreInfo.Ruleset = ruleset; + + return scoreInfo; + } + + /// + /// Create a from an API score instance. + /// + /// The mod instances, resolved from a ruleset. + /// The object to populate the scores' beatmap with. + /// + /// If this is a type, then the score will be fully populated with the given object. + /// Otherwise, if this is an type (e.g. ), then only the beatmap ruleset will be populated. + /// Otherwise, if this is null, then the beatmap ruleset will not be populated. + /// The online beatmap ID is populated in all cases. + /// + /// + /// The populated . + public ScoreInfo ToScoreInfo(Mod[] mods, IBeatmapInfo? beatmap = null) + { + var score = new ScoreInfo + { + OnlineID = OnlineID, + User = User ?? new APIUser { Id = UserID }, + BeatmapInfo = new BeatmapInfo { OnlineID = BeatmapID }, + Ruleset = new RulesetInfo { OnlineID = RulesetID }, + Passed = Passed, + TotalScore = TotalScore, + Accuracy = Accuracy, + MaxCombo = MaxCombo, + Rank = Rank, + Statistics = Statistics, + MaximumStatistics = MaximumStatistics, + Date = EndedAt, + Hash = HasReplay ? "online" : string.Empty, // TODO: temporary? + Mods = mods, + PP = PP, + }; + + if (beatmap is BeatmapInfo realmBeatmap) + score.BeatmapInfo = realmBeatmap; + else if (beatmap != null) + { + score.BeatmapInfo.Ruleset.OnlineID = beatmap.Ruleset.OnlineID; + score.BeatmapInfo.Ruleset.Name = beatmap.Ruleset.Name; + score.BeatmapInfo.Ruleset.ShortName = beatmap.Ruleset.ShortName; + } + + return score; + } + + /// + /// Creates a from a local score for score submission. + /// + /// The local score. + public static SoloScoreInfo ForSubmission(ScoreInfo score) => new SoloScoreInfo + { + Rank = score.Rank, + TotalScore = score.TotalScore, + Accuracy = score.Accuracy, + PP = score.PP, + MaxCombo = score.MaxCombo, + RulesetID = score.RulesetID, + Passed = score.Passed, + Mods = score.APIMods, + Statistics = score.Statistics.Where(kvp => kvp.Value != 0).ToDictionary(kvp => kvp.Key, kvp => kvp.Value), + MaximumStatistics = score.MaximumStatistics.Where(kvp => kvp.Value != 0).ToDictionary(kvp => kvp.Key, kvp => kvp.Value), + }; + + public long OnlineID => (long?)ID ?? -1; + } +} diff --git a/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs b/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs index 736024b08b..c303c410ec 100644 --- a/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs +++ b/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs @@ -1,9 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; -using Humanizer; using JetBrains.Annotations; using osu.Framework.IO.Network; using osu.Game.Extensions; @@ -84,7 +85,7 @@ namespace osu.Game.Online.API.Requests req.AddParameter("q", query); if (General != null && General.Any()) - req.AddParameter("c", string.Join('.', General.Select(e => e.ToString().Underscore()))); + req.AddParameter("c", string.Join('.', General.Select(e => e.ToString().ToSnakeCase()))); if (ruleset.OnlineID >= 0) req.AddParameter("m", ruleset.OnlineID.ToString()); @@ -110,7 +111,8 @@ namespace osu.Game.Online.API.Requests req.AddParameter("nsfw", ExplicitContent == SearchExplicit.Show ? "true" : "false"); - req.AddCursor(cursor); + if (cursor != null) + req.AddCursor(cursor); return req; } diff --git a/osu.Game/Online/API/Requests/SearchBeatmapSetsResponse.cs b/osu.Game/Online/API/Requests/SearchBeatmapSetsResponse.cs index 3c4fb11ed1..abc30e18ea 100644 --- a/osu.Game/Online/API/Requests/SearchBeatmapSetsResponse.cs +++ b/osu.Game/Online/API/Requests/SearchBeatmapSetsResponse.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using Newtonsoft.Json; using osu.Game.Online.API.Requests.Responses; diff --git a/osu.Game/Online/BeatmapDownloadTracker.cs b/osu.Game/Online/BeatmapDownloadTracker.cs index 9f795f007a..144c4445a3 100644 --- a/osu.Game/Online/BeatmapDownloadTracker.cs +++ b/osu.Game/Online/BeatmapDownloadTracker.cs @@ -8,11 +8,9 @@ using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Online.API; -#nullable enable - namespace osu.Game.Online { - public class BeatmapDownloadTracker : DownloadTracker + public partial class BeatmapDownloadTracker : DownloadTracker { [Resolved(CanBeNull = true)] protected BeatmapModelDownloader? Downloader { get; private set; } @@ -42,7 +40,7 @@ namespace osu.Game.Online // Used to interact with manager classes that don't support interface types. Will eventually be replaced. var beatmapSetInfo = new BeatmapSetInfo { OnlineID = TrackedItem.OnlineID }; - realmSubscription = realm.RegisterForNotifications(r => r.All().Where(s => s.OnlineID == TrackedItem.OnlineID && !s.DeletePending), (items, changes, ___) => + realmSubscription = realm.RegisterForNotifications(r => r.All().Where(s => s.OnlineID == TrackedItem.OnlineID && !s.DeletePending), (items, _, _) => { if (items.Any()) Schedule(() => UpdateState(DownloadState.LocallyAvailable)); diff --git a/osu.Game/Online/Chat/Channel.cs b/osu.Game/Online/Chat/Channel.cs index a37d3084f0..761e8aba8d 100644 --- a/osu.Game/Online/Chat/Channel.cs +++ b/osu.Game/Online/Chat/Channel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -96,6 +98,11 @@ namespace osu.Game.Online.Chat /// public Bindable HighlightedMessage = new Bindable(); + /// + /// The current text box message while in this . + /// + public Bindable TextBoxMessage = new Bindable(string.Empty); + [JsonConstructor] public Channel() { @@ -132,6 +139,14 @@ namespace osu.Game.Online.Chat /// public void AddNewMessages(params Message[] messages) { + foreach (var m in messages) + { + LocalEchoMessage localEcho = pendingMessages.FirstOrDefault(local => local.Uuid == m.Uuid); + + if (localEcho != null) + ReplaceMessage(localEcho, m); + } + messages = messages.Except(Messages).ToArray(); if (messages.Length == 0) return; @@ -147,6 +162,20 @@ namespace osu.Game.Online.Chat NewMessagesArrived?.Invoke(messages); } + public void RemoveMessagesFromUser(int userId) + { + for (int i = 0; i < Messages.Count; i++) + { + var message = Messages[i]; + + if (message.SenderId == userId) + { + Messages.RemoveAt(i--); + MessageRemoved?.Invoke(message); + } + } + } + /// /// Replace or remove a message from the channel. /// @@ -169,6 +198,10 @@ namespace osu.Game.Online.Chat throw new InvalidOperationException("Attempted to add the same message again"); Messages.Add(final); + + if (final.Id > LastMessageId) + LastMessageId = final.Id; + PendingMessageResolved?.Invoke(echo, final); } diff --git a/osu.Game/Online/Chat/ChannelManager.cs b/osu.Game/Online/Chat/ChannelManager.cs index 31f67bcecc..e95bc128c8 100644 --- a/osu.Game/Online/Chat/ChannelManager.cs +++ b/osu.Game/Online/Chat/ChannelManager.cs @@ -1,19 +1,22 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; -using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Graphics.Containers; using osu.Framework.Logging; +using osu.Framework.Threading; using osu.Game.Database; -using osu.Game.Input; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Notifications; using osu.Game.Overlays.Chat.Listing; namespace osu.Game.Online.Chat @@ -21,7 +24,7 @@ namespace osu.Game.Online.Chat /// /// Manages everything channel related /// - public class ChannelManager : PollingComponent, IChannelPostTarget + public partial class ChannelManager : CompositeComponent, IChannelPostTarget { /// /// The channels the player joins on startup @@ -61,45 +64,47 @@ namespace osu.Game.Online.Chat /// public IBindableList AvailableChannels => availableChannels; - [Resolved] - private IAPIProvider api { get; set; } + /// + /// Whether the client responsible for channel notifications is connected. + /// + public bool NotificationsConnected => connector.IsConnected.Value; + + private readonly IAPIProvider api; + private readonly NotificationsClientConnector connector; [Resolved] private UserLookupCache users { get; set; } - public readonly BindableBool HighPollRate = new BindableBool(); + private readonly IBindable apiState = new Bindable(); + private ScheduledDelegate scheduledAck; - private readonly IBindable isIdle = new BindableBool(); + private long? lastSilenceMessageId; + private uint? lastSilenceId; - public ChannelManager() + public ChannelManager(IAPIProvider api) { + this.api = api; + + connector = api.GetNotificationsConnector(); + CurrentChannel.ValueChanged += currentChannelChanged; } - [BackgroundDependencyLoader(permitNulls: true)] - private void load(IdleTracker idleTracker) + [BackgroundDependencyLoader] + private void load() { - HighPollRate.BindValueChanged(updatePollRate); - isIdle.BindValueChanged(updatePollRate, true); + connector.ChannelJoined += ch => Schedule(() => joinChannel(ch)); - if (idleTracker != null) - isIdle.BindTo(idleTracker.IsIdle); - } + connector.ChannelParted += ch => Schedule(() => leaveChannel(getChannel(ch), false)); - private void updatePollRate(ValueChangedEvent valueChangedEvent) - { - // Polling will eventually be replaced with websocket, but let's avoid doing these background operations as much as possible for now. - // The only loss will be delayed PM/message highlight notifications. - int millisecondsBetweenPolls = HighPollRate.Value ? 1000 : 60000; + connector.NewMessages += msgs => Schedule(() => addMessages(msgs)); - if (isIdle.Value) - millisecondsBetweenPolls *= 10; + connector.PresenceReceived += () => Schedule(initializeChannels); - if (TimeBetweenPolls.Value != millisecondsBetweenPolls) - { - TimeBetweenPolls.Value = millisecondsBetweenPolls; - Logger.Log($"Chat is now polling every {TimeBetweenPolls.Value} ms"); - } + connector.Start(); + + apiState.BindTo(api.State); + apiState.BindValueChanged(_ => SendAck(), true); } /// @@ -109,8 +114,7 @@ namespace osu.Game.Online.Chat /// public void OpenChannel(string name) { - if (name == null) - throw new ArgumentNullException(nameof(name)); + ArgumentNullException.ThrowIfNull(name); CurrentChannel.Value = AvailableChannels.FirstOrDefault(c => c.Name == name) ?? throw new ChannelNotFoundException(name); } @@ -121,8 +125,7 @@ namespace osu.Game.Online.Chat /// The user the private channel is opened with. public void OpenPrivateChannel(APIUser user) { - if (user == null) - throw new ArgumentNullException(nameof(user)); + ArgumentNullException.ThrowIfNull(user); if (user.Id == api.LocalUser.Value.Id) return; @@ -131,12 +134,14 @@ namespace osu.Game.Online.Chat ?? JoinChannel(new Channel(user)); } - private void currentChannelChanged(ValueChangedEvent e) + private void currentChannelChanged(ValueChangedEvent channel) { - bool isSelectorChannel = e.NewValue is ChannelListing.ChannelListingChannel; + bool isSelectorChannel = channel.NewValue is ChannelListing.ChannelListingChannel; if (!isSelectorChannel) - JoinChannel(e.NewValue); + JoinChannel(channel.NewValue); + + Logger.Log($"Current channel changed to {channel.NewValue}"); } /// @@ -177,7 +182,8 @@ namespace osu.Game.Online.Chat Timestamp = DateTimeOffset.Now, ChannelId = target.Id, IsAction = isAction, - Content = text + Content = text, + Uuid = Guid.NewGuid().ToString() }; target.AddLocalEcho(message); @@ -187,13 +193,7 @@ namespace osu.Game.Online.Chat { var createNewPrivateMessageRequest = new CreateNewPrivateMessageRequest(target.Users.First(), message); - createNewPrivateMessageRequest.Success += createRes => - { - target.Id = createRes.ChannelID; - target.ReplaceMessage(message, createRes.Message); - dequeueAndRun(); - }; - + createNewPrivateMessageRequest.Success += _ => dequeueAndRun(); createNewPrivateMessageRequest.Failure += exception => { handlePostException(exception); @@ -207,12 +207,7 @@ namespace osu.Game.Online.Chat var req = new PostMessageRequest(message); - req.Success += m => - { - target.ReplaceMessage(message, m); - dequeueAndRun(); - }; - + req.Success += m => dequeueAndRun(); req.Failure += exception => { handlePostException(exception); @@ -324,16 +319,23 @@ namespace osu.Game.Online.Chat } } - private void handleChannelMessages(IEnumerable messages) + private void addMessages(List messages) { var channels = JoinedChannels.ToList(); foreach (var group in messages.GroupBy(m => m.ChannelId)) channels.Find(c => c.Id == group.Key)?.AddNewMessages(group.ToArray()); + + lastSilenceMessageId ??= messages.LastOrDefault()?.Id; } private void initializeChannels() { + // This request is self-retrying until it succeeds. + // To avoid requests piling up when not logged in (ie. API is unavailable) exit early. + if (!api.IsLoggedIn) + return; + var req = new ListChannelsRequest(); bool joinDefaults = JoinedChannels.Count == 0; @@ -349,10 +351,11 @@ namespace osu.Game.Online.Chat joinChannel(ch); } }; + req.Failure += error => { Logger.Error(error, "Fetching channel list failed"); - initializeChannels(); + Scheduler.AddDelayed(initializeChannels, 60000); }; api.Queue(req); @@ -372,13 +375,51 @@ namespace osu.Game.Online.Chat var fetchInitialMsgReq = new GetMessagesRequest(channel); fetchInitialMsgReq.Success += messages => { - handleChannelMessages(messages); + addMessages(messages); channel.MessagesLoaded = true; // this will mark the channel as having received messages even if there were none. }; api.Queue(fetchInitialMsgReq); } + /// + /// Sends an acknowledgement request to the API. + /// This marks the user as online to receive messages from public channels, while also returning a list of silenced users. + /// It needs to be called at least once every 10 minutes to remain visibly marked as online. + /// + public void SendAck() + { + if (apiState.Value != APIState.Online) + return; + + var req = new ChatAckRequest + { + SinceMessageId = lastSilenceMessageId, + SinceSilenceId = lastSilenceId + }; + + req.Failure += _ => scheduleNextRequest(); + req.Success += ack => + { + foreach (var silence in ack.Silences) + { + foreach (var channel in JoinedChannels) + channel.RemoveMessagesFromUser(silence.UserId); + lastSilenceId = Math.Max(lastSilenceId ?? 0, silence.Id); + } + + scheduleNextRequest(); + }; + + api.Queue(req); + + void scheduleNextRequest() + { + scheduledAck?.Cancel(); + scheduledAck = Scheduler.AddDelayed(SendAck, 60000); + } + } + /// /// Find an existing channel instance for the provided channel. Lookup is performed basd on ID. /// The provided channel may be used if an existing instance is not found. @@ -391,7 +432,13 @@ namespace osu.Game.Online.Chat { Channel found = null; - bool lookupCondition(Channel ch) => lookup.Id > 0 ? ch.Id == lookup.Id : lookup.Name == ch.Name; + bool lookupCondition(Channel ch) + { + if (ch.Id > 0 && lookup.Id > 0) + return ch.Id == lookup.Id; + + return ch.Name == lookup.Name; + } var available = AvailableChannels.FirstOrDefault(lookupCondition); if (available != null) @@ -411,6 +458,12 @@ namespace osu.Game.Online.Chat if (foundSelf != null) found.Users.Remove(foundSelf); } + else + { + found.Id = lookup.Id; + found.Name = lookup.Name; + found.LastMessageId = Math.Max(found.LastMessageId ?? 0, lookup.LastMessageId ?? 0); + } if (joined == null && addToJoined) joinedChannels.Add(found); if (available == null && addToAvailable) availableChannels.Add(found); @@ -445,14 +498,22 @@ namespace osu.Game.Online.Chat return channel; case ChannelType.PM: + Logger.Log($"Attempting to join PM channel {channel}"); + var createRequest = new CreateChannelRequest(channel); + createRequest.Failure += e => + { + Logger.Log($"Failed to join PM channel {channel} ({e.Message})"); + }; createRequest.Success += resChannel => { + Logger.Log($"Joined PM channel {channel} ({resChannel.ChannelID})"); + if (resChannel.ChannelID.HasValue) { channel.Id = resChannel.ChannelID.Value; - handleChannelMessages(resChannel.RecentMessages); + addMessages(resChannel.RecentMessages); channel.MessagesLoaded = true; // this will mark the channel as having received messages even if there were none. } }; @@ -461,9 +522,23 @@ namespace osu.Game.Online.Chat break; default: + Logger.Log($"Attempting to join public channel {channel}"); + var req = new JoinChannelRequest(channel); - req.Success += () => joinChannel(channel, fetchInitialMessages); - req.Failure += ex => LeaveChannel(channel); + req.Success += () => + { + Logger.Log($"Joined public channel {channel}"); + joinChannel(channel, fetchInitialMessages); + + // Required after joining public channels to mark the user as online in them. + // Todo: Temporary workaround for https://github.com/ppy/osu-web/issues/9602 + SendAck(); + }; + req.Failure += e => + { + Logger.Log($"Failed to join public channel {channel} ({e.Message})"); + LeaveChannel(channel); + }; api.Queue(req); return channel; } @@ -483,7 +558,9 @@ namespace osu.Game.Online.Chat /// Leave the specified channel. Can be called from any thread. /// /// The channel to leave. - public void LeaveChannel(Channel channel) => Schedule(() => + public void LeaveChannel(Channel channel) => Schedule(() => leaveChannel(channel, true)); + + private void leaveChannel(Channel channel, bool sendLeaveRequest) { if (channel == null) return; @@ -506,10 +583,11 @@ namespace osu.Game.Online.Chat if (channel.Joined.Value) { - api.Queue(new LeaveChannelRequest(channel)); + if (sendLeaveRequest) + api.Queue(new LeaveChannelRequest(channel)); channel.Joined.Value = false; } - }); + } /// /// Opens the most recently closed channel that has not already been reopened, @@ -552,57 +630,6 @@ namespace osu.Game.Online.Chat } } - private long lastMessageId; - - private bool channelsInitialised; - - protected override Task Poll() - { - if (!api.IsLoggedIn) - return base.Poll(); - - var fetchReq = new GetUpdatesRequest(lastMessageId); - - var tcs = new TaskCompletionSource(); - - fetchReq.Success += updates => - { - if (updates?.Presence != null) - { - foreach (var channel in updates.Presence) - { - // we received this from the server so should mark the channel already joined. - channel.Joined.Value = true; - joinChannel(channel); - } - - //todo: handle left channels - - handleChannelMessages(updates.Messages); - - foreach (var group in updates.Messages.GroupBy(m => m.ChannelId)) - JoinedChannels.FirstOrDefault(c => c.Id == group.Key)?.AddNewMessages(group.ToArray()); - - lastMessageId = updates.Messages.LastOrDefault()?.Id ?? lastMessageId; - } - - if (!channelsInitialised) - { - channelsInitialised = true; - // we want this to run after the first presence so we can see if the user is in any channels already. - initializeChannels(); - } - - tcs.SetResult(true); - }; - - fetchReq.Failure += _ => tcs.SetResult(false); - - api.Queue(fetchReq); - - return tcs.Task; - } - /// /// Marks the as read /// @@ -624,6 +651,12 @@ namespace osu.Game.Online.Chat api.Queue(req); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + connector?.Dispose(); + } } /// diff --git a/osu.Game/Online/Chat/ChannelType.cs b/osu.Game/Online/Chat/ChannelType.cs index bd628e90c4..a864e20830 100644 --- a/osu.Game/Online/Chat/ChannelType.cs +++ b/osu.Game/Online/Chat/ChannelType.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Online.Chat { public enum ChannelType diff --git a/osu.Game/Online/Chat/DrawableLinkCompiler.cs b/osu.Game/Online/Chat/DrawableLinkCompiler.cs index 3b0d049528..ee53c00668 100644 --- a/osu.Game/Online/Chat/DrawableLinkCompiler.cs +++ b/osu.Game/Online/Chat/DrawableLinkCompiler.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -18,7 +20,7 @@ namespace osu.Game.Online.Chat /// /// An invisible drawable that brings multiple pieces together to form a consumable clickable link. /// - public class DrawableLinkCompiler : OsuHoverContainer + public partial class DrawableLinkCompiler : OsuHoverContainer { /// /// Each word part of a chat link (split for word-wrap support). @@ -50,7 +52,7 @@ namespace osu.Game.Online.Chat protected override IEnumerable EffectTargets => Parts; - private class LinkHoverSounds : HoverClickSounds + private partial class LinkHoverSounds : HoverClickSounds { private readonly List parts; diff --git a/osu.Game/Online/Chat/ErrorMessage.cs b/osu.Game/Online/Chat/ErrorMessage.cs index 87a65fb3f1..9cd91a0927 100644 --- a/osu.Game/Online/Chat/ErrorMessage.cs +++ b/osu.Game/Online/Chat/ErrorMessage.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Online.Chat { public class ErrorMessage : InfoMessage diff --git a/osu.Game/Online/Chat/ExternalLinkOpener.cs b/osu.Game/Online/Chat/ExternalLinkOpener.cs index 20d8459132..201212c648 100644 --- a/osu.Game/Online/Chat/ExternalLinkOpener.cs +++ b/osu.Game/Online/Chat/ExternalLinkOpener.cs @@ -1,25 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.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.Overlays; -using osu.Game.Overlays.Chat; +using osu.Game.Overlays.Dialog; namespace osu.Game.Online.Chat { - public class ExternalLinkOpener : Component + public partial class ExternalLinkOpener : Component { [Resolved] - private GameHost host { get; set; } + private GameHost host { get; set; } = null!; [Resolved(CanBeNull = true)] - private IDialogOverlay dialogOverlay { get; set; } + private IDialogOverlay? dialogOverlay { get; set; } - private Bindable externalLinkWarning; + private Bindable externalLinkWarning = null!; [BackgroundDependencyLoader(true)] private void load(OsuConfigManager config) @@ -29,10 +31,39 @@ namespace osu.Game.Online.Chat public void OpenUrlExternally(string url, bool bypassWarning = false) { - if (!bypassWarning && externalLinkWarning.Value) - dialogOverlay.Push(new ExternalLinkDialog(url, () => host.OpenUrlExternally(url))); + if (!bypassWarning && externalLinkWarning.Value && dialogOverlay != null) + dialogOverlay.Push(new ExternalLinkDialog(url, () => host.OpenUrlExternally(url), () => host.GetClipboard()?.SetText(url))); else host.OpenUrlExternally(url); } + + public partial class ExternalLinkDialog : PopupDialog + { + public ExternalLinkDialog(string url, Action openExternalLinkAction, Action copyExternalLinkAction) + { + HeaderText = "Just checking..."; + BodyText = $"You are about to leave osu! and open the following link in a web browser:\n\n{url}"; + + Icon = FontAwesome.Solid.ExclamationTriangle; + + Buttons = new PopupDialogButton[] + { + new PopupDialogOkButton + { + Text = @"Yes. Go for it.", + Action = openExternalLinkAction + }, + new PopupDialogCancelButton + { + Text = @"Copy URL to the clipboard instead.", + Action = copyExternalLinkAction + }, + new PopupDialogCancelButton + { + Text = @"No! Abort mission!" + }, + }; + } + } } } diff --git a/osu.Game/Online/Chat/IChannelPostTarget.cs b/osu.Game/Online/Chat/IChannelPostTarget.cs index 5697e918f0..bd8648189a 100644 --- a/osu.Game/Online/Chat/IChannelPostTarget.cs +++ b/osu.Game/Online/Chat/IChannelPostTarget.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; namespace osu.Game.Online.Chat diff --git a/osu.Game/Online/Chat/InfoMessage.cs b/osu.Game/Online/Chat/InfoMessage.cs index e97245df45..2ade99dcb2 100644 --- a/osu.Game/Online/Chat/InfoMessage.cs +++ b/osu.Game/Online/Chat/InfoMessage.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Online.Chat @@ -11,7 +10,6 @@ namespace osu.Game.Online.Chat public InfoMessage(string message) : base(null) { - Timestamp = DateTimeOffset.Now; Content = message; Sender = APIUser.SYSTEM_USER; diff --git a/osu.Game/Online/Chat/LocalMessage.cs b/osu.Game/Online/Chat/LocalMessage.cs index 027edbcca5..57caca2287 100644 --- a/osu.Game/Online/Chat/LocalMessage.cs +++ b/osu.Game/Online/Chat/LocalMessage.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; + namespace osu.Game.Online.Chat { /// @@ -11,6 +13,7 @@ namespace osu.Game.Online.Chat protected LocalMessage(long? id) : base(id) { + Timestamp = DateTimeOffset.Now; } } } diff --git a/osu.Game/Online/Chat/Message.cs b/osu.Game/Online/Chat/Message.cs index ad004e2881..8ea3ca0fc7 100644 --- a/osu.Game/Online/Chat/Message.cs +++ b/osu.Game/Online/Chat/Message.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; +using System.Threading; using Newtonsoft.Json; using osu.Game.Online.API.Requests.Responses; @@ -28,6 +31,19 @@ namespace osu.Game.Online.Chat [JsonProperty(@"sender")] public APIUser Sender; + [JsonProperty(@"sender_id")] + public int SenderId + { + get => Sender?.Id ?? 0; + set => Sender = new APIUser { Id = value }; + } + + /// + /// A unique identifier for this message. Sent to and from osu!web to use for deduplication. + /// + [JsonProperty(@"uuid")] + public string Uuid { get; set; } = string.Empty; + [JsonConstructor] public Message() { @@ -44,19 +60,28 @@ namespace osu.Game.Online.Chat /// The s' and s are according to public List Links; + private static long constructionOrderStatic; + private readonly long constructionOrder; + public Message(long? id) { Id = id; + + constructionOrder = Interlocked.Increment(ref constructionOrderStatic); } public int CompareTo(Message other) { - if (!Id.HasValue) - return other.Id.HasValue ? 1 : Timestamp.CompareTo(other.Timestamp); - if (!other.Id.HasValue) - return -1; + if (Id.HasValue && other.Id.HasValue) + return Id.Value.CompareTo(other.Id.Value); - return Id.Value.CompareTo(other.Id.Value); + int timestampComparison = Timestamp.CompareTo(other.Timestamp); + + if (timestampComparison != 0) + return timestampComparison; + + // Timestamp might not be accurate enough to make a stable sorting decision. + return constructionOrder.CompareTo(other.constructionOrder); } public virtual bool Equals(Message other) @@ -70,6 +95,6 @@ namespace osu.Game.Online.Chat // ReSharper disable once ImpureMethodCallOnReadonlyValueField public override int GetHashCode() => Id.GetHashCode(); - public override string ToString() => $"[{ChannelId}] ({Id}) {Sender}: {Content}"; + public override string ToString() => $"({(Id?.ToString() ?? "null")}) {Timestamp} {Sender}: {Content}"; } } diff --git a/osu.Game/Online/Chat/MessageFormatter.cs b/osu.Game/Online/Chat/MessageFormatter.cs index b18daea453..523185a7cb 100644 --- a/osu.Game/Online/Chat/MessageFormatter.cs +++ b/osu.Game/Online/Chat/MessageFormatter.cs @@ -6,8 +6,6 @@ using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; -#nullable enable - namespace osu.Game.Online.Chat { public static class MessageFormatter @@ -73,7 +71,7 @@ namespace osu.Game.Online.Chat { int index = m.Index - captureOffset; - string? displayText = string.Format(display, + string displayText = string.Format(display, m.Groups[0], m.Groups["text"].Value, m.Groups["url"].Value).Trim(); @@ -111,7 +109,7 @@ namespace osu.Game.Online.Chat foreach (Match m in regex.Matches(result.Text, startIndex)) { int index = m.Index; - string? linkText = m.Groups["link"].Value; + string linkText = m.Groups["link"].Value; int indexLength = linkText.Length; var details = GetLinkDetails(linkText); @@ -127,7 +125,7 @@ namespace osu.Game.Online.Chat public static LinkDetails GetLinkDetails(string url) { - string[]? args = url.Split('/', StringSplitOptions.RemoveEmptyEntries); + string[] args = url.Split('/', StringSplitOptions.RemoveEmptyEntries); args[0] = args[0].TrimEnd(':'); switch (args[0]) @@ -343,6 +341,8 @@ namespace osu.Game.Online.Chat OpenWiki, Custom, OpenChangelog, + FilterBeatmapSetGenre, + FilterBeatmapSetLanguage, } public class Link : IComparable @@ -364,6 +364,6 @@ namespace osu.Game.Online.Chat public bool Overlaps(Link otherLink) => Index < otherLink.Index + otherLink.Length && otherLink.Index < Index + Length; - public int CompareTo(Link otherLink) => Index > otherLink.Index ? 1 : -1; + public int CompareTo(Link? otherLink) => Index > otherLink?.Index ? 1 : -1; } } diff --git a/osu.Game/Online/Chat/MessageNotifier.cs b/osu.Game/Online/Chat/MessageNotifier.cs index fbc5ef79ef..9b2ad666b2 100644 --- a/osu.Game/Online/Chat/MessageNotifier.cs +++ b/osu.Game/Online/Chat/MessageNotifier.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Collections.Specialized; +using System.Diagnostics; using System.Linq; using System.Text.RegularExpressions; using osu.Framework.Allocation; @@ -21,7 +24,7 @@ namespace osu.Game.Online.Chat /// /// Component that handles creating and posting notifications for incoming messages. /// - public class MessageNotifier : Component + public partial class MessageNotifier : Component { [Resolved] private INotificationOverlay notifications { get; set; } @@ -59,12 +62,16 @@ namespace osu.Game.Online.Chat switch (e.Action) { case NotifyCollectionChangedAction.Add: + Debug.Assert(e.NewItems != null); + foreach (var channel in e.NewItems.Cast()) channel.NewMessagesArrived += checkNewMessages; break; case NotifyCollectionChangedAction.Remove: + Debug.Assert(e.OldItems != null); + foreach (var channel in e.OldItems.Cast()) channel.NewMessagesArrived -= checkNewMessages; @@ -136,7 +143,7 @@ namespace osu.Game.Online.Chat return Regex.IsMatch(message, $@"(^|\W)({fullName}|{underscoreName})($|\W)", RegexOptions.IgnoreCase); } - public class PrivateMessageNotification : HighlightMessageNotification + public partial class PrivateMessageNotification : HighlightMessageNotification { public PrivateMessageNotification(Message message, Channel channel) : base(message, channel) @@ -146,7 +153,7 @@ namespace osu.Game.Online.Chat } } - public class MentionNotification : HighlightMessageNotification + public partial class MentionNotification : HighlightMessageNotification { public MentionNotification(Message message, Channel channel) : base(message, channel) @@ -156,7 +163,7 @@ namespace osu.Game.Online.Chat } } - public abstract class HighlightMessageNotification : SimpleNotification + public abstract partial class HighlightMessageNotification : SimpleNotification { protected HighlightMessageNotification(Message message, Channel channel) { @@ -172,7 +179,7 @@ namespace osu.Game.Online.Chat [BackgroundDependencyLoader] private void load(OsuColour colours, ChatOverlay chatOverlay, INotificationOverlay notificationOverlay) { - IconBackground.Colour = colours.PurpleDark; + IconContent.Colour = colours.PurpleDark; Activated = delegate { diff --git a/osu.Game/Online/Chat/NowPlayingCommand.cs b/osu.Game/Online/Chat/NowPlayingCommand.cs index 2bf35d7973..e7018d6993 100644 --- a/osu.Game/Online/Chat/NowPlayingCommand.cs +++ b/osu.Game/Online/Chat/NowPlayingCommand.cs @@ -1,33 +1,48 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using System.Linq; +using System.Text; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Online.API; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; using osu.Game.Users; namespace osu.Game.Online.Chat { - public class NowPlayingCommand : Component + public partial class NowPlayingCommand : Component { [Resolved] - private IChannelPostTarget channelManager { get; set; } + private IChannelPostTarget channelManager { get; set; } = null!; [Resolved] - private IAPIProvider api { get; set; } + private IAPIProvider api { get; set; } = null!; [Resolved] - private Bindable currentBeatmap { get; set; } + private Bindable currentBeatmap { get; set; } = null!; - private readonly Channel target; + [Resolved] + private Bindable> selectedMods { get; set; } = null!; + + [Resolved] + private IBindable currentRuleset { get; set; } = null!; + + [Resolved] + private LocalisationManager localisation { get; set; } = null!; + + private readonly Channel? target; /// /// Creates a new to post the currently-playing beatmap to a parenting . /// /// The target channel to post to. If null, the currently-selected channel will be posted to. - public NowPlayingCommand(Channel target = null) + public NowPlayingCommand(Channel target) { this.target = target; } @@ -46,7 +61,7 @@ namespace osu.Game.Online.Chat beatmapInfo = game.BeatmapInfo; break; - case UserActivity.Editing edit: + case UserActivity.EditingBeatmap edit: verb = "editing"; beatmapInfo = edit.BeatmapInfo; break; @@ -57,10 +72,55 @@ namespace osu.Game.Online.Chat break; } - string beatmapString = beatmapInfo.OnlineID > 0 ? $"[{api.WebsiteRootUrl}/b/{beatmapInfo.OnlineID} {beatmapInfo}]" : beatmapInfo.ToString(); + string[] pieces = + { + "is", + verb, + getBeatmapPart(), + getRulesetPart(), + getModPart(), + }; - channelManager.PostMessage($"is {verb} {beatmapString}", true, target); + channelManager.PostMessage(string.Join(' ', pieces.Where(p => !string.IsNullOrEmpty(p))), true, target); Expire(); + + string getBeatmapPart() + { + string beatmapInfoString = localisation.GetLocalisedBindableString(beatmapInfo.GetDisplayTitleRomanisable()).Value; + + return beatmapInfo.OnlineID > 0 ? $"[{api.WebsiteRootUrl}/b/{beatmapInfo.OnlineID} {beatmapInfoString}]" : beatmapInfoString; + } + + string getRulesetPart() + { + if (api.Activity.Value is not UserActivity.InGame) return string.Empty; + + return $"<{currentRuleset.Value.Name}>"; + } + + string getModPart() + { + if (api.Activity.Value is not UserActivity.InGame) return string.Empty; + + if (selectedMods.Value.Count == 0) + { + return string.Empty; + } + + StringBuilder modsString = new StringBuilder(); + + foreach (var mod in selectedMods.Value.Where(mod => mod.Type == ModType.DifficultyIncrease)) + { + modsString.Append($"+{mod.Acronym} "); + } + + foreach (var mod in selectedMods.Value.Where(mod => mod.Type != ModType.DifficultyIncrease)) + { + modsString.Append($"-{mod.Acronym} "); + } + + return modsString.ToString().Trim(); + } } } } diff --git a/osu.Game/Online/Chat/StandAloneChatDisplay.cs b/osu.Game/Online/Chat/StandAloneChatDisplay.cs index bbfffea6fd..e3b5037367 100644 --- a/osu.Game/Online/Chat/StandAloneChatDisplay.cs +++ b/osu.Game/Online/Chat/StandAloneChatDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -21,8 +23,9 @@ namespace osu.Game.Online.Chat /// /// Display a chat channel in an insolated region. /// - public class StandAloneChatDisplay : CompositeDrawable + public partial class StandAloneChatDisplay : CompositeDrawable { + [Cached] public readonly Bindable Channel = new Bindable(); protected readonly ChatTextBox TextBox; @@ -109,8 +112,13 @@ namespace osu.Game.Online.Chat { drawableChannel?.Expire(); + if (e.OldValue != null) + TextBox?.Current.UnbindFrom(e.OldValue.TextBoxMessage); + if (e.NewValue == null) return; + TextBox?.Current.BindTo(e.NewValue.TextBoxMessage); + drawableChannel = CreateDrawableChannel(e.NewValue); drawableChannel.CreateChatLineAction = CreateMessage; drawableChannel.Padding = new MarginPadding { Bottom = postingTextBox ? text_box_height : 0 }; @@ -118,17 +126,20 @@ namespace osu.Game.Online.Chat AddInternal(drawableChannel); } - public class ChatTextBox : FocusedTextBox + public partial class ChatTextBox : HistoryTextBox { protected override bool OnKeyDown(KeyDownEvent e) { // Chat text boxes are generally used in places where they retain focus, but shouldn't block interaction with other // elements on the same screen. - switch (e.Key) + if (!HoldFocus) { - case Key.Up: - case Key.Down: - return false; + switch (e.Key) + { + case Key.Up: + case Key.Down: + return false; + } } return base.OnKeyDown(e); @@ -151,7 +162,7 @@ namespace osu.Game.Online.Chat public Action FocusLost; } - public class StandAloneDrawableChannel : DrawableChannel + public partial class StandAloneDrawableChannel : DrawableChannel { public Func CreateChatLineAction; @@ -165,15 +176,15 @@ namespace osu.Game.Online.Chat protected override DaySeparator CreateDaySeparator(DateTimeOffset time) => new StandAloneDaySeparator(time); } - protected class StandAloneDaySeparator : DaySeparator + protected partial class StandAloneDaySeparator : DaySeparator { protected override float TextSize => 14; protected override float LineHeight => 1; protected override float Spacing => 5; protected override float DateAlign => 125; - public StandAloneDaySeparator(DateTimeOffset time) - : base(time) + public StandAloneDaySeparator(DateTimeOffset date) + : base(date) { } @@ -185,11 +196,10 @@ namespace osu.Game.Online.Chat } } - protected class StandAloneMessage : ChatLine + protected partial class StandAloneMessage : ChatLine { - protected override float TextSize => 15; + protected override float FontSize => 15; protected override float Spacing => 5; - protected override float TimestampWidth => 45; protected override float UsernameWidth => 75; public StandAloneMessage(Message message) diff --git a/osu.Game/Online/DevelopmentEndpointConfiguration.cs b/osu.Game/Online/DevelopmentEndpointConfiguration.cs index 69531dbe1b..3171d15fc2 100644 --- a/osu.Game/Online/DevelopmentEndpointConfiguration.cs +++ b/osu.Game/Online/DevelopmentEndpointConfiguration.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Online { public class DevelopmentEndpointConfiguration : EndpointConfiguration @@ -12,6 +14,7 @@ namespace osu.Game.Online APIClientID = "5"; SpectatorEndpointUrl = $"{APIEndpointUrl}/spectator"; MultiplayerEndpointUrl = $"{APIEndpointUrl}/multiplayer"; + MetadataEndpointUrl = $"{APIEndpointUrl}/metadata"; } } } diff --git a/osu.Game/Online/DownloadState.cs b/osu.Game/Online/DownloadState.cs index a58c40d16a..f4ecb28b90 100644 --- a/osu.Game/Online/DownloadState.cs +++ b/osu.Game/Online/DownloadState.cs @@ -5,6 +5,7 @@ namespace osu.Game.Online { public enum DownloadState { + Unknown, NotDownloaded, Downloading, Importing, diff --git a/osu.Game/Online/DownloadTracker.cs b/osu.Game/Online/DownloadTracker.cs index 357c64b6a3..27a765ca20 100644 --- a/osu.Game/Online/DownloadTracker.cs +++ b/osu.Game/Online/DownloadTracker.cs @@ -4,11 +4,9 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; -#nullable enable - namespace osu.Game.Online { - public abstract class DownloadTracker : Component + public abstract partial class DownloadTracker : Component where T : class { public readonly T TrackedItem; diff --git a/osu.Game/Online/EndpointConfiguration.cs b/osu.Game/Online/EndpointConfiguration.cs index e347d3c653..f3bcced630 100644 --- a/osu.Game/Online/EndpointConfiguration.cs +++ b/osu.Game/Online/EndpointConfiguration.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Online { /// @@ -37,5 +39,10 @@ namespace osu.Game.Online /// The endpoint for the SignalR multiplayer server. /// public string MultiplayerEndpointUrl { get; set; } + + /// + /// The endpoint for the SignalR metadata server. + /// + public string MetadataEndpointUrl { get; set; } } } diff --git a/osu.Game/Online/ExperimentalEndpointConfiguration.cs b/osu.Game/Online/ExperimentalEndpointConfiguration.cs new file mode 100644 index 0000000000..c3d0014c8b --- /dev/null +++ b/osu.Game/Online/ExperimentalEndpointConfiguration.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Online +{ + public class ExperimentalEndpointConfiguration : EndpointConfiguration + { + public ExperimentalEndpointConfiguration() + { + WebsiteRootUrl = @"https://osu.ppy.sh"; + APIEndpointUrl = @"https://lazer.ppy.sh"; + APIClientSecret = @"FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk"; + APIClientID = "5"; + SpectatorEndpointUrl = "https://spectator.ppy.sh/spectator"; + MultiplayerEndpointUrl = "https://spectator.ppy.sh/multiplayer"; + MetadataEndpointUrl = "https://spectator.ppy.sh/metadata"; + } + } +} diff --git a/osu.Game/Online/HubClient.cs b/osu.Game/Online/HubClient.cs new file mode 100644 index 0000000000..583f15a4a4 --- /dev/null +++ b/osu.Game/Online/HubClient.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; + +namespace osu.Game.Online +{ + public class HubClient : PersistentEndpointClient + { + public readonly HubConnection Connection; + + public HubClient(HubConnection connection) + { + Connection = connection; + Connection.Closed += InvokeClosed; + } + + public override Task ConnectAsync(CancellationToken cancellationToken) => Connection.StartAsync(cancellationToken); + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync().ConfigureAwait(false); + await Connection.DisposeAsync().ConfigureAwait(false); + } + } +} diff --git a/osu.Game/Online/HubClientConnector.cs b/osu.Game/Online/HubClientConnector.cs index 261724e315..8fd79bd703 100644 --- a/osu.Game/Online/HubClientConnector.cs +++ b/osu.Game/Online/HubClientConnector.cs @@ -1,24 +1,21 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - using System; using System.Collections.Generic; using System.Net; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; using osu.Framework; -using osu.Framework.Bindables; -using osu.Framework.Logging; using osu.Game.Online.API; namespace osu.Game.Online { - public class HubClientConnector : IHubClientConnector + public class HubClientConnector : PersistentEndpointClientConnector, IHubClientConnector { public const string SERVER_SHUTDOWN_MESSAGE = "Server is shutting down."; @@ -27,7 +24,6 @@ namespace osu.Game.Online /// public Action? ConfigureConnection { get; set; } - private readonly string clientName; private readonly string endpoint; private readonly string versionHash; private readonly bool preferMessagePack; @@ -36,18 +32,7 @@ namespace osu.Game.Online /// /// The current connection opened by this connector. /// - public HubConnection? CurrentConnection { get; private set; } - - /// - /// Whether this is connected to the hub, use to access the connection, if this is true. - /// - public IBindable IsConnected => isConnected; - - private readonly Bindable isConnected = new Bindable(); - private readonly SemaphoreSlim connectionLock = new SemaphoreSlim(1); - private CancellationTokenSource connectCancelSource = new CancellationTokenSource(); - - private readonly IBindable apiState = new Bindable(); + public new HubConnection? CurrentConnection => ((HubClient?)base.CurrentConnection)?.Connection; /// /// Constructs a new . @@ -58,114 +43,38 @@ namespace osu.Game.Online /// The hash representing the current game version, used for verification purposes. /// Whether to use MessagePack for serialisation if available on this platform. public HubClientConnector(string clientName, string endpoint, IAPIProvider api, string versionHash, bool preferMessagePack = true) + : base(api) { - this.clientName = clientName; + ClientName = clientName; this.endpoint = endpoint; this.api = api; this.versionHash = versionHash; this.preferMessagePack = preferMessagePack; - apiState.BindTo(api.State); - apiState.BindValueChanged(state => connectIfPossible(), true); + // Automatically start these connections. + Start(); } - public void Reconnect() - { - Logger.Log($"{clientName} reconnecting...", LoggingTarget.Network); - Task.Run(connectIfPossible); - } - - private void connectIfPossible() - { - switch (apiState.Value) - { - case APIState.Failing: - case APIState.Offline: - Task.Run(() => disconnect(true)); - break; - - case APIState.Online: - Task.Run(connect); - break; - } - } - - private async Task connect() - { - cancelExistingConnect(); - - if (!await connectionLock.WaitAsync(10000).ConfigureAwait(false)) - throw new TimeoutException("Could not obtain a lock to connect. A previous attempt is likely stuck."); - - try - { - while (apiState.Value == APIState.Online) - { - // ensure any previous connection was disposed. - // this will also create a new cancellation token source. - await disconnect(false).ConfigureAwait(false); - - // this token will be valid for the scope of this connection. - // if cancelled, we can be sure that a disconnect or reconnect is handled elsewhere. - var cancellationToken = connectCancelSource.Token; - - cancellationToken.ThrowIfCancellationRequested(); - - Logger.Log($"{clientName} connecting...", LoggingTarget.Network); - - try - { - // importantly, rebuild the connection each attempt to get an updated access token. - CurrentConnection = buildConnection(cancellationToken); - - await CurrentConnection.StartAsync(cancellationToken).ConfigureAwait(false); - - Logger.Log($"{clientName} connected!", LoggingTarget.Network); - isConnected.Value = true; - return; - } - catch (OperationCanceledException) - { - //connection process was cancelled. - throw; - } - catch (Exception e) - { - await handleErrorAndDelay(e, cancellationToken).ConfigureAwait(false); - } - } - } - finally - { - connectionLock.Release(); - } - } - - /// - /// Handles an exception and delays an async flow. - /// - private async Task handleErrorAndDelay(Exception exception, CancellationToken cancellationToken) - { - Logger.Log($"{clientName} connection error: {exception}", LoggingTarget.Network); - await Task.Delay(5000, cancellationToken).ConfigureAwait(false); - } - - private HubConnection buildConnection(CancellationToken cancellationToken) + protected override Task BuildConnectionAsync(CancellationToken cancellationToken) { var builder = new HubConnectionBuilder() .WithUrl(endpoint, options => { - // Use HttpClient.DefaultProxy once on net6 everywhere. - // The credential setter can also be removed at this point. - options.Proxy = WebRequest.DefaultWebProxy; - if (options.Proxy != null) - options.Proxy.Credentials = CredentialCache.DefaultCredentials; + // Configuring proxies is not supported on iOS, see https://github.com/xamarin/xamarin-macios/issues/14632. + if (RuntimeInfo.OS != RuntimeInfo.Platform.iOS) + { + // Use HttpClient.DefaultProxy once on net6 everywhere. + // The credential setter can also be removed at this point. + options.Proxy = WebRequest.DefaultWebProxy; + if (options.Proxy != null) + options.Proxy.Credentials = CredentialCache.DefaultCredentials; + } options.Headers.Add("Authorization", $"Bearer {api.AccessToken}"); options.Headers.Add("OsuVersionHash", versionHash); }); - if (RuntimeInfo.SupportsJIT && preferMessagePack) + if (RuntimeFeature.IsDynamicCodeCompiled && preferMessagePack) { builder.AddMessagePackProtocol(options => { @@ -190,59 +99,9 @@ namespace osu.Game.Online ConfigureConnection?.Invoke(newConnection); - newConnection.Closed += ex => onConnectionClosed(ex, cancellationToken); - return newConnection; + return Task.FromResult((PersistentEndpointClient)new HubClient(newConnection)); } - private async Task onConnectionClosed(Exception? ex, CancellationToken cancellationToken) - { - isConnected.Value = false; - - if (ex != null) - await handleErrorAndDelay(ex, cancellationToken).ConfigureAwait(false); - else - Logger.Log($"{clientName} disconnected", LoggingTarget.Network); - - // make sure a disconnect wasn't triggered (and this is still the active connection). - if (!cancellationToken.IsCancellationRequested) - await Task.Run(connect, default).ConfigureAwait(false); - } - - private async Task disconnect(bool takeLock) - { - cancelExistingConnect(); - - if (takeLock) - { - if (!await connectionLock.WaitAsync(10000).ConfigureAwait(false)) - throw new TimeoutException("Could not obtain a lock to disconnect. A previous attempt is likely stuck."); - } - - try - { - if (CurrentConnection != null) - await CurrentConnection.DisposeAsync().ConfigureAwait(false); - } - finally - { - CurrentConnection = null; - if (takeLock) - connectionLock.Release(); - } - } - - private void cancelExistingConnect() - { - connectCancelSource.Cancel(); - connectCancelSource = new CancellationTokenSource(); - } - - public override string ToString() => $"Connector for {clientName} ({(IsConnected.Value ? "connected" : "not connected")}"; - - public void Dispose() - { - apiState.UnbindAll(); - cancelExistingConnect(); - } + protected override string ClientName { get; } } } diff --git a/osu.Game/Online/IHubClientConnector.cs b/osu.Game/Online/IHubClientConnector.cs index b168e4669f..53c4897e73 100644 --- a/osu.Game/Online/IHubClientConnector.cs +++ b/osu.Game/Online/IHubClientConnector.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 enable - using System; +using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR.Client; using osu.Framework.Bindables; using osu.Game.Online.API; @@ -34,6 +33,6 @@ namespace osu.Game.Online /// /// Reconnect if already connected. /// - void Reconnect(); + Task Reconnect(); } } diff --git a/osu.Game/Online/ILinkHandler.cs b/osu.Game/Online/ILinkHandler.cs new file mode 100644 index 0000000000..1b8fad4bd9 --- /dev/null +++ b/osu.Game/Online/ILinkHandler.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Game.Online.Chat; + +namespace osu.Game.Online +{ + /// + /// Handle an arbitrary URL. Displays via in-game overlays where possible. + /// Methods can be called from a non-thread-safe non-game-loaded state. + /// + [Cached] + public interface ILinkHandler + { + /// + /// Handle an arbitrary URL. Displays via in-game overlays where possible. + /// This can be called from a non-thread-safe non-game-loaded state. + /// + /// The URL to load. + void HandleLink(string url); + + /// + /// Handle a specific . + /// This can be called from a non-thread-safe non-game-loaded state. + /// + /// The link to load. + void HandleLink(LinkDetails link); + } +} diff --git a/osu.Game/Online/Leaderboards/DrawableRank.cs b/osu.Game/Online/Leaderboards/DrawableRank.cs index 4d41230799..5a65c15444 100644 --- a/osu.Game/Online/Leaderboards/DrawableRank.cs +++ b/osu.Game/Online/Leaderboards/DrawableRank.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -16,7 +18,7 @@ using osuTK.Graphics; namespace osu.Game.Online.Leaderboards { - public class DrawableRank : CompositeDrawable + public partial class DrawableRank : CompositeDrawable { private readonly ScoreRank rank; diff --git a/osu.Game/Online/Leaderboards/Leaderboard.cs b/osu.Game/Online/Leaderboards/Leaderboard.cs index c94a6d3361..93aa0b95a7 100644 --- a/osu.Game/Online/Leaderboards/Leaderboard.cs +++ b/osu.Game/Online/Leaderboards/Leaderboard.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Development; @@ -32,12 +31,14 @@ namespace osu.Game.Online.Leaderboards /// /// The scope of the leaderboard (ie. global or local). /// The score model class. - public abstract class Leaderboard : CompositeDrawable + public abstract partial class Leaderboard : CompositeDrawable { /// /// The currently displayed scores. /// - public IEnumerable Scores => scores; + public IBindableList Scores => scores; + + private readonly BindableList scores = new BindableList(); /// /// Whether the current scope should refetch in response to changes in API connectivity state. @@ -50,25 +51,23 @@ namespace osu.Game.Online.Leaderboards private readonly Container placeholderContainer; private readonly UserTopScoreContainer userScoreContainer; - private FillFlowContainer scoreFlowContainer; + private FillFlowContainer? scoreFlowContainer; private readonly LoadingSpinner loading; - private CancellationTokenSource currentFetchCancellationSource; - private CancellationTokenSource currentScoresAsyncLoadCancellationSource; + private CancellationTokenSource? currentFetchCancellationSource; + private CancellationTokenSource? currentScoresAsyncLoadCancellationSource; - private APIRequest fetchScoresRequest; + private APIRequest? fetchScoresRequest; private LeaderboardState state; [Resolved(CanBeNull = true)] - private IAPIProvider api { get; set; } + private IAPIProvider? api { get; set; } private readonly IBindable apiState = new Bindable(); - private ICollection scores; - - private TScope scope; + private TScope scope = default!; public TScope Scope { @@ -167,7 +166,7 @@ namespace osu.Game.Online.Leaderboards throw new InvalidOperationException($"State {state} cannot be set by a leaderboard implementation."); } - Debug.Assert(scores?.Any() != true); + Debug.Assert(!scores.Any()); setState(state); } @@ -177,17 +176,33 @@ namespace osu.Game.Online.Leaderboards /// /// The scores to display. /// The user top score, if any. - protected void SetScores(IEnumerable scores, TScoreInfo userScore = default) + protected void SetScores(IEnumerable? scores, TScoreInfo? userScore = default) { - this.scores = scores?.ToList(); - userScoreContainer.Score.Value = userScore; + this.scores.Clear(); + if (scores != null) + this.scores.AddRange(scores); - if (userScore == null) - userScoreContainer.Hide(); - else - userScoreContainer.Show(); + // Non-delayed schedule may potentially run inline (due to IsMainThread check passing) after leaderboard is disposed. + // This is guarded against in BeatmapLeaderboard via web request cancellation, but let's be extra safe. + if (!IsDisposed) + { + // Schedule needs to be non-delayed here for the weird logic in refetchScores to work. + // If it is removed, the placeholder will be incorrectly updated to "no scores" rather than "retrieving". + // This whole flow should be refactored in the future. + Scheduler.Add(applyNewScores, false); + } - Scheduler.Add(updateScoresDrawables, false); + void applyNewScores() + { + userScoreContainer.Score.Value = userScore; + + if (userScore == null) + userScoreContainer.Hide(); + else + userScoreContainer.Show(); + + updateScoresDrawables(); + } } /// @@ -195,8 +210,7 @@ namespace osu.Game.Online.Leaderboards /// /// /// An responsible for the fetch operation. This will be queued and performed automatically. - [CanBeNull] - protected abstract APIRequest FetchScores(CancellationToken cancellationToken); + protected abstract APIRequest? FetchScores(CancellationToken cancellationToken); protected abstract LeaderboardScore CreateDrawableScore(TScoreInfo model, int index); @@ -207,8 +221,8 @@ namespace osu.Game.Online.Leaderboards Debug.Assert(ThreadSafety.IsUpdateThread); cancelPendingWork(); - SetScores(null); + SetScores(null); setState(LeaderboardState.Retrieving); currentFetchCancellationSource = new CancellationTokenSource(); @@ -245,7 +259,7 @@ namespace osu.Game.Online.Leaderboards .Expire(); scoreFlowContainer = null; - if (scores?.Any() != true) + if (!scores.Any()) { setState(LeaderboardState.NoScores); return; @@ -280,7 +294,7 @@ namespace osu.Game.Online.Leaderboards #region Placeholder handling - private Placeholder placeholder; + private Placeholder? placeholder; private void setState(LeaderboardState state) { @@ -307,7 +321,7 @@ namespace osu.Game.Online.Leaderboards placeholder.FadeInFromZero(fade_duration, Easing.OutQuint); } - private Placeholder getPlaceholderFor(LeaderboardState state) + private Placeholder? getPlaceholderFor(LeaderboardState state) { switch (state) { diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs index c75e98cdaa..e20b28ee0c 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -35,7 +37,7 @@ using osu.Game.Utils; namespace osu.Game.Online.Leaderboards { - public class LeaderboardScore : OsuClickableContainer, IHasContextMenu, IHasCustomTooltip + public partial class LeaderboardScore : OsuClickableContainer, IHasContextMenu, IHasCustomTooltip { public const float HEIGHT = 60; @@ -179,11 +181,11 @@ namespace osu.Game.Online.Leaderboards Masking = true, Children = new Drawable[] { - new UpdateableFlag(user.Country) + new UpdateableFlag(user.CountryCode) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Size = new Vector2(30f, 20f), + Size = new Vector2(28, 20), }, new DateLabel(Score.Date) { @@ -308,7 +310,7 @@ namespace osu.Game.Online.Leaderboards base.OnHoverLost(e); } - private class ScoreComponentLabel : Container, IHasTooltip + private partial class ScoreComponentLabel : Container, IHasTooltip { private const float icon_size = 20; private readonly FillFlowContainer content; @@ -370,7 +372,7 @@ namespace osu.Game.Online.Leaderboards } } - private class RankLabel : Container, IHasTooltip + private partial class RankLabel : Container, IHasTooltip { public RankLabel(int? rank) { @@ -389,7 +391,7 @@ namespace osu.Game.Online.Leaderboards public LocalisableString TooltipText { get; } } - private class DateLabel : DrawableDate + private partial class DateLabel : DrawableDate { public DateLabel(DateTimeOffset date) : base(date) @@ -424,10 +426,10 @@ namespace osu.Game.Online.Leaderboards items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => songSelect.Mods.Value = Score.Mods)); if (Score.Files.Count > 0) + { items.Add(new OsuMenuItem("Export", MenuItemType.Standard, () => new LegacyScoreExporter(storage).Export(Score))); - - if (!isOnlineScope) items.Add(new OsuMenuItem(CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(Score)))); + } return items.ToArray(); } diff --git a/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs b/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs index c26e9e6802..0b2e401f57 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs @@ -10,20 +10,23 @@ using osuTK; using osu.Game.Graphics.Sprites; using osu.Game.Graphics; using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Localisation; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; - -#nullable enable +using osu.Framework.Bindables; +using osu.Game.Configuration; namespace osu.Game.Online.Leaderboards { - public class LeaderboardScoreTooltip : VisibilityContainer, ITooltip + public partial class LeaderboardScoreTooltip : VisibilityContainer, ITooltip { private OsuSpriteText timestampLabel = null!; private FillFlowContainer topScoreStatistics = null!; private FillFlowContainer bottomScoreStatistics = null!; private FillFlowContainer modStatistics = null!; + private readonly Bindable prefer24HourTime = new Bindable(); public LeaderboardScoreTooltip() { @@ -36,8 +39,9 @@ namespace osu.Game.Online.Leaderboards } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, OsuConfigManager configManager) { + configManager.BindWith(OsuSetting.Prefer24HourTime, prefer24HourTime); InternalChildren = new Drawable[] { new Box @@ -92,6 +96,13 @@ namespace osu.Game.Online.Leaderboards }; } + protected override void LoadComplete() + { + base.LoadComplete(); + + prefer24HourTime.BindValueChanged(_ => updateTimestampLabel(), true); + } + private ScoreInfo? displayedScore; public void SetContent(ScoreInfo score) @@ -101,7 +112,7 @@ namespace osu.Game.Online.Leaderboards displayedScore = score; - timestampLabel.Text = $"Played on {score.Date.ToLocalTime():d MMMM yyyy HH:mm}"; + updateTimestampLabel(); modStatistics.Clear(); topScoreStatistics.Clear(); @@ -121,14 +132,23 @@ namespace osu.Game.Online.Leaderboards } } + private void updateTimestampLabel() + { + if (displayedScore != null) + { + timestampLabel.Text = LocalisableString.Format("Played on {0}", + displayedScore.Date.ToLocalTime().ToLocalisableString(prefer24HourTime.Value ? @"d MMMM yyyy HH:mm" : @"d MMMM yyyy h:mm tt")); + } + } + protected override void PopIn() => this.FadeIn(20, Easing.OutQuint); protected override void PopOut() => this.FadeOut(80, Easing.OutQuint); public void Move(Vector2 pos) => Position = pos; - private class HitResultCell : CompositeDrawable + private partial class HitResultCell : CompositeDrawable { - private readonly string displayName; + private readonly LocalisableString displayName; private readonly HitResult result; private readonly int count; @@ -136,7 +156,7 @@ namespace osu.Game.Online.Leaderboards { AutoSizeAxes = Axes.Both; - displayName = stat.DisplayName; + displayName = stat.DisplayName.ToUpper(); result = stat.Result; count = stat.Count; } @@ -155,7 +175,7 @@ namespace osu.Game.Online.Leaderboards new OsuSpriteText { Font = OsuFont.Torus.With(size: 12, weight: FontWeight.SemiBold), - Text = displayName.ToUpperInvariant(), + Text = displayName.ToUpper(), Colour = colours.ForHitResult(result), }, new OsuSpriteText @@ -168,7 +188,7 @@ namespace osu.Game.Online.Leaderboards } } - private class ModCell : CompositeDrawable + private partial class ModCell : CompositeDrawable { private readonly Mod mod; diff --git a/osu.Game/Online/Leaderboards/LeaderboardState.cs b/osu.Game/Online/Leaderboards/LeaderboardState.cs index 6b07500a98..abc0ef4f19 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardState.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardState.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Online.Leaderboards { public enum LeaderboardState diff --git a/osu.Game/Online/Leaderboards/UpdateableRank.cs b/osu.Game/Online/Leaderboards/UpdateableRank.cs index 8f74fd84fe..46cfe8ec65 100644 --- a/osu.Game/Online/Leaderboards/UpdateableRank.cs +++ b/osu.Game/Online/Leaderboards/UpdateableRank.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Scoring; namespace osu.Game.Online.Leaderboards { - public class UpdateableRank : ModelBackedDrawable + public partial class UpdateableRank : ModelBackedDrawable { public ScoreRank? Rank { @@ -15,7 +17,7 @@ namespace osu.Game.Online.Leaderboards set => Model = value; } - public UpdateableRank(ScoreRank? rank) + public UpdateableRank(ScoreRank? rank = null) { Rank = rank; } diff --git a/osu.Game/Online/Leaderboards/UserTopScoreContainer.cs b/osu.Game/Online/Leaderboards/UserTopScoreContainer.cs index 3db497bd6a..af59da2fb8 100644 --- a/osu.Game/Online/Leaderboards/UserTopScoreContainer.cs +++ b/osu.Game/Online/Leaderboards/UserTopScoreContainer.cs @@ -12,17 +12,19 @@ using osuTK; namespace osu.Game.Online.Leaderboards { - public class UserTopScoreContainer : VisibilityContainer + public partial class UserTopScoreContainer : VisibilityContainer { private const int duration = 500; - public Bindable Score = new Bindable(); + public Bindable Score = new Bindable(); private readonly Container scoreContainer; private readonly Func createScoreDelegate; protected override bool StartHidden => true; + private CancellationTokenSource? loadScoreCancellation; + public UserTopScoreContainer(Func createScoreDelegate) { this.createScoreDelegate = createScoreDelegate; @@ -46,7 +48,7 @@ namespace osu.Game.Online.Leaderboards { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = @"your personal best".ToUpper(), + Text = @"your personal best".ToUpperInvariant(), Font = OsuFont.GetFont(size: 15, weight: FontWeight.Bold), }, scoreContainer = new Container @@ -63,9 +65,7 @@ namespace osu.Game.Online.Leaderboards Score.BindValueChanged(onScoreChanged); } - private CancellationTokenSource loadScoreCancellation; - - private void onScoreChanged(ValueChangedEvent score) + private void onScoreChanged(ValueChangedEvent score) { var newScore = score.NewValue; diff --git a/osu.Game/Online/Metadata/BeatmapUpdates.cs b/osu.Game/Online/Metadata/BeatmapUpdates.cs new file mode 100644 index 0000000000..a0cf616c70 --- /dev/null +++ b/osu.Game/Online/Metadata/BeatmapUpdates.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using MessagePack; + +namespace osu.Game.Online.Metadata +{ + /// + /// Describes a set of beatmaps which have been updated in some way. + /// + [MessagePackObject] + [Serializable] + public class BeatmapUpdates + { + [Key(0)] + public int[] BeatmapSetIDs { get; set; } + + [Key(1)] + public int LastProcessedQueueID { get; set; } + + public BeatmapUpdates(int[] beatmapSetIDs, int lastProcessedQueueID) + { + BeatmapSetIDs = beatmapSetIDs; + LastProcessedQueueID = lastProcessedQueueID; + } + } +} diff --git a/osu.Game/Skinning/SkinnableTarget.cs b/osu.Game/Online/Metadata/IMetadataClient.cs similarity index 50% rename from osu.Game/Skinning/SkinnableTarget.cs rename to osu.Game/Online/Metadata/IMetadataClient.cs index 09de8a5d71..ad1e7ebbaf 100644 --- a/osu.Game/Skinning/SkinnableTarget.cs +++ b/osu.Game/Online/Metadata/IMetadataClient.cs @@ -1,11 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -namespace osu.Game.Skinning +using System.Threading.Tasks; + +namespace osu.Game.Online.Metadata { - public enum SkinnableTarget + public interface IMetadataClient { - MainHUDComponents, - SongSelect + Task BeatmapSetsUpdated(BeatmapUpdates updates); } } diff --git a/osu.Game/Online/Metadata/IMetadataServer.cs b/osu.Game/Online/Metadata/IMetadataServer.cs new file mode 100644 index 0000000000..994f60f877 --- /dev/null +++ b/osu.Game/Online/Metadata/IMetadataServer.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; + +namespace osu.Game.Online.Metadata +{ + /// + /// Metadata server is responsible for keeping the osu! client up-to-date with any changes. + /// + public interface IMetadataServer + { + /// + /// Get any changes since a specific point in the queue. + /// Should be used to allow the client to catch up with any changes after being closed or disconnected. + /// + /// The last processed queue ID. + /// + Task GetChangesSince(int queueId); + } +} diff --git a/osu.Game/Online/Metadata/MetadataClient.cs b/osu.Game/Online/Metadata/MetadataClient.cs new file mode 100644 index 0000000000..d4e7540fe7 --- /dev/null +++ b/osu.Game/Online/Metadata/MetadataClient.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Graphics; + +namespace osu.Game.Online.Metadata +{ + public abstract partial class MetadataClient : Component, IMetadataClient, IMetadataServer + { + public abstract Task BeatmapSetsUpdated(BeatmapUpdates updates); + + public abstract Task GetChangesSince(int queueId); + + public Action? ChangedBeatmapSetsArrived; + + protected Task ProcessChanges(int[] beatmapSetIDs) + { + ChangedBeatmapSetsArrived?.Invoke(beatmapSetIDs.Distinct().ToArray()); + return Task.CompletedTask; + } + } +} diff --git a/osu.Game/Online/Metadata/OnlineMetadataClient.cs b/osu.Game/Online/Metadata/OnlineMetadataClient.cs new file mode 100644 index 0000000000..57311419f7 --- /dev/null +++ b/osu.Game/Online/Metadata/OnlineMetadataClient.cs @@ -0,0 +1,125 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Logging; +using osu.Game.Configuration; +using osu.Game.Online.API; + +namespace osu.Game.Online.Metadata +{ + public partial class OnlineMetadataClient : MetadataClient + { + private readonly string endpoint; + + private IHubClientConnector? connector; + + private Bindable lastQueueId = null!; + + private HubConnection? connection => connector?.CurrentConnection; + + public OnlineMetadataClient(EndpointConfiguration endpoints) + { + endpoint = endpoints.MetadataEndpointUrl; + } + + [BackgroundDependencyLoader] + private void load(IAPIProvider api, OsuConfigManager config) + { + // Importantly, we are intentionally not using MessagePack here to correctly support derived class serialization. + // More information on the limitations / reasoning can be found in osu-server-spectator's initialisation code. + connector = api.GetHubConnector(nameof(OnlineMetadataClient), endpoint); + + if (connector != null) + { + connector.ConfigureConnection = connection => + { + // this is kind of SILLY + // https://github.com/dotnet/aspnetcore/issues/15198 + connection.On(nameof(IMetadataClient.BeatmapSetsUpdated), ((IMetadataClient)this).BeatmapSetsUpdated); + }; + + connector.IsConnected.BindValueChanged(isConnectedChanged, true); + } + + lastQueueId = config.GetBindable(OsuSetting.LastProcessedMetadataId); + } + + private bool catchingUp; + + private void isConnectedChanged(ValueChangedEvent connected) + { + if (!connected.NewValue) + return; + + if (lastQueueId.Value >= 0) + { + catchingUp = true; + + Task.Run(async () => + { + try + { + while (true) + { + Logger.Log($"Requesting catch-up from {lastQueueId.Value}"); + var catchUpChanges = await GetChangesSince(lastQueueId.Value).ConfigureAwait(true); + + lastQueueId.Value = catchUpChanges.LastProcessedQueueID; + + if (catchUpChanges.BeatmapSetIDs.Length == 0) + { + Logger.Log($"Catch-up complete at {lastQueueId.Value}"); + break; + } + + await ProcessChanges(catchUpChanges.BeatmapSetIDs).ConfigureAwait(true); + } + } + catch (Exception e) + { + Logger.Log($"Error while processing catch-up of metadata ({e.Message})"); + } + finally + { + catchingUp = false; + } + }); + } + } + + public override async Task BeatmapSetsUpdated(BeatmapUpdates updates) + { + Logger.Log($"Received beatmap updates {updates.BeatmapSetIDs.Length} updates with last id {updates.LastProcessedQueueID}"); + + // If we're still catching up, avoid updating the last ID as it will interfere with catch-up efforts. + if (!catchingUp) + lastQueueId.Value = updates.LastProcessedQueueID; + + await ProcessChanges(updates.BeatmapSetIDs).ConfigureAwait(false); + } + + public override Task GetChangesSince(int queueId) + { + if (connector?.IsConnected.Value != true) + return Task.FromCanceled(default); + + Logger.Log($"Requesting any changes since last known queue id {queueId}"); + + Debug.Assert(connection != null); + + return connection.InvokeAsync(nameof(IMetadataServer.GetChangesSince), queueId); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + connector?.Dispose(); + } + } +} diff --git a/osu.Game/Online/Multiplayer/Countdown/CountdownChangedEvent.cs b/osu.Game/Online/Multiplayer/Countdown/CountdownChangedEvent.cs deleted file mode 100644 index b067f3b235..0000000000 --- a/osu.Game/Online/Multiplayer/Countdown/CountdownChangedEvent.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable enable - -using MessagePack; - -namespace osu.Game.Online.Multiplayer.Countdown -{ - /// - /// Indicates a change to the 's countdown. - /// - [MessagePackObject] - public class CountdownChangedEvent : MatchServerEvent - { - /// - /// The new countdown. - /// - [Key(0)] - public MultiplayerCountdown? Countdown { get; set; } - } -} diff --git a/osu.Game/Online/Multiplayer/Countdown/CountdownStartedEvent.cs b/osu.Game/Online/Multiplayer/Countdown/CountdownStartedEvent.cs new file mode 100644 index 0000000000..1dbb27bce6 --- /dev/null +++ b/osu.Game/Online/Multiplayer/Countdown/CountdownStartedEvent.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using MessagePack; +using Newtonsoft.Json; + +namespace osu.Game.Online.Multiplayer.Countdown +{ + /// + /// Indicates that a countdown started in the . + /// + [MessagePackObject] + public class CountdownStartedEvent : MatchServerEvent + { + /// + /// The countdown that was started. + /// + [Key(0)] + public readonly MultiplayerCountdown Countdown; + + [JsonConstructor] + [SerializationConstructor] + public CountdownStartedEvent(MultiplayerCountdown countdown) + { + Countdown = countdown; + } + } +} diff --git a/osu.Game/Online/Multiplayer/Countdown/CountdownStoppedEvent.cs b/osu.Game/Online/Multiplayer/Countdown/CountdownStoppedEvent.cs new file mode 100644 index 0000000000..b46ed0e5e0 --- /dev/null +++ b/osu.Game/Online/Multiplayer/Countdown/CountdownStoppedEvent.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using MessagePack; +using Newtonsoft.Json; + +namespace osu.Game.Online.Multiplayer.Countdown +{ + /// + /// Indicates that a countdown was stopped in the . + /// + [MessagePackObject] + public class CountdownStoppedEvent : MatchServerEvent + { + /// + /// The identifier of the countdown that was stopped. + /// + [Key(0)] + public readonly int ID; + + [JsonConstructor] + [SerializationConstructor] + public CountdownStoppedEvent(int id) + { + ID = id; + } + } +} diff --git a/osu.Game/Online/Multiplayer/Countdown/StartMatchCountdownRequest.cs b/osu.Game/Online/Multiplayer/Countdown/StartMatchCountdownRequest.cs index 08eab26090..d0de69d3f4 100644 --- a/osu.Game/Online/Multiplayer/Countdown/StartMatchCountdownRequest.cs +++ b/osu.Game/Online/Multiplayer/Countdown/StartMatchCountdownRequest.cs @@ -4,8 +4,6 @@ using System; using MessagePack; -#nullable enable - namespace osu.Game.Online.Multiplayer.Countdown { /// diff --git a/osu.Game/Online/Multiplayer/Countdown/StopCountdownRequest.cs b/osu.Game/Online/Multiplayer/Countdown/StopCountdownRequest.cs index 20a0e32734..495252c044 100644 --- a/osu.Game/Online/Multiplayer/Countdown/StopCountdownRequest.cs +++ b/osu.Game/Online/Multiplayer/Countdown/StopCountdownRequest.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 enable - using MessagePack; +using Newtonsoft.Json; namespace osu.Game.Online.Multiplayer.Countdown { @@ -13,5 +12,14 @@ namespace osu.Game.Online.Multiplayer.Countdown [MessagePackObject] public class StopCountdownRequest : MatchUserRequest { + [Key(0)] + public readonly int ID; + + [JsonConstructor] + [SerializationConstructor] + public StopCountdownRequest(int id) + { + ID = id; + } } } diff --git a/osu.Game/Online/Multiplayer/ForceGameplayStartCountdown.cs b/osu.Game/Online/Multiplayer/ForceGameplayStartCountdown.cs index 4ec5019a07..bbfc5a02c6 100644 --- a/osu.Game/Online/Multiplayer/ForceGameplayStartCountdown.cs +++ b/osu.Game/Online/Multiplayer/ForceGameplayStartCountdown.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using MessagePack; namespace osu.Game.Online.Multiplayer @@ -11,7 +13,7 @@ namespace osu.Game.Online.Multiplayer /// and forcing progression of any clients that are blocking load due to user interaction. /// [MessagePackObject] - public class ForceGameplayStartCountdown : MultiplayerCountdown + public sealed class ForceGameplayStartCountdown : MultiplayerCountdown { } } diff --git a/osu.Game/Online/Multiplayer/IMultiplayerClient.cs b/osu.Game/Online/Multiplayer/IMultiplayerClient.cs index 2f454ea835..995bac1af5 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerClient.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Threading.Tasks; using osu.Game.Online.API; diff --git a/osu.Game/Online/Multiplayer/IMultiplayerLoungeServer.cs b/osu.Game/Online/Multiplayer/IMultiplayerLoungeServer.cs index 0a618c8f5c..68bf3cfaec 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerLoungeServer.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerLoungeServer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Threading.Tasks; namespace osu.Game.Online.Multiplayer diff --git a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs index 073d512f90..a2608f1564 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Threading.Tasks; using osu.Game.Online.API; diff --git a/osu.Game/Online/Multiplayer/IMultiplayerServer.cs b/osu.Game/Online/Multiplayer/IMultiplayerServer.cs index d3a070af6d..cc7a474ce7 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerServer.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerServer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Online.Multiplayer { /// diff --git a/osu.Game/Online/Multiplayer/InvalidPasswordException.cs b/osu.Game/Online/Multiplayer/InvalidPasswordException.cs index 0441aea287..305c41e69b 100644 --- a/osu.Game/Online/Multiplayer/InvalidPasswordException.cs +++ b/osu.Game/Online/Multiplayer/InvalidPasswordException.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Runtime.Serialization; using Microsoft.AspNetCore.SignalR; diff --git a/osu.Game/Online/Multiplayer/InvalidStateChangeException.cs b/osu.Game/Online/Multiplayer/InvalidStateChangeException.cs index 69b6d4bc13..ab513e71ee 100644 --- a/osu.Game/Online/Multiplayer/InvalidStateChangeException.cs +++ b/osu.Game/Online/Multiplayer/InvalidStateChangeException.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Runtime.Serialization; using Microsoft.AspNetCore.SignalR; diff --git a/osu.Game/Online/Multiplayer/InvalidStateException.cs b/osu.Game/Online/Multiplayer/InvalidStateException.cs index 77a3533dd3..ba3b84ffe4 100644 --- a/osu.Game/Online/Multiplayer/InvalidStateException.cs +++ b/osu.Game/Online/Multiplayer/InvalidStateException.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Runtime.Serialization; using Microsoft.AspNetCore.SignalR; diff --git a/osu.Game/Online/Multiplayer/MatchRoomState.cs b/osu.Game/Online/Multiplayer/MatchRoomState.cs index 30d948f878..cae3aaf7d0 100644 --- a/osu.Game/Online/Multiplayer/MatchRoomState.cs +++ b/osu.Game/Online/Multiplayer/MatchRoomState.cs @@ -5,8 +5,6 @@ using System; using MessagePack; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; -#nullable enable - namespace osu.Game.Online.Multiplayer { /// diff --git a/osu.Game/Online/Multiplayer/MatchServerEvent.cs b/osu.Game/Online/Multiplayer/MatchServerEvent.cs index 4ce55e424d..376ff4d261 100644 --- a/osu.Game/Online/Multiplayer/MatchServerEvent.cs +++ b/osu.Game/Online/Multiplayer/MatchServerEvent.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 enable - using System; using MessagePack; using osu.Game.Online.Multiplayer.Countdown; @@ -15,7 +13,8 @@ namespace osu.Game.Online.Multiplayer [Serializable] [MessagePackObject] // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types. - [Union(0, typeof(CountdownChangedEvent))] + [Union(0, typeof(CountdownStartedEvent))] + [Union(1, typeof(CountdownStoppedEvent))] public abstract class MatchServerEvent { } diff --git a/osu.Game/Online/Multiplayer/MatchStartCountdown.cs b/osu.Game/Online/Multiplayer/MatchStartCountdown.cs index 6c1cdd97d3..fe65ebb059 100644 --- a/osu.Game/Online/Multiplayer/MatchStartCountdown.cs +++ b/osu.Game/Online/Multiplayer/MatchStartCountdown.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 enable - using MessagePack; namespace osu.Game.Online.Multiplayer @@ -11,7 +9,7 @@ namespace osu.Game.Online.Multiplayer /// A which will start the match after ending. /// [MessagePackObject] - public class MatchStartCountdown : MultiplayerCountdown + public sealed class MatchStartCountdown : MultiplayerCountdown { } } diff --git a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/ChangeTeamRequest.cs b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/ChangeTeamRequest.cs index a26a2b3fc2..4e425490ec 100644 --- a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/ChangeTeamRequest.cs +++ b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/ChangeTeamRequest.cs @@ -3,8 +3,6 @@ using MessagePack; -#nullable enable - namespace osu.Game.Online.Multiplayer.MatchTypes.TeamVersus { [MessagePackObject] diff --git a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/MultiplayerTeam.cs b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/MultiplayerTeam.cs index f952dbc1b5..4ecdfb882f 100644 --- a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/MultiplayerTeam.cs +++ b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/MultiplayerTeam.cs @@ -4,8 +4,6 @@ using System; using MessagePack; -#nullable enable - namespace osu.Game.Online.Multiplayer.MatchTypes.TeamVersus { [Serializable] diff --git a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusRoomState.cs b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusRoomState.cs index 91d1aa43d4..3758429643 100644 --- a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusRoomState.cs +++ b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusRoomState.cs @@ -4,8 +4,6 @@ using System.Collections.Generic; using MessagePack; -#nullable enable - namespace osu.Game.Online.Multiplayer.MatchTypes.TeamVersus { [MessagePackObject] diff --git a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusUserState.cs b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusUserState.cs index 96a4e2ea99..ac3b9724cc 100644 --- a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusUserState.cs +++ b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusUserState.cs @@ -3,8 +3,6 @@ using MessagePack; -#nullable enable - namespace osu.Game.Online.Multiplayer.MatchTypes.TeamVersus { public class TeamVersusUserState : MatchUserState diff --git a/osu.Game/Online/Multiplayer/MatchUserRequest.cs b/osu.Game/Online/Multiplayer/MatchUserRequest.cs index 888b55e428..7fc1790434 100644 --- a/osu.Game/Online/Multiplayer/MatchUserRequest.cs +++ b/osu.Game/Online/Multiplayer/MatchUserRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using MessagePack; using osu.Game.Online.Multiplayer.Countdown; diff --git a/osu.Game/Online/Multiplayer/MatchUserState.cs b/osu.Game/Online/Multiplayer/MatchUserState.cs index 665b64a8b4..92e8c501ed 100644 --- a/osu.Game/Online/Multiplayer/MatchUserState.cs +++ b/osu.Game/Online/Multiplayer/MatchUserState.cs @@ -5,8 +5,6 @@ using System; using MessagePack; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; -#nullable enable - namespace osu.Game.Online.Multiplayer { /// diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index c95f3fa579..2be7327234 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -1,14 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Newtonsoft.Json; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Development; @@ -20,15 +19,17 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer.Countdown; using osu.Game.Online.Rooms; using osu.Game.Online.Rooms.RoomStatuses; +using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Utils; -using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Online.Multiplayer { - public abstract class MultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer + public abstract partial class MultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer { + public Action? PostNotification { protected get; set; } + /// /// Invoked when any change occurs to the multiplayer room. /// @@ -93,7 +94,7 @@ namespace osu.Game.Online.Multiplayer /// /// The joined . /// - public virtual MultiplayerRoom? Room + public virtual MultiplayerRoom? Room // virtual for moq { get { @@ -152,7 +153,7 @@ namespace osu.Game.Online.Multiplayer // clean up local room state on server disconnect. if (!connected.NewValue && Room != null) { - Logger.Log("Connection to multiplayer server was lost.", LoggingTarget.Runtime, LogLevel.Important); + Logger.Log("Clearing room due to multiplayer server connection loss.", LoggingTarget.Runtime, LogLevel.Important); LeaveRoom(); } })); @@ -199,6 +200,9 @@ namespace osu.Game.Online.Multiplayer APIRoom.Playlist.AddRange(joinedRoom.Playlist.Select(createPlaylistItem)); APIRoom.CurrentPlaylistItem.Value = APIRoom.Playlist.Single(item => item.ID == joinedRoom.Settings.PlaylistItemId); + // The server will null out the end date upon the host joining the room, but the null value is never communicated to the client. + APIRoom.EndDate.Value = null; + Debug.Assert(LocalUser != null); addUserToAPIRoom(LocalUser); @@ -207,6 +211,8 @@ namespace osu.Game.Online.Multiplayer updateLocalRoomSettings(joinedRoom.Settings); + postServerShuttingDownNotification(); + OnRoomJoined(); }, cancellationSource.Token).ConfigureAwait(false); }, cancellationSource.Token).ConfigureAwait(false); @@ -265,8 +271,9 @@ namespace osu.Game.Online.Multiplayer /// The type of the match, if any. /// The new queue mode, if any. /// The new auto-start countdown duration, if any. + /// The new auto-skip setting. public Task ChangeSettings(Optional name = default, Optional password = default, Optional matchType = default, Optional queueMode = default, - Optional autoStartDuration = default) + Optional autoStartDuration = default, Optional autoSkip = default) { if (Room == null) throw new InvalidOperationException("Must be joined to a match to change settings."); @@ -278,6 +285,7 @@ namespace osu.Game.Online.Multiplayer MatchType = matchType.GetOr(Room.Settings.MatchType), QueueMode = queueMode.GetOr(Room.Settings.QueueMode), AutoStartDuration = autoStartDuration.GetOr(Room.Settings.AutoStartDuration), + AutoSkip = autoSkip.GetOr(Room.Settings.AutoSkip) }); } @@ -415,7 +423,7 @@ namespace osu.Game.Online.Multiplayer UserJoined?.Invoke(user); RoomUpdated?.Invoke(); - }); + }, false); } Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user) => @@ -550,8 +558,22 @@ namespace osu.Game.Online.Multiplayer switch (e) { - case CountdownChangedEvent countdownChangedEvent: - Room.Countdown = countdownChangedEvent.Countdown; + case CountdownStartedEvent countdownStartedEvent: + Room.ActiveCountdowns.Add(countdownStartedEvent.Countdown); + + switch (countdownStartedEvent.Countdown) + { + case ServerShuttingDownCountdown: + postServerShuttingDownNotification(); + break; + } + + break; + + case CountdownStoppedEvent countdownStoppedEvent: + MultiplayerCountdown? countdown = Room.ActiveCountdowns.FirstOrDefault(countdown => countdown.ID == countdownStoppedEvent.ID); + if (countdown != null) + Room.ActiveCountdowns.Remove(countdown); break; } @@ -561,6 +583,16 @@ namespace osu.Game.Online.Multiplayer return Task.CompletedTask; } + private void postServerShuttingDownNotification() + { + ServerShuttingDownCountdown? countdown = room?.ActiveCountdowns.OfType().FirstOrDefault(); + + if (countdown == null) + return; + + PostNotification?.Invoke(new ServerShutdownNotification(countdown.TimeRemaining)); + } + Task IMultiplayerClient.UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability) { Scheduler.Add(() => @@ -696,13 +728,20 @@ namespace osu.Game.Online.Multiplayer if (Room == null) return; - Debug.Assert(APIRoom != null); + try + { + Debug.Assert(APIRoom != null); - Room.Playlist[Room.Playlist.IndexOf(Room.Playlist.Single(existing => existing.ID == item.ID))] = item; + Room.Playlist[Room.Playlist.IndexOf(Room.Playlist.Single(existing => existing.ID == item.ID))] = item; - int existingIndex = APIRoom.Playlist.IndexOf(APIRoom.Playlist.Single(existing => existing.ID == item.ID)); - APIRoom.Playlist.RemoveAt(existingIndex); - APIRoom.Playlist.Insert(existingIndex, createPlaylistItem(item)); + int existingIndex = APIRoom.Playlist.IndexOf(APIRoom.Playlist.Single(existing => existing.ID == item.ID)); + APIRoom.Playlist.RemoveAt(existingIndex); + APIRoom.Playlist.Insert(existingIndex, createPlaylistItem(item)); + } + catch (Exception ex) + { + throw new AggregateException($"Item: {JsonConvert.SerializeObject(createPlaylistItem(item))}\n\nRoom:{JsonConvert.SerializeObject(APIRoom)}", ex); + } ItemChanged?.Invoke(item); RoomUpdated?.Invoke(); @@ -739,6 +778,7 @@ namespace osu.Game.Online.Multiplayer APIRoom.QueueMode.Value = Room.Settings.QueueMode; APIRoom.AutoStartDuration.Value = Room.Settings.AutoStartDuration; APIRoom.CurrentPlaylistItem.Value = APIRoom.Playlist.Single(item => item.ID == settings.PlaylistItemId); + APIRoom.AutoSkip.Value = Room.Settings.AutoSkip; RoomUpdated?.Invoke(); } @@ -782,7 +822,7 @@ namespace osu.Game.Online.Multiplayer { if (cancellationToken.IsCancellationRequested) { - tcs.SetCanceled(); + tcs.SetCanceled(cancellationToken); return; } diff --git a/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs b/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs index cda313bd0a..2083aa4e28 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.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 enable - using System; using System.Diagnostics; using System.Threading.Tasks; diff --git a/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs b/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs index dbf2ab667b..c59f5937b0 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerCountdown.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 enable - using System; using MessagePack; using osu.Game.Online.Multiplayer.Countdown; @@ -15,15 +13,28 @@ namespace osu.Game.Online.Multiplayer [MessagePackObject] [Union(0, typeof(MatchStartCountdown))] // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types. [Union(1, typeof(ForceGameplayStartCountdown))] + [Union(2, typeof(ServerShuttingDownCountdown))] public abstract class MultiplayerCountdown { + /// + /// A unique identifier for this countdown. + /// + [Key(0)] + public int ID { get; set; } + /// /// The amount of time remaining in the countdown. /// /// - /// This is only sent once from the server upon initial retrieval of the or via a . + /// This is only sent once from the server upon initial retrieval of the or via a . /// - [Key(0)] + [Key(1)] public TimeSpan TimeRemaining { get; set; } + + /// + /// Whether only a single instance of this type may be active at any one time. + /// + [IgnoreMember] + public virtual bool IsExclusive => true; } } diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs index e215498ff9..00048fa931 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoom.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 enable - using System; using System.Collections.Generic; using MessagePack; @@ -55,10 +53,10 @@ namespace osu.Game.Online.Multiplayer public IList Playlist { get; set; } = new List(); /// - /// The currently-running countdown. + /// The currently running countdowns. /// [Key(7)] - public MultiplayerCountdown? Countdown { get; set; } + public IList ActiveCountdowns { get; set; } = new List(); [JsonConstructor] [SerializationConstructor] diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs index 5c086066e6..c73b02874e 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.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 enable - using System; using MessagePack; using osu.Game.Online.Rooms; @@ -31,6 +29,9 @@ namespace osu.Game.Online.Multiplayer [Key(5)] public TimeSpan AutoStartDuration { get; set; } + [Key(6)] + public bool AutoSkip { get; set; } + [IgnoreMember] public bool AutoStartEnabled => AutoStartDuration != TimeSpan.Zero; @@ -44,7 +45,8 @@ namespace osu.Game.Online.Multiplayer && PlaylistItemId == other.PlaylistItemId && MatchType == other.MatchType && QueueMode == other.QueueMode - && AutoStartDuration == other.AutoStartDuration; + && AutoStartDuration == other.AutoStartDuration + && AutoSkip == other.AutoSkip; } public override string ToString() => $"Name:{Name}" @@ -52,6 +54,7 @@ namespace osu.Game.Online.Multiplayer + $" Type:{MatchType}" + $" Item:{PlaylistItemId}" + $" Queue:{QueueMode}" - + $" Start:{AutoStartDuration}"; + + $" Start:{AutoStartDuration}" + + $" AutoSkip:{AutoSkip}"; } } diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomState.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomState.cs index 48f25d7ca2..8e28d958a7 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomState.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomState.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 enable - namespace osu.Game.Online.Multiplayer { /// diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs index 50e539e8a6..d70a2797c4 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomUser.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 enable - using System; using System.Collections.Generic; using System.Linq; @@ -56,10 +54,10 @@ namespace osu.Game.Online.Multiplayer return UserID == other.UserID; } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) return true; - if (obj.GetType() != GetType()) return false; + if (obj?.GetType() != GetType()) return false; return Equals((MultiplayerRoomUser)obj); } diff --git a/osu.Game/Online/Multiplayer/MultiplayerUserState.cs b/osu.Game/Online/Multiplayer/MultiplayerUserState.cs index d1369a7970..0f7dc6b8cd 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerUserState.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerUserState.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Online.Multiplayer { public enum MultiplayerUserState diff --git a/osu.Game/Online/Multiplayer/NotHostException.cs b/osu.Game/Online/Multiplayer/NotHostException.cs index 051cde45a0..9f789f1e81 100644 --- a/osu.Game/Online/Multiplayer/NotHostException.cs +++ b/osu.Game/Online/Multiplayer/NotHostException.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Runtime.Serialization; using Microsoft.AspNetCore.SignalR; diff --git a/osu.Game/Online/Multiplayer/NotJoinedRoomException.cs b/osu.Game/Online/Multiplayer/NotJoinedRoomException.cs index 0e9902f002..c749e4615a 100644 --- a/osu.Game/Online/Multiplayer/NotJoinedRoomException.cs +++ b/osu.Game/Online/Multiplayer/NotJoinedRoomException.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Runtime.Serialization; using Microsoft.AspNetCore.SignalR; diff --git a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs index a3423d4189..8ff0ce4065 100644 --- a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.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 enable - using System; using System.Collections.Generic; using System.Diagnostics; @@ -20,7 +18,7 @@ namespace osu.Game.Online.Multiplayer /// /// A with online connectivity. /// - public class OnlineMultiplayerClient : MultiplayerClient + public partial class OnlineMultiplayerClient : MultiplayerClient { private readonly string endpoint; @@ -82,12 +80,18 @@ namespace osu.Game.Online.Multiplayer try { - return await connection.InvokeAsync(nameof(IMultiplayerServer.JoinRoomWithPassword), roomId, password ?? string.Empty); + return await connection.InvokeAsync(nameof(IMultiplayerServer.JoinRoomWithPassword), roomId, password ?? string.Empty).ConfigureAwait(false); } catch (HubException exception) { if (exception.GetHubExceptionMessage() == HubClientConnector.SERVER_SHUTDOWN_MESSAGE) - connector?.Reconnect(); + { + Debug.Assert(connector != null); + + await connector.Reconnect().ConfigureAwait(false); + return await JoinRoom(roomId, password).ConfigureAwait(false); + } + throw; } } diff --git a/osu.Game/Online/Multiplayer/QueueMode.cs b/osu.Game/Online/Multiplayer/QueueMode.cs index 3d113c028e..a7bc4ae00a 100644 --- a/osu.Game/Online/Multiplayer/QueueMode.cs +++ b/osu.Game/Online/Multiplayer/QueueMode.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; namespace osu.Game.Online.Multiplayer diff --git a/osu.Game/Online/Multiplayer/ServerShutdownNotification.cs b/osu.Game/Online/Multiplayer/ServerShutdownNotification.cs new file mode 100644 index 0000000000..1de18e44a7 --- /dev/null +++ b/osu.Game/Online/Multiplayer/ServerShutdownNotification.cs @@ -0,0 +1,62 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using Humanizer.Localisation; +using osu.Framework.Allocation; +using osu.Framework.Threading; +using osu.Game.Overlays.Notifications; +using osu.Game.Utils; + +namespace osu.Game.Online.Multiplayer +{ + public partial class ServerShutdownNotification : SimpleNotification + { + private readonly DateTimeOffset endDate; + private ScheduledDelegate? updateDelegate; + + public ServerShutdownNotification(TimeSpan duration) + { + endDate = DateTimeOffset.UtcNow + duration; + } + + [BackgroundDependencyLoader] + private void load() + { + updateTime(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateDelegate = Scheduler.Add(updateTimeWithReschedule); + } + + private void updateTimeWithReschedule() + { + updateTime(); + + // The remaining time on a countdown may be at a fractional portion between two seconds. + // We want to align certain audio/visual cues to the point at which integer seconds change. + // To do so, we schedule to the next whole second. Note that scheduler invocation isn't + // guaranteed to be accurate, so this may still occur slightly late, but even in such a case + // the next invocation will be roughly correct. + double timeToNextSecond = endDate.Subtract(DateTimeOffset.UtcNow).TotalMilliseconds % 1000; + + updateDelegate = Scheduler.AddDelayed(updateTimeWithReschedule, timeToNextSecond); + } + + private void updateTime() + { + TimeSpan remaining = endDate.Subtract(DateTimeOffset.Now); + + if (remaining.TotalSeconds <= 5) + { + updateDelegate?.Cancel(); + Text = "The multiplayer server will be right back..."; + } + else + Text = $"The multiplayer server is restarting in {HumanizerUtils.Humanize(remaining, precision: 3, minUnit: TimeUnit.Second)}."; + } + } +} diff --git a/osu.Game/Online/Multiplayer/ServerShuttingDownCountdown.cs b/osu.Game/Online/Multiplayer/ServerShuttingDownCountdown.cs new file mode 100644 index 0000000000..b0a45dc768 --- /dev/null +++ b/osu.Game/Online/Multiplayer/ServerShuttingDownCountdown.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using MessagePack; + +namespace osu.Game.Online.Multiplayer +{ + /// + /// A countdown that indicates the current multiplayer server is shutting down. + /// + [MessagePackObject] + public class ServerShuttingDownCountdown : MultiplayerCountdown + { + } +} diff --git a/osu.Game/Online/Notifications/NotificationsClient.cs b/osu.Game/Online/Notifications/NotificationsClient.cs new file mode 100644 index 0000000000..5762e0e588 --- /dev/null +++ b/osu.Game/Online/Notifications/NotificationsClient.cs @@ -0,0 +1,79 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.Chat; + +namespace osu.Game.Online.Notifications +{ + /// + /// An abstract client which receives notification-related events (chat/notifications). + /// + public abstract class NotificationsClient : PersistentEndpointClient + { + public Action? ChannelJoined; + public Action? ChannelParted; + public Action>? NewMessages; + public Action? PresenceReceived; + + protected readonly IAPIProvider API; + + private long lastMessageId; + + protected NotificationsClient(IAPIProvider api) + { + API = api; + } + + public override Task ConnectAsync(CancellationToken cancellationToken) + { + API.Queue(CreateInitialFetchRequest(0)); + return Task.CompletedTask; + } + + protected APIRequest CreateInitialFetchRequest(long? lastMessageId = null) + { + var fetchReq = new GetUpdatesRequest(lastMessageId ?? this.lastMessageId); + + fetchReq.Success += updates => + { + if (updates?.Presence != null) + { + foreach (var channel in updates.Presence) + HandleChannelJoined(channel); + + //todo: handle left channels + + HandleMessages(updates.Messages); + } + + PresenceReceived?.Invoke(); + }; + + return fetchReq; + } + + protected void HandleChannelJoined(Channel channel) + { + channel.Joined.Value = true; + ChannelJoined?.Invoke(channel); + } + + protected void HandleChannelParted(Channel channel) => ChannelParted?.Invoke(channel); + + protected void HandleMessages(List? messages) + { + if (messages == null) + return; + + NewMessages?.Invoke(messages); + lastMessageId = Math.Max(lastMessageId, messages.LastOrDefault()?.Id ?? 0); + } + } +} diff --git a/osu.Game/Online/Notifications/NotificationsClientConnector.cs b/osu.Game/Online/Notifications/NotificationsClientConnector.cs new file mode 100644 index 0000000000..34ce186cb8 --- /dev/null +++ b/osu.Game/Online/Notifications/NotificationsClientConnector.cs @@ -0,0 +1,42 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using osu.Game.Online.API; +using osu.Game.Online.Chat; + +namespace osu.Game.Online.Notifications +{ + /// + /// An abstract connector or s. + /// + public abstract class NotificationsClientConnector : PersistentEndpointClientConnector + { + public event Action? ChannelJoined; + public event Action? ChannelParted; + public event Action>? NewMessages; + public event Action? PresenceReceived; + + protected NotificationsClientConnector(IAPIProvider api) + : base(api) + { + } + + protected sealed override async Task BuildConnectionAsync(CancellationToken cancellationToken) + { + var client = await BuildNotificationClientAsync(cancellationToken).ConfigureAwait(false); + + client.ChannelJoined = c => ChannelJoined?.Invoke(c); + client.ChannelParted = c => ChannelParted?.Invoke(c); + client.NewMessages = m => NewMessages?.Invoke(m); + client.PresenceReceived = () => PresenceReceived?.Invoke(); + + return client; + } + + protected abstract Task BuildNotificationClientAsync(CancellationToken cancellationToken); + } +} diff --git a/osu.Game/Online/Notifications/WebSocket/EndChatRequest.cs b/osu.Game/Online/Notifications/WebSocket/EndChatRequest.cs new file mode 100644 index 0000000000..7f67587f5d --- /dev/null +++ b/osu.Game/Online/Notifications/WebSocket/EndChatRequest.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 Newtonsoft.Json; + +namespace osu.Game.Online.Notifications.WebSocket +{ + /// + /// A websocket message notifying the server that the client no longer wants to receive chat messages. + /// + [JsonObject(MemberSerialization.OptIn)] + public class EndChatRequest : SocketMessage + { + public EndChatRequest() + { + Event = @"chat.end"; + } + } +} diff --git a/osu.Game/Online/Notifications/WebSocket/NewChatMessageData.cs b/osu.Game/Online/Notifications/WebSocket/NewChatMessageData.cs new file mode 100644 index 0000000000..850fbd226b --- /dev/null +++ b/osu.Game/Online/Notifications/WebSocket/NewChatMessageData.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; +using Newtonsoft.Json; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; + +namespace osu.Game.Online.Notifications.WebSocket +{ + /// + /// A websocket message sent from the server when new messages arrive. + /// + [JsonObject(MemberSerialization.OptIn)] + public class NewChatMessageData + { + [JsonProperty(@"messages")] + public List Messages { get; set; } = null!; + + [JsonProperty(@"users")] + private List users { get; set; } = null!; + + [OnDeserialized] + private void onDeserialised(StreamingContext context) + { + foreach (var m in Messages) + m.Sender = users.Single(u => u.OnlineID == m.SenderId); + } + } +} diff --git a/osu.Game/Online/Notifications/WebSocket/SocketMessage.cs b/osu.Game/Online/Notifications/WebSocket/SocketMessage.cs new file mode 100644 index 0000000000..666a9dd8a3 --- /dev/null +++ b/osu.Game/Online/Notifications/WebSocket/SocketMessage.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace osu.Game.Online.Notifications.WebSocket +{ + /// + /// A websocket message, sent either from the client or server. + /// + [JsonObject(MemberSerialization.OptIn)] + public class SocketMessage + { + [JsonProperty(@"event")] + public string Event { get; set; } = null!; + + [JsonProperty(@"data")] + public JObject? Data { get; set; } + + [JsonProperty(@"error")] + public string? Error { get; set; } + } +} diff --git a/osu.Game/Online/Notifications/WebSocket/StartChatRequest.cs b/osu.Game/Online/Notifications/WebSocket/StartChatRequest.cs new file mode 100644 index 0000000000..9dd69a7377 --- /dev/null +++ b/osu.Game/Online/Notifications/WebSocket/StartChatRequest.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 Newtonsoft.Json; + +namespace osu.Game.Online.Notifications.WebSocket +{ + /// + /// A websocket message notifying the server that the client wants to receive chat messages. + /// + [JsonObject(MemberSerialization.OptIn)] + public class StartChatRequest : SocketMessage + { + public StartChatRequest() + { + Event = @"chat.start"; + } + } +} diff --git a/osu.Game/Online/Notifications/WebSocket/WebSocketNotificationsClient.cs b/osu.Game/Online/Notifications/WebSocket/WebSocketNotificationsClient.cs new file mode 100644 index 0000000000..73e5dcec6f --- /dev/null +++ b/osu.Game/Online/Notifications/WebSocket/WebSocketNotificationsClient.cs @@ -0,0 +1,180 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using osu.Framework.Extensions.TypeExtensions; +using osu.Framework.Logging; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.Chat; + +namespace osu.Game.Online.Notifications.WebSocket +{ + /// + /// A notifications client which receives events via a websocket. + /// + public class WebSocketNotificationsClient : NotificationsClient + { + private readonly ClientWebSocket socket; + private readonly string endpoint; + private readonly ConcurrentDictionary channelsMap = new ConcurrentDictionary(); + + public WebSocketNotificationsClient(ClientWebSocket socket, string endpoint, IAPIProvider api) + : base(api) + { + this.socket = socket; + this.endpoint = endpoint; + } + + public override async Task ConnectAsync(CancellationToken cancellationToken) + { + await socket.ConnectAsync(new Uri(endpoint), cancellationToken).ConfigureAwait(false); + await sendMessage(new StartChatRequest(), CancellationToken.None).ConfigureAwait(false); + + runReadLoop(cancellationToken); + + await base.ConnectAsync(cancellationToken).ConfigureAwait(false); + } + + private void runReadLoop(CancellationToken cancellationToken) => Task.Run(async () => + { + byte[] buffer = new byte[1024]; + StringBuilder messageResult = new StringBuilder(); + + while (!cancellationToken.IsCancellationRequested) + { + try + { + WebSocketReceiveResult result = await socket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false); + + switch (result.MessageType) + { + case WebSocketMessageType.Text: + messageResult.Append(Encoding.UTF8.GetString(buffer[..result.Count])); + + if (result.EndOfMessage) + { + SocketMessage? message = JsonConvert.DeserializeObject(messageResult.ToString()); + messageResult.Clear(); + + Debug.Assert(message != null); + + if (message.Error != null) + { + Logger.Log($"{GetType().ReadableName()} error: {message.Error}", LoggingTarget.Network); + break; + } + + await onMessageReceivedAsync(message).ConfigureAwait(false); + } + + break; + + case WebSocketMessageType.Binary: + throw new NotImplementedException("Binary message type not supported."); + + case WebSocketMessageType.Close: + throw new WebException("Connection closed by remote host."); + } + } + catch (Exception ex) + { + await InvokeClosed(ex).ConfigureAwait(false); + return; + } + } + }, cancellationToken); + + private async Task closeAsync() + { + try + { + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, @"Disconnecting", CancellationToken.None).ConfigureAwait(false); + } + catch + { + // Closure can fail if the connection is aborted. Don't really care since it's disposed anyway. + } + } + + private async Task sendMessage(SocketMessage message, CancellationToken cancellationToken) + { + if (socket.State != WebSocketState.Open) + return; + + await socket.SendAsync(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(message)), WebSocketMessageType.Text, true, cancellationToken).ConfigureAwait(false); + } + + private async Task onMessageReceivedAsync(SocketMessage message) + { + switch (message.Event) + { + case @"chat.channel.join": + Debug.Assert(message.Data != null); + + Channel? joinedChannel = JsonConvert.DeserializeObject(message.Data.ToString()); + Debug.Assert(joinedChannel != null); + + HandleChannelJoined(joinedChannel); + break; + + case @"chat.channel.part": + Debug.Assert(message.Data != null); + + Channel? partedChannel = JsonConvert.DeserializeObject(message.Data.ToString()); + Debug.Assert(partedChannel != null); + + HandleChannelParted(partedChannel); + break; + + case @"chat.message.new": + Debug.Assert(message.Data != null); + + NewChatMessageData? messageData = JsonConvert.DeserializeObject(message.Data.ToString()); + Debug.Assert(messageData != null); + + foreach (var msg in messageData.Messages) + HandleChannelJoined(await getChannel(msg.ChannelId).ConfigureAwait(false)); + + HandleMessages(messageData.Messages); + break; + } + } + + private async Task getChannel(long channelId) + { + if (channelsMap.TryGetValue(channelId, out Channel? channel)) + return channel; + + var tsc = new TaskCompletionSource(); + var req = new GetChannelRequest(channelId); + + req.Success += response => + { + channelsMap[channelId] = response.Channel; + tsc.SetResult(response.Channel); + }; + + req.Failure += ex => tsc.SetException(ex); + + API.Queue(req); + + return await tsc.Task.ConfigureAwait(false); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync().ConfigureAwait(false); + await closeAsync().ConfigureAwait(false); + socket.Dispose(); + } + } +} diff --git a/osu.Game/Online/Notifications/WebSocket/WebSocketNotificationsClientConnector.cs b/osu.Game/Online/Notifications/WebSocket/WebSocketNotificationsClientConnector.cs new file mode 100644 index 0000000000..f50369a06c --- /dev/null +++ b/osu.Game/Online/Notifications/WebSocket/WebSocketNotificationsClientConnector.cs @@ -0,0 +1,46 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; + +namespace osu.Game.Online.Notifications.WebSocket +{ + /// + /// A connector for s that receive events via a websocket. + /// + public class WebSocketNotificationsClientConnector : NotificationsClientConnector + { + private readonly IAPIProvider api; + + public WebSocketNotificationsClientConnector(IAPIProvider api) + : base(api) + { + this.api = api; + } + + protected override async Task BuildNotificationClientAsync(CancellationToken cancellationToken) + { + var tcs = new TaskCompletionSource(); + + var req = new GetNotificationsRequest(); + req.Success += bundle => tcs.SetResult(bundle.Endpoint); + req.Failure += ex => tcs.SetException(ex); + api.Queue(req); + + string endpoint = await tcs.Task.ConfigureAwait(false); + + ClientWebSocket socket = new ClientWebSocket(); + socket.Options.SetRequestHeader(@"Authorization", @$"Bearer {api.AccessToken}"); + socket.Options.Proxy = WebRequest.DefaultWebProxy; + if (socket.Options.Proxy != null) + socket.Options.Proxy.Credentials = CredentialCache.DefaultCredentials; + + return new WebSocketNotificationsClient(socket, endpoint, api); + } + } +} diff --git a/osu.Game/Online/OnlineViewContainer.cs b/osu.Game/Online/OnlineViewContainer.cs index 4955aa9058..46f64fbb61 100644 --- a/osu.Game/Online/OnlineViewContainer.cs +++ b/osu.Game/Online/OnlineViewContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -15,7 +17,7 @@ namespace osu.Game.Online /// A for displaying online content which require a local user to be logged in. /// Shows its children only when the local user is logged in and supports displaying a placeholder if not. /// - public class OnlineViewContainer : Container + public partial class OnlineViewContainer : Container { protected LoadingSpinner LoadingSpinner { get; private set; } diff --git a/osu.Game/Online/PersistentEndpointClient.cs b/osu.Game/Online/PersistentEndpointClient.cs new file mode 100644 index 0000000000..32c243fbbb --- /dev/null +++ b/osu.Game/Online/PersistentEndpointClient.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace osu.Game.Online +{ + public abstract class PersistentEndpointClient : IAsyncDisposable + { + /// + /// An event notifying the that the connection has been closed + /// + public event Func? Closed; + + /// + /// Notifies the that the connection has been closed. + /// + /// The exception that the connection closed with. + protected Task InvokeClosed(Exception? exception) => Closed?.Invoke(exception) ?? Task.CompletedTask; + + /// + /// Connects the client to the remote service to begin processing messages. + /// + /// A cancellation token to stop processing messages. + public abstract Task ConnectAsync(CancellationToken cancellationToken); + + public virtual ValueTask DisposeAsync() + { + Closed = null; + return new ValueTask(Task.CompletedTask); + } + } +} diff --git a/osu.Game/Online/PersistentEndpointClientConnector.cs b/osu.Game/Online/PersistentEndpointClientConnector.cs new file mode 100644 index 0000000000..e33924047d --- /dev/null +++ b/osu.Game/Online/PersistentEndpointClientConnector.cs @@ -0,0 +1,216 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Bindables; +using osu.Framework.Extensions.TypeExtensions; +using osu.Framework.Logging; +using osu.Game.Online.API; + +namespace osu.Game.Online +{ + public abstract class PersistentEndpointClientConnector : IDisposable + { + /// + /// Whether the managed connection is currently connected. When true use to access the connection. + /// + public IBindable IsConnected => isConnected; + + /// + /// The current connection opened by this connector. + /// + public PersistentEndpointClient? CurrentConnection { get; private set; } + + protected readonly IAPIProvider API; + + private readonly IBindable apiState = new Bindable(); + private readonly Bindable isConnected = new Bindable(); + private readonly SemaphoreSlim connectionLock = new SemaphoreSlim(1); + private CancellationTokenSource connectCancelSource = new CancellationTokenSource(); + private bool started; + + /// + /// Constructs a new . + /// + /// An API provider used to react to connection state changes. + protected PersistentEndpointClientConnector(IAPIProvider api) + { + API = api; + apiState.BindTo(api.State); + } + + /// + /// Attempts to connect and begins processing messages from the remote endpoint. + /// + public void Start() + { + if (started) + return; + + apiState.BindValueChanged(_ => Task.Run(connectIfPossible), true); + started = true; + } + + public Task Reconnect() + { + Logger.Log($"{ClientName} reconnecting...", LoggingTarget.Network); + return Task.Run(connectIfPossible); + } + + private async Task connectIfPossible() + { + switch (apiState.Value) + { + case APIState.Failing: + case APIState.Offline: + await disconnect(true).ConfigureAwait(true); + break; + + case APIState.Online: + await connect().ConfigureAwait(true); + break; + } + } + + private async Task connect() + { + cancelExistingConnect(); + + if (!await connectionLock.WaitAsync(10000).ConfigureAwait(false)) + throw new TimeoutException("Could not obtain a lock to connect. A previous attempt is likely stuck."); + + try + { + while (apiState.Value == APIState.Online) + { + // ensure any previous connection was disposed. + // this will also create a new cancellation token source. + await disconnect(false).ConfigureAwait(false); + + // this token will be valid for the scope of this connection. + // if cancelled, we can be sure that a disconnect or reconnect is handled elsewhere. + var cancellationToken = connectCancelSource.Token; + + cancellationToken.ThrowIfCancellationRequested(); + + Logger.Log($"{ClientName} connecting...", LoggingTarget.Network); + + try + { + // importantly, rebuild the connection each attempt to get an updated access token. + CurrentConnection = await BuildConnectionAsync(cancellationToken).ConfigureAwait(false); + CurrentConnection.Closed += ex => onConnectionClosed(ex, cancellationToken); + + cancellationToken.ThrowIfCancellationRequested(); + + await CurrentConnection.ConnectAsync(cancellationToken).ConfigureAwait(false); + + Logger.Log($"{ClientName} connected!", LoggingTarget.Network); + isConnected.Value = true; + return; + } + catch (OperationCanceledException) + { + //connection process was cancelled. + throw; + } + catch (Exception e) + { + await handleErrorAndDelay(e, cancellationToken).ConfigureAwait(false); + } + } + } + finally + { + connectionLock.Release(); + } + } + + /// + /// Handles an exception and delays an async flow. + /// + private async Task handleErrorAndDelay(Exception exception, CancellationToken cancellationToken) + { + Logger.Log($"{ClientName} connect attempt failed: {exception.Message}", LoggingTarget.Network); + await Task.Delay(5000, cancellationToken).ConfigureAwait(false); + } + + /// + /// Creates a new . + /// + /// A cancellation token to stop the process. + protected abstract Task BuildConnectionAsync(CancellationToken cancellationToken); + + private async Task onConnectionClosed(Exception? ex, CancellationToken cancellationToken) + { + bool hasBeenCancelled = cancellationToken.IsCancellationRequested; + + await disconnect(true).ConfigureAwait(false); + + if (ex != null) + await handleErrorAndDelay(ex, CancellationToken.None).ConfigureAwait(false); + else + Logger.Log($"{ClientName} disconnected", LoggingTarget.Network); + + // make sure a disconnect wasn't triggered (and this is still the active connection). + if (!hasBeenCancelled) + await Task.Run(connect, default).ConfigureAwait(false); + } + + private async Task disconnect(bool takeLock) + { + cancelExistingConnect(); + + if (takeLock) + { + if (!await connectionLock.WaitAsync(10000).ConfigureAwait(false)) + throw new TimeoutException("Could not obtain a lock to disconnect. A previous attempt is likely stuck."); + } + + try + { + if (CurrentConnection != null) + await CurrentConnection.DisposeAsync().ConfigureAwait(false); + } + finally + { + isConnected.Value = false; + CurrentConnection = null; + + if (takeLock) + connectionLock.Release(); + } + } + + private void cancelExistingConnect() + { + connectCancelSource.Cancel(); + connectCancelSource = new CancellationTokenSource(); + } + + protected virtual string ClientName => GetType().ReadableName(); + + public override string ToString() => $"{ClientName} ({(IsConnected.Value ? "connected" : "not connected")})"; + + private bool isDisposed; + + protected virtual void Dispose(bool isDisposing) + { + if (isDisposed) + return; + + apiState.UnbindAll(); + cancelExistingConnect(); + + isDisposed = true; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + } +} diff --git a/osu.Game/Online/Placeholders/ClickablePlaceholder.cs b/osu.Game/Online/Placeholders/ClickablePlaceholder.cs index 054a4a3c39..9bef1d4b7a 100644 --- a/osu.Game/Online/Placeholders/ClickablePlaceholder.cs +++ b/osu.Game/Online/Placeholders/ClickablePlaceholder.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; @@ -9,7 +11,7 @@ using osu.Game.Graphics.UserInterface; namespace osu.Game.Online.Placeholders { - public class ClickablePlaceholder : Placeholder + public partial class ClickablePlaceholder : Placeholder { public Action Action; diff --git a/osu.Game/Online/Placeholders/LoginPlaceholder.cs b/osu.Game/Online/Placeholders/LoginPlaceholder.cs index d03b3d8ffc..de7ac6e936 100644 --- a/osu.Game/Online/Placeholders/LoginPlaceholder.cs +++ b/osu.Game/Online/Placeholders/LoginPlaceholder.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; @@ -8,7 +10,7 @@ using osu.Game.Overlays; namespace osu.Game.Online.Placeholders { - public sealed class LoginPlaceholder : ClickablePlaceholder + public sealed partial class LoginPlaceholder : ClickablePlaceholder { [Resolved(CanBeNull = true)] private LoginOverlay login { get; set; } diff --git a/osu.Game/Online/Placeholders/MessagePlaceholder.cs b/osu.Game/Online/Placeholders/MessagePlaceholder.cs index 1676ba6cf2..07a111a10f 100644 --- a/osu.Game/Online/Placeholders/MessagePlaceholder.cs +++ b/osu.Game/Online/Placeholders/MessagePlaceholder.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; namespace osu.Game.Online.Placeholders { - public class MessagePlaceholder : Placeholder + public partial class MessagePlaceholder : Placeholder { private readonly LocalisableString message; diff --git a/osu.Game/Online/Placeholders/Placeholder.cs b/osu.Game/Online/Placeholders/Placeholder.cs index f58282bbd9..37669fb899 100644 --- a/osu.Game/Online/Placeholders/Placeholder.cs +++ b/osu.Game/Online/Placeholders/Placeholder.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics; using osu.Game.Graphics.Containers; namespace osu.Game.Online.Placeholders { - public abstract class Placeholder : OsuTextFlowContainer, IEquatable + public abstract partial class Placeholder : OsuTextFlowContainer, IEquatable { protected const float TEXT_SIZE = 22; diff --git a/osu.Game/Online/PollingComponent.cs b/osu.Game/Online/PollingComponent.cs index 5eddb3b49d..a44308c726 100644 --- a/osu.Game/Online/PollingComponent.cs +++ b/osu.Game/Online/PollingComponent.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using System.Threading.Tasks; @@ -14,7 +16,7 @@ namespace osu.Game.Online /// /// A component which requires a constant polling process. /// - public abstract class PollingComponent : CompositeDrawable // switch away from Component because InternalChildren are used in usages. + public abstract partial class PollingComponent : CompositeComponent { private double? lastTimePolled; diff --git a/osu.Game/Online/ProductionEndpointConfiguration.cs b/osu.Game/Online/ProductionEndpointConfiguration.cs index e44dad1db5..0244761b65 100644 --- a/osu.Game/Online/ProductionEndpointConfiguration.cs +++ b/osu.Game/Online/ProductionEndpointConfiguration.cs @@ -10,8 +10,9 @@ namespace osu.Game.Online WebsiteRootUrl = APIEndpointUrl = @"https://osu.ppy.sh"; APIClientSecret = @"FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk"; APIClientID = "5"; - SpectatorEndpointUrl = "https://spectator2.ppy.sh/spectator"; - MultiplayerEndpointUrl = "https://spectator2.ppy.sh/multiplayer"; + SpectatorEndpointUrl = "https://spectator.ppy.sh/spectator"; + MultiplayerEndpointUrl = "https://spectator.ppy.sh/multiplayer"; + MetadataEndpointUrl = "https://spectator.ppy.sh/metadata"; } } } diff --git a/osu.Game/Online/Rooms/APICreatedRoom.cs b/osu.Game/Online/Rooms/APICreatedRoom.cs index d1062b2306..254a338a60 100644 --- a/osu.Game/Online/Rooms/APICreatedRoom.cs +++ b/osu.Game/Online/Rooms/APICreatedRoom.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Newtonsoft.Json; namespace osu.Game.Online.Rooms { - public class APICreatedRoom : Room + public partial class APICreatedRoom : Room { [JsonProperty("error")] public string Error { get; set; } diff --git a/osu.Game/Online/Rooms/APILeaderboard.cs b/osu.Game/Online/Rooms/APILeaderboard.cs index c487123906..170cf3684b 100644 --- a/osu.Game/Online/Rooms/APILeaderboard.cs +++ b/osu.Game/Online/Rooms/APILeaderboard.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using Newtonsoft.Json; using osu.Game.Online.API.Requests.Responses; diff --git a/osu.Game/Online/Rooms/APIScoreToken.cs b/osu.Game/Online/Rooms/APIScoreToken.cs index 6b559876de..58a633f3cf 100644 --- a/osu.Game/Online/Rooms/APIScoreToken.cs +++ b/osu.Game/Online/Rooms/APIScoreToken.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Newtonsoft.Json; namespace osu.Game.Online.Rooms diff --git a/osu.Game/Online/Rooms/BeatmapAvailability.cs b/osu.Game/Online/Rooms/BeatmapAvailability.cs index a83327aad5..f2b981c075 100644 --- a/osu.Game/Online/Rooms/BeatmapAvailability.cs +++ b/osu.Game/Online/Rooms/BeatmapAvailability.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using MessagePack; using Newtonsoft.Json; diff --git a/osu.Game/Online/Rooms/CreateRoomRequest.cs b/osu.Game/Online/Rooms/CreateRoomRequest.cs index f058eb9ba8..b22780490b 100644 --- a/osu.Game/Online/Rooms/CreateRoomRequest.cs +++ b/osu.Game/Online/Rooms/CreateRoomRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Net.Http; using Newtonsoft.Json; using osu.Framework.IO.Network; diff --git a/osu.Game/Online/Rooms/CreateRoomScoreRequest.cs b/osu.Game/Online/Rooms/CreateRoomScoreRequest.cs index d4303e77df..65731b2b68 100644 --- a/osu.Game/Online/Rooms/CreateRoomScoreRequest.cs +++ b/osu.Game/Online/Rooms/CreateRoomScoreRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Net.Http; using osu.Framework.IO.Network; using osu.Game.Online.API; diff --git a/osu.Game/Online/Rooms/GetRoomLeaderboardRequest.cs b/osu.Game/Online/Rooms/GetRoomLeaderboardRequest.cs index 67e2a2b27f..6b5ed2d024 100644 --- a/osu.Game/Online/Rooms/GetRoomLeaderboardRequest.cs +++ b/osu.Game/Online/Rooms/GetRoomLeaderboardRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Online.API; namespace osu.Game.Online.Rooms diff --git a/osu.Game/Online/Rooms/GetRoomRequest.cs b/osu.Game/Online/Rooms/GetRoomRequest.cs index 853873901e..237d427509 100644 --- a/osu.Game/Online/Rooms/GetRoomRequest.cs +++ b/osu.Game/Online/Rooms/GetRoomRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Online.API; namespace osu.Game.Online.Rooms diff --git a/osu.Game/Online/Rooms/GetRoomsRequest.cs b/osu.Game/Online/Rooms/GetRoomsRequest.cs index e45365797a..afab83b5be 100644 --- a/osu.Game/Online/Rooms/GetRoomsRequest.cs +++ b/osu.Game/Online/Rooms/GetRoomsRequest.cs @@ -1,9 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; -using Humanizer; using osu.Framework.IO.Network; +using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Screens.OnlinePlay.Lounge.Components; @@ -25,7 +27,7 @@ namespace osu.Game.Online.Rooms var req = base.CreateWebRequest(); if (status != RoomStatusFilter.Open) - req.AddParameter("mode", status.ToString().Underscore().ToLowerInvariant()); + req.AddParameter("mode", status.ToString().ToSnakeCase().ToLowerInvariant()); if (!string.IsNullOrEmpty(category)) req.AddParameter("category", category); diff --git a/osu.Game/Online/Rooms/IndexPlaylistScoresRequest.cs b/osu.Game/Online/Rooms/IndexPlaylistScoresRequest.cs index bd9f254e1a..1e50155623 100644 --- a/osu.Game/Online/Rooms/IndexPlaylistScoresRequest.cs +++ b/osu.Game/Online/Rooms/IndexPlaylistScoresRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Diagnostics; using JetBrains.Annotations; using osu.Framework.IO.Network; diff --git a/osu.Game/Online/Rooms/IndexScoresParams.cs b/osu.Game/Online/Rooms/IndexScoresParams.cs index 3df8c8e753..253caa13a1 100644 --- a/osu.Game/Online/Rooms/IndexScoresParams.cs +++ b/osu.Game/Online/Rooms/IndexScoresParams.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using JetBrains.Annotations; using Newtonsoft.Json; diff --git a/osu.Game/Online/Rooms/IndexedMultiplayerScores.cs b/osu.Game/Online/Rooms/IndexedMultiplayerScores.cs index 2008d1aa52..59cba2340d 100644 --- a/osu.Game/Online/Rooms/IndexedMultiplayerScores.cs +++ b/osu.Game/Online/Rooms/IndexedMultiplayerScores.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using Newtonsoft.Json; @@ -15,7 +17,7 @@ namespace osu.Game.Online.Rooms /// The total scores in the playlist item. /// [JsonProperty("total")] - public int? TotalScores { get; set; } + public long? TotalScores { get; set; } /// /// The user's score, if any. diff --git a/osu.Game/Online/Rooms/ItemAttemptsCount.cs b/osu.Game/Online/Rooms/ItemAttemptsCount.cs index 298603d778..71f50b9898 100644 --- a/osu.Game/Online/Rooms/ItemAttemptsCount.cs +++ b/osu.Game/Online/Rooms/ItemAttemptsCount.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Newtonsoft.Json; namespace osu.Game.Online.Rooms diff --git a/osu.Game/Online/Rooms/JoinRoomRequest.cs b/osu.Game/Online/Rooms/JoinRoomRequest.cs index 2a3480c992..0a687312e7 100644 --- a/osu.Game/Online/Rooms/JoinRoomRequest.cs +++ b/osu.Game/Online/Rooms/JoinRoomRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Net.Http; using osu.Framework.IO.Network; using osu.Game.Online.API; diff --git a/osu.Game/Online/Rooms/MatchType.cs b/osu.Game/Online/Rooms/MatchType.cs index 278f0693eb..fd2f583f83 100644 --- a/osu.Game/Online/Rooms/MatchType.cs +++ b/osu.Game/Online/Rooms/MatchType.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; diff --git a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs index 388a02f798..c45f703b05 100644 --- a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs +++ b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.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 enable - using System; using System.Collections.Generic; using System.Linq; diff --git a/osu.Game/Online/Rooms/MultiplayerScore.cs b/osu.Game/Online/Rooms/MultiplayerScore.cs index 85327be037..d5e0c7a970 100644 --- a/osu.Game/Online/Rooms/MultiplayerScore.cs +++ b/osu.Game/Online/Rooms/MultiplayerScore.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -63,7 +65,7 @@ namespace osu.Game.Online.Rooms [CanBeNull] public MultiplayerScoresAround ScoresAround { get; set; } - public ScoreInfo CreateScoreInfo(RulesetStore rulesets, PlaylistItem playlistItem, [NotNull] BeatmapInfo beatmap) + public ScoreInfo CreateScoreInfo(ScoreManager scoreManager, RulesetStore rulesets, PlaylistItem playlistItem, [NotNull] BeatmapInfo beatmap) { var ruleset = rulesets.GetRuleset(playlistItem.RulesetID); if (ruleset == null) @@ -77,7 +79,7 @@ namespace osu.Game.Online.Rooms TotalScore = TotalScore, MaxCombo = MaxCombo, BeatmapInfo = beatmap, - Ruleset = rulesets.GetRuleset(playlistItem.RulesetID) ?? throw new InvalidOperationException(), + Ruleset = rulesets.GetRuleset(playlistItem.RulesetID) ?? throw new InvalidOperationException($"Ruleset with ID of {playlistItem.RulesetID} not found locally"), Statistics = Statistics, User = User, Accuracy = Accuracy, @@ -88,6 +90,8 @@ namespace osu.Game.Online.Rooms Position = Position, }; + scoreManager.PopulateMaximumStatistics(scoreInfo); + return scoreInfo; } } diff --git a/osu.Game/Online/Rooms/MultiplayerScores.cs b/osu.Game/Online/Rooms/MultiplayerScores.cs index 3f970b2f8e..4c13579b3e 100644 --- a/osu.Game/Online/Rooms/MultiplayerScores.cs +++ b/osu.Game/Online/Rooms/MultiplayerScores.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using Newtonsoft.Json; using osu.Game.Online.API.Requests; diff --git a/osu.Game/Online/Rooms/MultiplayerScoresAround.cs b/osu.Game/Online/Rooms/MultiplayerScoresAround.cs index a99439312a..eca242bb0c 100644 --- a/osu.Game/Online/Rooms/MultiplayerScoresAround.cs +++ b/osu.Game/Online/Rooms/MultiplayerScoresAround.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using Newtonsoft.Json; diff --git a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs index 4ca6d79b19..1d496cc636 100644 --- a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using System.Linq; @@ -25,13 +27,10 @@ namespace osu.Game.Online.Rooms /// This differs from a regular download tracking composite as this accounts for the /// databased beatmap set's checksum, to disallow from playing with an altered version of the beatmap. /// - public class OnlinePlayBeatmapAvailabilityTracker : CompositeDrawable + public partial class OnlinePlayBeatmapAvailabilityTracker : CompositeComponent { public readonly IBindable SelectedItem = new Bindable(); - // Required to allow child components to update. Can potentially be replaced with a `CompositeComponent` class if or when we make one. - protected override bool RequiresChildrenUpdate => true; - [Resolved] private RealmAccess realm { get; set; } = null!; @@ -99,7 +98,7 @@ namespace osu.Game.Online.Rooms // handles changes to hash that didn't occur from the import process (ie. a user editing the beatmap in the editor, somehow). realmSubscription?.Dispose(); - realmSubscription = realm.RegisterForNotifications(r => filteredBeatmaps(), (items, changes, ___) => + realmSubscription = realm.RegisterForNotifications(_ => filteredBeatmaps(), (_, changes, _) => { if (changes == null) return; @@ -115,6 +114,7 @@ namespace osu.Game.Online.Rooms switch (downloadTracker.State.Value) { + case DownloadState.Unknown: case DownloadState.NotDownloaded: availability.Value = BeatmapAvailability.NotDownloaded(); break; diff --git a/osu.Game/Online/Rooms/PartRoomRequest.cs b/osu.Game/Online/Rooms/PartRoomRequest.cs index 2f036abc8c..da4e9a44c5 100644 --- a/osu.Game/Online/Rooms/PartRoomRequest.cs +++ b/osu.Game/Online/Rooms/PartRoomRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Net.Http; using osu.Framework.IO.Network; using osu.Game.Online.API; diff --git a/osu.Game/Online/Rooms/PlaylistAggregateScore.cs b/osu.Game/Online/Rooms/PlaylistAggregateScore.cs index 61e0951cd5..85e7b29a05 100644 --- a/osu.Game/Online/Rooms/PlaylistAggregateScore.cs +++ b/osu.Game/Online/Rooms/PlaylistAggregateScore.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Newtonsoft.Json; namespace osu.Game.Online.Rooms diff --git a/osu.Game/Online/Rooms/PlaylistExtensions.cs b/osu.Game/Online/Rooms/PlaylistExtensions.cs index 34c93bd9e0..cd52a3c6e6 100644 --- a/osu.Game/Online/Rooms/PlaylistExtensions.cs +++ b/osu.Game/Online/Rooms/PlaylistExtensions.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 enable - using System.Collections.Generic; using System.Linq; using Humanizer; diff --git a/osu.Game/Online/Rooms/PlaylistItem.cs b/osu.Game/Online/Rooms/PlaylistItem.cs index 12091bae88..2213311c67 100644 --- a/osu.Game/Online/Rooms/PlaylistItem.cs +++ b/osu.Game/Online/Rooms/PlaylistItem.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 enable - using System; using System.Linq; using JetBrains.Annotations; @@ -74,7 +72,7 @@ namespace osu.Game.Online.Rooms /// In many cases, this will *not* contain any usable information apart from OnlineID. /// [JsonIgnore] - public IBeatmapInfo Beatmap { get; set; } = null!; + public IBeatmapInfo Beatmap { get; private set; } [JsonIgnore] public IBindable Valid => valid; @@ -83,6 +81,7 @@ namespace osu.Game.Online.Rooms [JsonConstructor] private PlaylistItem() + : this(new APIBeatmap()) { } diff --git a/osu.Game/Online/Rooms/Room.cs b/osu.Game/Online/Rooms/Room.cs index 1933267efd..8f346c4057 100644 --- a/osu.Game/Online/Rooms/Room.cs +++ b/osu.Game/Online/Rooms/Room.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using Newtonsoft.Json; @@ -14,7 +16,7 @@ using osu.Game.Online.Rooms.RoomStatuses; namespace osu.Game.Online.Rooms { [JsonObject(MemberSerialization.OptIn)] - public class Room + public partial class Room : IDependencyInjectionCandidate { [Cached] [JsonProperty("id")] @@ -157,6 +159,10 @@ namespace osu.Game.Online.Rooms set => MaxAttempts.Value = value; } + [Cached] + [JsonProperty("auto_skip")] + public readonly Bindable AutoSkip = new Bindable(); + public Room() { Password.BindValueChanged(p => HasPassword.Value = !string.IsNullOrEmpty(p.NewValue)); @@ -193,6 +199,7 @@ namespace osu.Game.Online.Rooms DifficultyRange.Value = other.DifficultyRange.Value; PlaylistItemStats.Value = other.PlaylistItemStats.Value; CurrentPlaylistItem.Value = other.CurrentPlaylistItem.Value; + AutoSkip.Value = other.AutoSkip.Value; if (EndDate.Value != null && DateTimeOffset.Now >= EndDate.Value) Status.Value = new RoomStatusEnded(); diff --git a/osu.Game/Online/Rooms/RoomAvailability.cs b/osu.Game/Online/Rooms/RoomAvailability.cs index 3aea0e5948..fada111826 100644 --- a/osu.Game/Online/Rooms/RoomAvailability.cs +++ b/osu.Game/Online/Rooms/RoomAvailability.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; namespace osu.Game.Online.Rooms diff --git a/osu.Game/Online/Rooms/RoomCategory.cs b/osu.Game/Online/Rooms/RoomCategory.cs index bca4d78359..ba17fb2121 100644 --- a/osu.Game/Online/Rooms/RoomCategory.cs +++ b/osu.Game/Online/Rooms/RoomCategory.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; namespace osu.Game.Online.Rooms diff --git a/osu.Game/Online/Rooms/RoomStatus.cs b/osu.Game/Online/Rooms/RoomStatus.cs index 87c5aa3fda..4b890b00b7 100644 --- a/osu.Game/Online/Rooms/RoomStatus.cs +++ b/osu.Game/Online/Rooms/RoomStatus.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Graphics; using osuTK.Graphics; diff --git a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusEnded.cs b/osu.Game/Online/Rooms/RoomStatuses/RoomStatusEnded.cs index 01f3ae368b..9aa6424592 100644 --- a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusEnded.cs +++ b/osu.Game/Online/Rooms/RoomStatuses/RoomStatusEnded.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Graphics; using osuTK.Graphics; diff --git a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusOpen.cs b/osu.Game/Online/Rooms/RoomStatuses/RoomStatusOpen.cs index 686d4f4033..c37b93ea1b 100644 --- a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusOpen.cs +++ b/osu.Game/Online/Rooms/RoomStatuses/RoomStatusOpen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Graphics; using osuTK.Graphics; diff --git a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusPlaying.cs b/osu.Game/Online/Rooms/RoomStatuses/RoomStatusPlaying.cs index 83f1acf52a..9eb61a82ec 100644 --- a/osu.Game/Online/Rooms/RoomStatuses/RoomStatusPlaying.cs +++ b/osu.Game/Online/Rooms/RoomStatuses/RoomStatusPlaying.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Graphics; using osuTK.Graphics; diff --git a/osu.Game/Online/Rooms/ShowPlaylistUserScoreRequest.cs b/osu.Game/Online/Rooms/ShowPlaylistUserScoreRequest.cs index ba3e3c6349..affb2846a2 100644 --- a/osu.Game/Online/Rooms/ShowPlaylistUserScoreRequest.cs +++ b/osu.Game/Online/Rooms/ShowPlaylistUserScoreRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Online.API; namespace osu.Game.Online.Rooms diff --git a/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs b/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs index 39193be1af..f4cadc3fde 100644 --- a/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs +++ b/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Scoring; namespace osu.Game.Online.Rooms diff --git a/osu.Game/Online/Rooms/SubmitScoreRequest.cs b/osu.Game/Online/Rooms/SubmitScoreRequest.cs index b263262d2b..48a7780a03 100644 --- a/osu.Game/Online/Rooms/SubmitScoreRequest.cs +++ b/osu.Game/Online/Rooms/SubmitScoreRequest.cs @@ -1,24 +1,26 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Net.Http; using Newtonsoft.Json; using osu.Framework.IO.Network; using osu.Game.Online.API; -using osu.Game.Online.Solo; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Scoring; namespace osu.Game.Online.Rooms { public abstract class SubmitScoreRequest : APIRequest { - public readonly SubmittableScore Score; + public readonly SoloScoreInfo Score; protected readonly long ScoreId; protected SubmitScoreRequest(ScoreInfo scoreInfo, long scoreId) { - Score = new SubmittableScore(scoreInfo); + Score = SoloScoreInfo.ForSubmission(scoreInfo); ScoreId = scoreId; } diff --git a/osu.Game/Online/ScoreDownloadTracker.cs b/osu.Game/Online/ScoreDownloadTracker.cs index ed1c566dbe..4ddcb40368 100644 --- a/osu.Game/Online/ScoreDownloadTracker.cs +++ b/osu.Game/Online/ScoreDownloadTracker.cs @@ -9,11 +9,9 @@ using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Scoring; -#nullable enable - namespace osu.Game.Online { - public class ScoreDownloadTracker : DownloadTracker + public partial class ScoreDownloadTracker : DownloadTracker { [Resolved(CanBeNull = true)] protected ScoreModelDownloader? Downloader { get; private set; } @@ -50,7 +48,7 @@ namespace osu.Game.Online realmSubscription = realm.RegisterForNotifications(r => r.All().Where(s => ((s.OnlineID > 0 && s.OnlineID == TrackedItem.OnlineID) || (!string.IsNullOrEmpty(s.Hash) && s.Hash == TrackedItem.Hash)) - && !s.DeletePending), (items, changes, ___) => + && !s.DeletePending), (items, _, _) => { if (items.Any()) Schedule(() => UpdateState(DownloadState.LocallyAvailable)); diff --git a/osu.Game/Online/SignalRDerivedTypeWorkaroundJsonConverter.cs b/osu.Game/Online/SignalRDerivedTypeWorkaroundJsonConverter.cs index ab04e046d3..86708bee82 100644 --- a/osu.Game/Online/SignalRDerivedTypeWorkaroundJsonConverter.cs +++ b/osu.Game/Online/SignalRDerivedTypeWorkaroundJsonConverter.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. -#nullable enable using System; using System.Linq; using Newtonsoft.Json; @@ -34,7 +33,8 @@ namespace osu.Game.Online object? instance = Activator.CreateInstance(resolvedType); - jsonSerializer.Populate(obj["$value"]!.CreateReader(), instance); + if (instance != null) + jsonSerializer.Populate(obj["$value"]!.CreateReader(), instance); return instance; } diff --git a/osu.Game/Online/SignalRUnionWorkaroundResolver.cs b/osu.Game/Online/SignalRUnionWorkaroundResolver.cs index c96f93df78..e58cd83477 100644 --- a/osu.Game/Online/SignalRUnionWorkaroundResolver.cs +++ b/osu.Game/Online/SignalRUnionWorkaroundResolver.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; diff --git a/osu.Game/Online/SignalRWorkaroundTypes.cs b/osu.Game/Online/SignalRWorkaroundTypes.cs index d1f0ba725f..0b545821ee 100644 --- a/osu.Game/Online/SignalRWorkaroundTypes.cs +++ b/osu.Game/Online/SignalRWorkaroundTypes.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using osu.Game.Online.Multiplayer; @@ -21,11 +23,13 @@ namespace osu.Game.Online (typeof(ChangeTeamRequest), typeof(MatchUserRequest)), (typeof(StartMatchCountdownRequest), typeof(MatchUserRequest)), (typeof(StopCountdownRequest), typeof(MatchUserRequest)), - (typeof(CountdownChangedEvent), typeof(MatchServerEvent)), + (typeof(CountdownStartedEvent), typeof(MatchServerEvent)), + (typeof(CountdownStoppedEvent), typeof(MatchServerEvent)), (typeof(TeamVersusRoomState), typeof(MatchRoomState)), (typeof(TeamVersusUserState), typeof(MatchUserState)), (typeof(MatchStartCountdown), typeof(MultiplayerCountdown)), - (typeof(ForceGameplayStartCountdown), typeof(MultiplayerCountdown)) + (typeof(ForceGameplayStartCountdown), typeof(MultiplayerCountdown)), + (typeof(ServerShuttingDownCountdown), typeof(MultiplayerCountdown)), }; } } diff --git a/osu.Game/Online/Solo/CreateSoloScoreRequest.cs b/osu.Game/Online/Solo/CreateSoloScoreRequest.cs index 0d2bea1f2a..8c92b32915 100644 --- a/osu.Game/Online/Solo/CreateSoloScoreRequest.cs +++ b/osu.Game/Online/Solo/CreateSoloScoreRequest.cs @@ -1,9 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Globalization; using System.Net.Http; using osu.Framework.IO.Network; +using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Online.Rooms; @@ -11,13 +14,13 @@ namespace osu.Game.Online.Solo { public class CreateSoloScoreRequest : APIRequest { - private readonly int beatmapId; + private readonly BeatmapInfo beatmapInfo; private readonly int rulesetId; private readonly string versionHash; - public CreateSoloScoreRequest(int beatmapId, int rulesetId, string versionHash) + public CreateSoloScoreRequest(BeatmapInfo beatmapInfo, int rulesetId, string versionHash) { - this.beatmapId = beatmapId; + this.beatmapInfo = beatmapInfo; this.rulesetId = rulesetId; this.versionHash = versionHash; } @@ -27,10 +30,11 @@ namespace osu.Game.Online.Solo var req = base.CreateWebRequest(); req.Method = HttpMethod.Post; req.AddParameter("version_hash", versionHash); + req.AddParameter("beatmap_hash", beatmapInfo.MD5Hash); req.AddParameter("ruleset_id", rulesetId.ToString(CultureInfo.InvariantCulture)); return req; } - protected override string Target => $@"beatmaps/{beatmapId}/solo/scores"; + protected override string Target => $@"beatmaps/{beatmapInfo.OnlineID}/solo/scores"; } } diff --git a/osu.Game/Online/Solo/SoloStatisticsUpdate.cs b/osu.Game/Online/Solo/SoloStatisticsUpdate.cs new file mode 100644 index 0000000000..cb9dac97c7 --- /dev/null +++ b/osu.Game/Online/Solo/SoloStatisticsUpdate.cs @@ -0,0 +1,42 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Scoring; +using osu.Game.Users; + +namespace osu.Game.Online.Solo +{ + /// + /// Contains data about the change in a user's profile statistics after completing a score. + /// + public class SoloStatisticsUpdate + { + /// + /// The score set by the user that triggered the update. + /// + public ScoreInfo Score { get; } + + /// + /// The user's profile statistics prior to the score being set. + /// + public UserStatistics Before { get; } + + /// + /// The user's profile statistics after the score was set. + /// + public UserStatistics After { get; } + + /// + /// Creates a new . + /// + /// The score set by the user that triggered the update. + /// The user's profile statistics prior to the score being set. + /// The user's profile statistics after the score was set. + public SoloStatisticsUpdate(ScoreInfo score, UserStatistics before, UserStatistics after) + { + Score = score; + Before = before; + After = after; + } + } +} diff --git a/osu.Game/Online/Solo/SoloStatisticsWatcher.cs b/osu.Game/Online/Solo/SoloStatisticsWatcher.cs new file mode 100644 index 0000000000..46449fea73 --- /dev/null +++ b/osu.Game/Online/Solo/SoloStatisticsWatcher.cs @@ -0,0 +1,162 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Game.Extensions; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Spectator; +using osu.Game.Scoring; +using osu.Game.Users; + +namespace osu.Game.Online.Solo +{ + /// + /// A persistent component that binds to the spectator server and API in order to deliver updates about the logged in user's gameplay statistics. + /// + public partial class SoloStatisticsWatcher : Component + { + [Resolved] + private SpectatorClient spectatorClient { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private readonly Dictionary callbacks = new Dictionary(); + private long? lastProcessedScoreId; + + private Dictionary? latestStatistics; + + protected override void LoadComplete() + { + base.LoadComplete(); + + api.LocalUser.BindValueChanged(user => onUserChanged(user.NewValue), true); + spectatorClient.OnUserScoreProcessed += userScoreProcessed; + } + + /// + /// Registers for a user statistics update after the given has been processed server-side. + /// + /// The score to listen for the statistics update for. + /// The callback to be invoked once the statistics update has been prepared. + /// An representing the subscription. Disposing it is equivalent to unsubscribing from future notifications. + public IDisposable RegisterForStatisticsUpdateAfter(ScoreInfo score, Action onUpdateReady) + { + Schedule(() => + { + if (!api.IsLoggedIn) + return; + + if (!score.Ruleset.IsLegacyRuleset() || score.OnlineID <= 0) + return; + + var callback = new StatisticsUpdateCallback(score, onUpdateReady); + + if (lastProcessedScoreId == score.OnlineID) + { + requestStatisticsUpdate(api.LocalUser.Value.Id, callback); + return; + } + + callbacks.Add(score.OnlineID, callback); + }); + + return new InvokeOnDisposal(() => Schedule(() => callbacks.Remove(score.OnlineID))); + } + + private void onUserChanged(APIUser? localUser) => Schedule(() => + { + callbacks.Clear(); + lastProcessedScoreId = null; + latestStatistics = null; + + if (localUser == null || localUser.OnlineID <= 1) + return; + + var userRequest = new GetUsersRequest(new[] { localUser.OnlineID }); + userRequest.Success += initialiseUserStatistics; + api.Queue(userRequest); + }); + + private void initialiseUserStatistics(GetUsersResponse response) => Schedule(() => + { + var user = response.Users.SingleOrDefault(); + + // possible if the user is restricted or similar. + if (user == null) + return; + + latestStatistics = new Dictionary(); + + if (user.RulesetsStatistics != null) + { + foreach (var rulesetStats in user.RulesetsStatistics) + latestStatistics.Add(rulesetStats.Key, rulesetStats.Value); + } + }); + + private void userScoreProcessed(int userId, long scoreId) + { + if (userId != api.LocalUser.Value?.OnlineID) + return; + + lastProcessedScoreId = scoreId; + + if (!callbacks.TryGetValue(scoreId, out var callback)) + return; + + requestStatisticsUpdate(userId, callback); + callbacks.Remove(scoreId); + } + + private void requestStatisticsUpdate(int userId, StatisticsUpdateCallback callback) + { + var request = new GetUserRequest(userId, callback.Score.Ruleset); + request.Success += user => Schedule(() => dispatchStatisticsUpdate(callback, user.Statistics)); + api.Queue(request); + } + + private void dispatchStatisticsUpdate(StatisticsUpdateCallback callback, UserStatistics updatedStatistics) + { + string rulesetName = callback.Score.Ruleset.ShortName; + + if (latestStatistics == null) + return; + + latestStatistics.TryGetValue(rulesetName, out UserStatistics? latestRulesetStatistics); + latestRulesetStatistics ??= new UserStatistics(); + + var update = new SoloStatisticsUpdate(callback.Score, latestRulesetStatistics, updatedStatistics); + callback.OnUpdateReady.Invoke(update); + + latestStatistics[rulesetName] = updatedStatistics; + } + + protected override void Dispose(bool isDisposing) + { + if (spectatorClient.IsNotNull()) + spectatorClient.OnUserScoreProcessed -= userScoreProcessed; + + base.Dispose(isDisposing); + } + + private class StatisticsUpdateCallback + { + public ScoreInfo Score { get; } + public Action OnUpdateReady { get; } + + public StatisticsUpdateCallback(ScoreInfo score, Action onUpdateReady) + { + Score = score; + OnUpdateReady = onUpdateReady; + } + } + } +} diff --git a/osu.Game/Online/Solo/SubmitSoloScoreRequest.cs b/osu.Game/Online/Solo/SubmitSoloScoreRequest.cs index 77fd7b813b..f387e61901 100644 --- a/osu.Game/Online/Solo/SubmitSoloScoreRequest.cs +++ b/osu.Game/Online/Solo/SubmitSoloScoreRequest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Online.Rooms; using osu.Game.Scoring; diff --git a/osu.Game/Online/Solo/SubmittableScore.cs b/osu.Game/Online/Solo/SubmittableScore.cs deleted file mode 100644 index 9b6da1844a..0000000000 --- a/osu.Game/Online/Solo/SubmittableScore.cs +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using JetBrains.Annotations; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring; - -namespace osu.Game.Online.Solo -{ - /// - /// A class specifically for sending scores to the API during score submission. - /// This is used instead of due to marginally different serialisation naming requirements. - /// - [Serializable] - public class SubmittableScore - { - [JsonProperty("rank")] - [JsonConverter(typeof(StringEnumConverter))] - public ScoreRank Rank { get; set; } - - [JsonProperty("total_score")] - public long TotalScore { get; set; } - - [JsonProperty("accuracy")] - public double Accuracy { get; set; } - - [JsonProperty(@"pp")] - public double? PP { get; set; } - - [JsonProperty("max_combo")] - public int MaxCombo { get; set; } - - [JsonProperty("ruleset_id")] - public int RulesetID { get; set; } - - [JsonProperty("passed")] - public bool Passed { get; set; } - - // Used for API serialisation/deserialisation. - [JsonProperty("mods")] - public APIMod[] Mods { get; set; } - - [JsonProperty("statistics")] - public Dictionary Statistics { get; set; } - - [UsedImplicitly] - public SubmittableScore() - { - } - - public SubmittableScore(ScoreInfo score) - { - Rank = score.Rank; - TotalScore = score.TotalScore; - Accuracy = score.Accuracy; - PP = score.PP; - MaxCombo = score.MaxCombo; - RulesetID = score.RulesetID; - Passed = score.Passed; - Mods = score.APIMods; - Statistics = score.Statistics; - } - } -} diff --git a/osu.Game/Online/Spectator/FrameDataBundle.cs b/osu.Game/Online/Spectator/FrameDataBundle.cs index a4c4972989..97ae468875 100644 --- a/osu.Game/Online/Spectator/FrameDataBundle.cs +++ b/osu.Game/Online/Spectator/FrameDataBundle.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 enable - using System; using System.Collections.Generic; using MessagePack; diff --git a/osu.Game/Online/Spectator/FrameHeader.cs b/osu.Game/Online/Spectator/FrameHeader.cs index adfcbcd95a..b6dcd8aaa5 100644 --- a/osu.Game/Online/Spectator/FrameHeader.cs +++ b/osu.Game/Online/Spectator/FrameHeader.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 enable - using System; using System.Collections.Generic; using MessagePack; diff --git a/osu.Game/Online/Spectator/ISpectatorClient.cs b/osu.Game/Online/Spectator/ISpectatorClient.cs index 3acc9b2282..605ebc4ef0 100644 --- a/osu.Game/Online/Spectator/ISpectatorClient.cs +++ b/osu.Game/Online/Spectator/ISpectatorClient.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Threading.Tasks; namespace osu.Game.Online.Spectator @@ -30,5 +32,12 @@ namespace osu.Game.Online.Spectator /// The user. /// The frame data. Task UserSentFrames(int userId, FrameDataBundle data); + + /// + /// Signals that a user's submitted score was fully processed. + /// + /// The ID of the user who achieved the score. + /// The ID of the score. + Task UserScoreProcessed(int userId, long scoreId); } } diff --git a/osu.Game/Online/Spectator/ISpectatorServer.cs b/osu.Game/Online/Spectator/ISpectatorServer.cs index af0196862a..fa9d04792a 100644 --- a/osu.Game/Online/Spectator/ISpectatorServer.cs +++ b/osu.Game/Online/Spectator/ISpectatorServer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Threading.Tasks; namespace osu.Game.Online.Spectator @@ -13,8 +15,9 @@ namespace osu.Game.Online.Spectator /// /// Signal the start of a new play session. /// + /// The score submission token. /// The state of gameplay. - Task BeginPlaySession(SpectatorState state); + Task BeginPlaySession(long? scoreToken, SpectatorState state); /// /// Send a bundle of frame data for the current play session. diff --git a/osu.Game/Online/Spectator/OnlineSpectatorClient.cs b/osu.Game/Online/Spectator/OnlineSpectatorClient.cs index 0f77f723db..3118e05053 100644 --- a/osu.Game/Online/Spectator/OnlineSpectatorClient.cs +++ b/osu.Game/Online/Spectator/OnlineSpectatorClient.cs @@ -1,11 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - +using System; using System.Diagnostics; using System.Threading.Tasks; -using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR.Client; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -14,7 +12,7 @@ using osu.Game.Online.Multiplayer; namespace osu.Game.Online.Spectator { - public class OnlineSpectatorClient : SpectatorClient + public partial class OnlineSpectatorClient : SpectatorClient { private readonly string endpoint; @@ -43,13 +41,14 @@ namespace osu.Game.Online.Spectator connection.On(nameof(ISpectatorClient.UserBeganPlaying), ((ISpectatorClient)this).UserBeganPlaying); connection.On(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames); connection.On(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying); + connection.On(nameof(ISpectatorClient.UserScoreProcessed), ((ISpectatorClient)this).UserScoreProcessed); }; IsConnected.BindTo(connector.IsConnected); } } - protected override async Task BeginPlayingInternal(SpectatorState state) + protected override async Task BeginPlayingInternal(long? scoreToken, SpectatorState state) { if (!IsConnected.Value) return; @@ -58,13 +57,20 @@ namespace osu.Game.Online.Spectator try { - await connection.SendAsync(nameof(ISpectatorServer.BeginPlaySession), state); + await connection.InvokeAsync(nameof(ISpectatorServer.BeginPlaySession), scoreToken, state).ConfigureAwait(false); } - catch (HubException exception) + catch (Exception exception) { if (exception.GetHubExceptionMessage() == HubClientConnector.SERVER_SHUTDOWN_MESSAGE) - connector?.Reconnect(); - throw; + { + Debug.Assert(connector != null); + + await connector.Reconnect().ConfigureAwait(false); + await BeginPlayingInternal(scoreToken, state).ConfigureAwait(false); + } + + // Exceptions can occur if, for instance, the locally played beatmap doesn't have a server-side counterpart. + // For now, let's ignore these so they don't cause unobserved exceptions to appear to the user (and sentry). } } @@ -85,7 +91,7 @@ namespace osu.Game.Online.Spectator Debug.Assert(connection != null); - return connection.SendAsync(nameof(ISpectatorServer.EndPlaySession), state); + return connection.InvokeAsync(nameof(ISpectatorServer.EndPlaySession), state); } protected override Task WatchUserInternal(int userId) @@ -95,7 +101,7 @@ namespace osu.Game.Online.Spectator Debug.Assert(connection != null); - return connection.SendAsync(nameof(ISpectatorServer.StartWatchingUser), userId); + return connection.InvokeAsync(nameof(ISpectatorServer.StartWatchingUser), userId); } protected override Task StopWatchingUserInternal(int userId) @@ -105,7 +111,7 @@ namespace osu.Game.Online.Spectator Debug.Assert(connection != null); - return connection.SendAsync(nameof(ISpectatorServer.EndWatchingUser), userId); + return connection.InvokeAsync(nameof(ISpectatorServer.EndWatchingUser), userId); } } } diff --git a/osu.Game/Online/Spectator/SpectatedUserState.cs b/osu.Game/Online/Spectator/SpectatedUserState.cs index 0f0a3068b8..edf0859a33 100644 --- a/osu.Game/Online/Spectator/SpectatedUserState.cs +++ b/osu.Game/Online/Spectator/SpectatedUserState.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Online.Spectator { public enum SpectatedUserState diff --git a/osu.Game/Online/Spectator/SpectatorClient.cs b/osu.Game/Online/Spectator/SpectatorClient.cs index 9bf49364f3..55ec75f4ce 100644 --- a/osu.Game/Online/Spectator/SpectatorClient.cs +++ b/osu.Game/Online/Spectator/SpectatorClient.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 enable - using System; using System.Collections.Generic; using System.Diagnostics; @@ -23,7 +21,7 @@ using osu.Game.Screens.Play; namespace osu.Game.Online.Spectator { - public abstract class SpectatorClient : Component, ISpectatorClient + public abstract partial class SpectatorClient : Component, ISpectatorClient { /// /// The maximum milliseconds between frame bundle sends. @@ -49,7 +47,7 @@ namespace osu.Game.Online.Spectator /// /// Whether the local user is playing. /// - protected bool IsPlaying { get; private set; } + protected internal bool IsPlaying { get; private set; } /// /// Called whenever new frames arrive from the server. @@ -67,16 +65,23 @@ namespace osu.Game.Online.Spectator public virtual event Action? OnUserFinishedPlaying; /// - /// All users currently being watched. + /// Called whenever a user-submitted score has been fully processed. /// - private readonly List watchedUsers = new List(); + public virtual event Action? OnUserScoreProcessed; + + /// + /// A dictionary containing all users currently being watched, with the number of watching components for each user. + /// + private readonly Dictionary watchedUsersRefCounts = new Dictionary(); private readonly BindableDictionary watchedUserStates = new BindableDictionary(); + private readonly BindableList playingUsers = new BindableList(); private readonly SpectatorState currentState = new SpectatorState(); private IBeatmap? currentBeatmap; private Score? currentScore; + private long? currentScoreToken; private readonly Queue pendingFrameBundles = new Queue(); @@ -96,17 +101,20 @@ namespace osu.Game.Online.Spectator if (connected.NewValue) { // get all the users that were previously being watched - int[] users = watchedUsers.ToArray(); - watchedUsers.Clear(); + var users = new Dictionary(watchedUsersRefCounts); + watchedUsersRefCounts.Clear(); // resubscribe to watched users. - foreach (int userId in users) - WatchUser(userId); + foreach ((int user, int watchers) in users) + { + for (int i = 0; i < watchers; i++) + WatchUser(user); + } // re-send state in case it wasn't received if (IsPlaying) // TODO: this is likely sent out of order after a reconnect scenario. needs further consideration. - BeginPlayingInternal(currentState); + BeginPlayingInternal(currentScoreToken, currentState); } else { @@ -123,7 +131,7 @@ namespace osu.Game.Online.Spectator if (!playingUsers.Contains(userId)) playingUsers.Add(userId); - if (watchedUsers.Contains(userId)) + if (watchedUsersRefCounts.ContainsKey(userId)) watchedUserStates[userId] = state; OnUserBeganPlaying?.Invoke(userId, state); @@ -138,7 +146,7 @@ namespace osu.Game.Online.Spectator { playingUsers.Remove(userId); - if (watchedUsers.Contains(userId)) + if (watchedUsersRefCounts.ContainsKey(userId)) watchedUserStates[userId] = state; OnUserFinishedPlaying?.Invoke(userId, state); @@ -157,7 +165,14 @@ namespace osu.Game.Online.Spectator return Task.CompletedTask; } - public void BeginPlaying(GameplayState state, Score score) + Task ISpectatorClient.UserScoreProcessed(int userId, long scoreId) + { + Schedule(() => OnUserScoreProcessed?.Invoke(userId, scoreId)); + + return Task.CompletedTask; + } + + public void BeginPlaying(long? scoreToken, GameplayState state, Score score) { // This schedule is only here to match the one below in `EndPlaying`. Schedule(() => @@ -172,12 +187,13 @@ namespace osu.Game.Online.Spectator currentState.RulesetID = score.ScoreInfo.RulesetID; currentState.Mods = score.ScoreInfo.Mods.Select(m => new APIMod(m)).ToArray(); currentState.State = SpectatedUserState.Playing; - currentState.MaximumScoringValues = state.ScoreProcessor.MaximumScoringValues; + currentState.MaximumStatistics = state.ScoreProcessor.MaximumStatistics; currentBeatmap = state.Beatmap; currentScore = score; + currentScoreToken = scoreToken; - BeginPlayingInternal(currentState); + BeginPlayingInternal(currentScoreToken, currentState); }); } @@ -190,7 +206,10 @@ namespace osu.Game.Online.Spectator } if (frame is IConvertibleReplayFrame convertible) + { + Debug.Assert(currentBeatmap != null); pendingFrames.Enqueue(convertible.ToLegacy(currentBeatmap)); + } if (pendingFrames.Count > max_pending_frames) purgePendingFrames(); @@ -205,6 +224,11 @@ namespace osu.Game.Online.Spectator if (!IsPlaying) return; + // Disposal can take some time, leading to EndPlaying potentially being called after a future play session. + // Account for this by ensuring the score of the current play matches the one in the provided state. + if (currentScore != state.Score) + return; + if (pendingFrames.Count > 0) purgePendingFrames(); @@ -226,11 +250,13 @@ namespace osu.Game.Online.Spectator { Debug.Assert(ThreadSafety.IsUpdateThread); - if (watchedUsers.Contains(userId)) + if (watchedUsersRefCounts.ContainsKey(userId)) + { + watchedUsersRefCounts[userId]++; return; + } - watchedUsers.Add(userId); - + watchedUsersRefCounts.Add(userId, 1); WatchUserInternal(userId); } @@ -240,13 +266,19 @@ namespace osu.Game.Online.Spectator // Todo: This should not be a thing, but requires framework changes. Schedule(() => { - watchedUsers.Remove(userId); + if (watchedUsersRefCounts.TryGetValue(userId, out int watchers) && watchers > 1) + { + watchedUsersRefCounts[userId]--; + return; + } + + watchedUsersRefCounts.Remove(userId); watchedUserStates.Remove(userId); StopWatchingUserInternal(userId); }); } - protected abstract Task BeginPlayingInternal(SpectatorState state); + protected abstract Task BeginPlayingInternal(long? scoreToken, SpectatorState state); protected abstract Task SendFramesInternal(FrameDataBundle bundle); @@ -296,17 +328,21 @@ namespace osu.Game.Online.Spectator lastSend = tcs.Task; - SendFramesInternal(bundle).ContinueWith(t => Schedule(() => + SendFramesInternal(bundle).ContinueWith(t => { + // Handle exception outside of `Schedule` to ensure it doesn't go unobserved. bool wasSuccessful = t.Exception == null; - // If the last bundle send wasn't successful, try again without dequeuing. - if (wasSuccessful) - pendingFrameBundles.Dequeue(); + return Schedule(() => + { + // If the last bundle send wasn't successful, try again without dequeuing. + if (wasSuccessful) + pendingFrameBundles.Dequeue(); - tcs.SetResult(wasSuccessful); - sendNextBundleIfRequired(); - })); + tcs.SetResult(wasSuccessful); + sendNextBundleIfRequired(); + }); + }); } } } diff --git a/osu.Game/Online/Spectator/SpectatorScoreProcessor.cs b/osu.Game/Online/Spectator/SpectatorScoreProcessor.cs index a1e8715c8f..1c505ea107 100644 --- a/osu.Game/Online/Spectator/SpectatorScoreProcessor.cs +++ b/osu.Game/Online/Spectator/SpectatorScoreProcessor.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 enable - using System; using System.Collections.Generic; using System.Diagnostics; @@ -23,12 +21,12 @@ namespace osu.Game.Online.Spectator /// A wrapper over a for spectated users. /// This should be used when a local "playable" beatmap is unavailable or expensive to generate for the spectated user. /// - public class SpectatorScoreProcessor : Component + public partial class SpectatorScoreProcessor : Component { /// /// The current total score. /// - public readonly BindableDouble TotalScore = new BindableDouble { MinValue = 0 }; + public readonly BindableLong TotalScore = new BindableLong { MinValue = 0 }; /// /// The current accuracy. @@ -154,12 +152,12 @@ namespace osu.Game.Online.Spectator scoreInfo.MaxCombo = frame.Header.MaxCombo; scoreInfo.Statistics = frame.Header.Statistics; + scoreInfo.MaximumStatistics = spectatorState.MaximumStatistics; Accuracy.Value = frame.Header.Accuracy; Combo.Value = frame.Header.Combo; - scoreProcessor.ExtractScoringValues(frame.Header, out var currentScoringValues, out _); - TotalScore.Value = scoreProcessor.ComputeScore(Mode.Value, currentScoringValues, spectatorState.MaximumScoringValues); + TotalScore.Value = scoreProcessor.ComputeScore(Mode.Value, scoreInfo); } protected override void Dispose(bool isDisposing) @@ -186,7 +184,7 @@ namespace osu.Game.Online.Spectator Header = header; } - public int CompareTo(TimedFrame other) => Time.CompareTo(other.Time); + public int CompareTo(TimedFrame? other) => Time.CompareTo(other?.Time); } } } diff --git a/osu.Game/Online/Spectator/SpectatorState.cs b/osu.Game/Online/Spectator/SpectatorState.cs index 64e5f8b3a1..91df05bf96 100644 --- a/osu.Game/Online/Spectator/SpectatorState.cs +++ b/osu.Game/Online/Spectator/SpectatorState.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using MessagePack; using osu.Game.Online.API; -using osu.Game.Scoring; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Online.Spectator { @@ -29,7 +31,7 @@ namespace osu.Game.Online.Spectator public SpectatedUserState State { get; set; } [Key(4)] - public ScoringValues MaximumScoringValues { get; set; } + public Dictionary MaximumStatistics { get; set; } = new Dictionary(); public bool Equals(SpectatorState other) { diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index b91d523151..7c9b03bd5b 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; @@ -14,6 +16,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Configuration; +using osu.Framework.Extensions; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; @@ -22,6 +25,8 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Input.Handlers.Tablet; +using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Framework.Threading; @@ -37,11 +42,14 @@ using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.IO; using osu.Game.Localisation; +using osu.Game.Online; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Overlays; +using osu.Game.Overlays.BeatmapListing; using osu.Game.Overlays.Music; using osu.Game.Overlays.Notifications; +using osu.Game.Overlays.SkinEditor; using osu.Game.Overlays.Toolbar; using osu.Game.Overlays.Volume; using osu.Game.Performance; @@ -53,13 +61,11 @@ using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; using osu.Game.Screens.Select; using osu.Game.Skinning; -using osu.Game.Skinning.Editor; using osu.Game.Updater; using osu.Game.Users; using osu.Game.Utils; using osuTK.Graphics; using Sentry; -using Logger = osu.Framework.Logging.Logger; namespace osu.Game { @@ -67,7 +73,8 @@ namespace osu.Game /// The full osu! experience. Builds on top of to add menus and binding logic /// for initial components that are generally retrieved via DI. /// - public class OsuGame : OsuGameBase, IKeyBindingHandler, ILocalUserPlayInfo, IPerformFromScreenRunner, IOverlayManager + [Cached(typeof(OsuGame))] + public partial class OsuGame : OsuGameBase, IKeyBindingHandler, ILocalUserPlayInfo, IPerformFromScreenRunner, IOverlayManager, ILinkHandler { /// /// The amount of global offset to apply when a left/right anchored overlay is displayed (ie. settings or notifications). @@ -133,6 +140,11 @@ namespace osu.Game private IdleTracker idleTracker; + /// + /// Whether the user is currently in an idle state. + /// + public IBindable IsIdle => idleTracker.IsIdle; + /// /// Whether overlays should be able to be opened game-wide. Value is sourced from the current active screen. /// @@ -153,7 +165,9 @@ namespace osu.Game protected SettingsOverlay Settings; - private FirstRunSetupOverlay firstRunOverlay; + protected FirstRunSetupOverlay FirstRunOverlay { get; private set; } + + private FPSCounter fpsCounter; private VolumeOverlay volume; @@ -168,6 +182,8 @@ namespace osu.Game private Bindable configRuleset; + private Bindable applySafeAreaConsiderations; + private Bindable uiScale; private Bindable configSkin; @@ -183,7 +199,8 @@ namespace osu.Game { this.args = args; - forwardLoggedErrorsToNotifications(); + forwardGeneralLogsToNotifications(); + forwardTabletLogsToNotifications(); SentryLogger = new SentryLogger(this); } @@ -228,7 +245,7 @@ namespace osu.Game /// /// Unregisters a blocking that was not created by itself. /// - private void unregisterBlockingOverlay(OverlayContainer overlayContainer) + private void unregisterBlockingOverlay(OverlayContainer overlayContainer) => Schedule(() => { externalOverlays.Remove(overlayContainer); @@ -236,7 +253,7 @@ namespace osu.Game focusedOverlays.Remove(focusedOverlayContainer); overlayContainer.Expire(); - } + }); #endregion @@ -260,8 +277,6 @@ namespace osu.Game [BackgroundDependencyLoader] private void load() { - dependencies.CacheAs(this); - SentryLogger.AttachUser(API.LocalUser); dependencies.Cache(osuLogo = new OsuLogo { Alpha = 0 }); @@ -270,10 +285,7 @@ namespace osu.Game configRuleset = LocalConfig.GetBindable(OsuSetting.Ruleset); uiScale = LocalConfig.GetBindable(OsuSetting.UIScale); - var preferredRuleset = int.TryParse(configRuleset.Value, out int rulesetId) - // int parsing can be removed 20220522 - ? RulesetStore.GetRuleset(rulesetId) - : RulesetStore.GetRuleset(configRuleset.Value); + var preferredRuleset = RulesetStore.GetRuleset(configRuleset.Value); try { @@ -288,25 +300,20 @@ namespace osu.Game Ruleset.ValueChanged += r => configRuleset.Value = r.NewValue.ShortName; - // bind config int to database SkinInfo configSkin = LocalConfig.GetBindable(OsuSetting.Skin); + + // Transfer skin from config to realm instance once on startup. + SkinManager.SetSkinFromConfiguration(configSkin.Value); + + // Transfer any runtime changes back to configuration file. SkinManager.CurrentSkinInfo.ValueChanged += skin => configSkin.Value = skin.NewValue.ID.ToString(); - configSkin.ValueChanged += skinId => + + LocalUserPlaying.BindValueChanged(p => { - Live skinInfo = null; - - if (Guid.TryParse(skinId.NewValue, out var guid)) - skinInfo = SkinManager.Query(s => s.ID == guid); - - if (skinInfo == null) - { - if (guid == SkinInfo.CLASSIC_SKIN) - skinInfo = DefaultLegacySkin.CreateInfo().ToLiveUnmanaged(); - } - - SkinManager.CurrentSkinInfo.Value = skinInfo ?? DefaultSkin.CreateInfo().ToLiveUnmanaged(); - }; - configSkin.TriggerChange(); + BeatmapManager.PauseImports = p.NewValue; + SkinManager.PauseImports = p.NewValue; + ScoreManager.PauseImports = p.NewValue; + }, true); IsActive.BindValueChanged(active => updateActiveState(active.NewValue), true); @@ -314,6 +321,9 @@ namespace osu.Game SelectedMods.BindValueChanged(modsChanged); Beatmap.BindValueChanged(beatmapChanged, true); + + applySafeAreaConsiderations = LocalConfig.GetBindable(OsuSetting.SafeAreaConsiderations); + applySafeAreaConsiderations.BindValueChanged(apply => SafeAreaContainer.SafeAreaOverrideEdges = apply.NewValue ? SafeAreaOverrideEdges : Edges.All, true); } private ExternalLinkOpener externalLinkOpener; @@ -332,7 +342,7 @@ namespace osu.Game /// The link to load. public void HandleLink(LinkDetails link) => Schedule(() => { - string argString = link.Argument.ToString(); + string argString = link.Argument.ToString() ?? string.Empty; switch (link.Action) { @@ -352,7 +362,18 @@ namespace osu.Game break; case LinkAction.SearchBeatmapSet: - SearchBeatmapSet(argString); + if (link.Argument is RomanisableString romanisable) + SearchBeatmapSet(romanisable.GetPreferred(Localisation.CurrentParameters.Value.PreferOriginalScript)); + else + SearchBeatmapSet(argString); + break; + + case LinkAction.FilterBeatmapSetGenre: + FilterBeatmapSetGenre((SearchGenre)link.Argument); + break; + + case LinkAction.FilterBeatmapSetLanguage: + FilterBeatmapSetLanguage((SearchLanguage)link.Argument); break; case LinkAction.OpenEditorTimestamp: @@ -406,6 +427,16 @@ namespace osu.Game if (url.StartsWith('/')) url = $"{API.APIEndpointUrl}{url}"; + if (!url.CheckIsValidUrl()) + { + Notifications.Post(new SimpleErrorNotification + { + Text = $"The URL {url} has an unsupported or dangerous protocol and will not be opened.", + }); + + return; + } + externalLinkOpener.OpenUrlExternally(url, bypassExternalUrlWarning); }); @@ -449,6 +480,10 @@ namespace osu.Game /// The query to search for. public void SearchBeatmapSet(string query) => waitForReady(() => beatmapListing, _ => beatmapListing.ShowWithSearch(query)); + public void FilterBeatmapSetGenre(SearchGenre genre) => waitForReady(() => beatmapListing, _ => beatmapListing.ShowWithGenreFilter(genre)); + + public void FilterBeatmapSetLanguage(SearchLanguage language) => waitForReady(() => beatmapListing, _ => beatmapListing.ShowWithLanguageFilter(language)); + /// /// Show a wiki's page as an overlay /// @@ -467,6 +502,23 @@ namespace osu.Game /// The build version of the update stream public void ShowChangelogBuild(string updateStream, string version) => waitForReady(() => changelogOverlay, _ => changelogOverlay.ShowBuild(updateStream, version)); + /// + /// Present a skin select immediately. + /// + /// The skin to select. + public void PresentSkin(SkinInfo skin) + { + var databasedSkin = SkinManager.Query(s => s.ID == skin.ID); + + if (databasedSkin == null) + { + Logger.Log("The requested skin could not be loaded.", LoggingTarget.Information); + return; + } + + SkinManager.CurrentSkinInfo.Value = databasedSkin; + } + /// /// Present a beatmap at song select immediately. /// The user should have already requested this interactively. @@ -483,6 +535,7 @@ namespace osu.Game /// public void PresentBeatmap(IBeatmapSetInfo beatmap, Predicate difficultyCriteria = null) { + Logger.Log($"Beginning {nameof(PresentBeatmap)} with beatmap {beatmap}"); Live databasedSet = null; if (beatmap.OnlineID > 0) @@ -519,10 +572,29 @@ namespace osu.Game } else { - Ruleset.Value = selection.Ruleset; - Beatmap.Value = BeatmapManager.GetWorkingBeatmap(selection); + // Don't change the local ruleset if the user is on another ruleset and is showing converted beatmaps at song select. + // Eventually we probably want to check whether conversion is actually possible for the current ruleset. + bool requiresRulesetSwitch = !selection.Ruleset.Equals(Ruleset.Value) + && (selection.Ruleset.OnlineID > 0 || !LocalConfig.Get(OsuSetting.ShowConvertedBeatmaps)); + + if (requiresRulesetSwitch) + { + Ruleset.Value = selection.Ruleset; + Beatmap.Value = BeatmapManager.GetWorkingBeatmap(selection); + + Logger.Log($"Completing {nameof(PresentBeatmap)} with beatmap {beatmap} ruleset {selection.Ruleset}"); + } + else + { + Beatmap.Value = BeatmapManager.GetWorkingBeatmap(selection); + + Logger.Log($"Completing {nameof(PresentBeatmap)} with beatmap {beatmap} (maintaining ruleset)"); + } } - }, validScreens: new[] { typeof(SongSelect), typeof(IHandlePresentBeatmap) }); + }, validScreens: new[] + { + typeof(SongSelect), typeof(IHandlePresentBeatmap) + }); } /// @@ -531,6 +603,8 @@ namespace osu.Game /// public void PresentScore(IScoreInfo score, ScorePresentType presentType = ScorePresentType.Results) { + Logger.Log($"Beginning {nameof(PresentScore)} with score {score}"); + // The given ScoreInfo may have missing properties if it was retrieved from online data. Re-retrieve it from the database // to ensure all the required data for presenting a replay are present. ScoreInfo databasedScoreInfo = null; @@ -563,8 +637,21 @@ namespace osu.Game return; } + // This should be able to be performed from song select, but that is disabled for now + // due to the weird decoupled ruleset logic (which can cause a crash in certain filter scenarios). + // + // As a special case, if the beatmap and ruleset already match, allow immediately displaying the score from song select. + // This is guaranteed to not crash, and feels better from a user's perspective (ie. if they are clicking a score in the + // song select leaderboard). + IEnumerable validScreens = + Beatmap.Value.BeatmapInfo.Equals(databasedBeatmap) && Ruleset.Value.Equals(databasedScore.ScoreInfo.Ruleset) + ? new[] { typeof(SongSelect) } + : Array.Empty(); + PerformFromScreen(screen => { + Logger.Log($"{nameof(PresentScore)} updating beatmap ({databasedBeatmap}) and ruleset ({databasedScore.ScoreInfo.Ruleset}) to match score"); + Ruleset.Value = databasedScore.ScoreInfo.Ruleset; Beatmap.Value = BeatmapManager.GetWorkingBeatmap(databasedBeatmap); @@ -578,17 +665,17 @@ namespace osu.Game screen.Push(new SoloResultsScreen(databasedScore.ScoreInfo, false)); break; } - }, validScreens: new[] { typeof(PlaySongSelect) }); + }, validScreens: validScreens); } - public override Task Import(params ImportTask[] imports) + public override Task Import(ImportTask[] imports, ImportParameters parameters = default) { // encapsulate task as we don't want to begin the import process until in a ready state. // ReSharper disable once AsyncVoidLambda // TODO: This is bad because `new Task` doesn't have a Func override. // Only used for android imports and a bit of a mess. Probably needs rethinking overall. - var importTask = new Task(async () => await base.Import(imports).ConfigureAwait(false)); + var importTask = new Task(async () => await base.Import(imports, parameters).ConfigureAwait(false)); waitForReady(() => this, _ => importTask.Start()); @@ -609,7 +696,6 @@ namespace osu.Game { beatmap.OldValue?.CancelAsyncLoad(); beatmap.NewValue?.BeginAsyncLoad(); - Logger.Log($"Game-wide working beatmap updated to {beatmap.NewValue}"); } private void modsChanged(ValueChangedEvent> mods) @@ -635,6 +721,12 @@ namespace osu.Game Add(performFromMainMenuTask = new PerformFromMenuRunner(action, validScreens, () => ScreenStack.CurrentScreen)); } + public override void AttemptExit() + { + // Using PerformFromScreen gives the user a chance to interrupt the exit process if needed. + PerformFromScreen(menu => menu.Exit()); + } + /// /// Wait for the game (and target component) to become loaded and then run an action. /// @@ -672,44 +764,49 @@ namespace osu.Game { base.LoadComplete(); - foreach (var language in Enum.GetValues(typeof(Language)).OfType()) + var languages = Enum.GetValues(); + + var mappings = languages.Select(language => { #if DEBUG if (language == Language.debug) - { - Localisation.AddLanguage(Language.debug.ToString(), new DebugLocalisationStore()); - continue; - } + return new LocaleMapping("debug", new DebugLocalisationStore()); #endif string cultureCode = language.ToCultureCode(); try { - Localisation.AddLanguage(cultureCode, new ResourceManagerLocalisationStore(cultureCode)); + return new LocaleMapping(new ResourceManagerLocalisationStore(cultureCode)); } catch (Exception ex) { Logger.Error(ex, $"Could not load localisations for language \"{cultureCode}\""); + return null; } - } + }).Where(m => m != null); + + Localisation.AddLocaleMappings(mappings); // The next time this is updated is in UpdateAfterChildren, which occurs too late and results // in the cursor being shown for a few frames during the intro. // This prevents the cursor from showing until we have a screen with CursorVisible = true - MenuCursorContainer.CanShowCursor = menuScreen?.CursorVisible ?? false; + GlobalCursorDisplay.ShowCursor = menuScreen?.CursorVisible ?? false; // todo: all archive managers should be able to be looped here. SkinManager.PostNotification = n => Notifications.Post(n); + SkinManager.PresentImport = items => PresentSkin(items.First().Value); BeatmapManager.PostNotification = n => Notifications.Post(n); - BeatmapManager.PostImport = items => PresentBeatmap(items.First().Value); + BeatmapManager.PresentImport = items => PresentBeatmap(items.First().Value); BeatmapDownloader.PostNotification = n => Notifications.Post(n); ScoreDownloader.PostNotification = n => Notifications.Post(n); ScoreManager.PostNotification = n => Notifications.Post(n); - ScoreManager.PostImport = items => PresentScore(items.First().Value); + ScoreManager.PresentImport = items => PresentScore(items.First().Value); + + MultiplayerClient.PostNotification = n => Notifications.Post(n); // make config aware of how to lookup skins for on-screen display purposes. // if this becomes a more common thing, tracked settings should be reconsidered to allow local DI. @@ -785,8 +882,8 @@ namespace osu.Game Children = new Drawable[] { overlayContent = new Container { RelativeSizeAxes = Axes.Both }, - rightFloatingOverlayContent = new Container { RelativeSizeAxes = Axes.Both }, leftFloatingOverlayContent = new Container { RelativeSizeAxes = Axes.Both }, + rightFloatingOverlayContent = new Container { RelativeSizeAxes = Axes.Both }, } }, topMostOverlayContent = new Container { RelativeSizeAxes = Axes.Both }, @@ -797,6 +894,13 @@ namespace osu.Game ScreenStack.ScreenPushed += screenPushed; ScreenStack.ScreenExited += screenExited; + loadComponentSingleFile(fpsCounter = new FPSCounter + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding(5), + }, topMostOverlayContent.Add); + if (!args?.Any(a => a == @"--no-version-overlay") ?? true) loadComponentSingleFile(versionManager = new VersionManager { Depth = int.MinValue }, ScreenContainer.Add); @@ -813,7 +917,9 @@ namespace osu.Game OnHome = delegate { CloseAllOverlays(false); - menuScreen?.MakeCurrent(); + + if (menuScreen?.GetChildScreen() != null) + menuScreen.MakeCurrent(); }, }, topMostOverlayContent.Add); @@ -832,11 +938,6 @@ namespace osu.Game d.Origin = Anchor.TopRight; }), rightFloatingOverlayContent.Add, true); - loadComponentSingleFile(new CollectionManager(Storage) - { - PostNotification = n => Notifications.Post(n), - }, Add, true); - loadComponentSingleFile(legacyImportManager, Add); loadComponentSingleFile(screenshotManager, Add); @@ -845,15 +946,15 @@ namespace osu.Game loadComponentSingleFile(CreateUpdateManager(), Add, true); // overlay elements - loadComponentSingleFile(firstRunOverlay = new FirstRunSetupOverlay(), overlayContent.Add, true); + loadComponentSingleFile(FirstRunOverlay = new FirstRunSetupOverlay(), overlayContent.Add, true); loadComponentSingleFile(new ManageCollectionsDialog(), overlayContent.Add, true); loadComponentSingleFile(beatmapListing = new BeatmapListingOverlay(), overlayContent.Add, true); loadComponentSingleFile(dashboard = new DashboardOverlay(), overlayContent.Add, true); loadComponentSingleFile(news = new NewsOverlay(), overlayContent.Add, true); var rankingsOverlay = loadComponentSingleFile(new RankingsOverlay(), overlayContent.Add, true); - loadComponentSingleFile(channelManager = new ChannelManager(), AddInternal, true); + loadComponentSingleFile(channelManager = new ChannelManager(API), Add, true); loadComponentSingleFile(chatOverlay = new ChatOverlay(), overlayContent.Add, true); - loadComponentSingleFile(new MessageNotifier(), AddInternal, true); + loadComponentSingleFile(new MessageNotifier(), Add, true); loadComponentSingleFile(Settings = new SettingsOverlay(), leftFloatingOverlayContent.Add, true); loadComponentSingleFile(changelogOverlay = new ChangelogOverlay(), overlayContent.Add, true); loadComponentSingleFile(userProfile = new UserProfileOverlay(), overlayContent.Add, true); @@ -878,25 +979,14 @@ namespace osu.Game loadComponentSingleFile(CreateHighPerformanceSession(), Add); - chatOverlay.State.BindValueChanged(_ => updateChatPollRate()); - // Multiplayer modes need to increase poll rate temporarily. - API.Activity.BindValueChanged(_ => updateChatPollRate(), true); - - void updateChatPollRate() - { - channelManager.HighPollRate.Value = - chatOverlay.State.Value == Visibility.Visible - || API.Activity.Value is UserActivity.InLobby - || API.Activity.Value is UserActivity.InMultiplayerGame - || API.Activity.Value is UserActivity.SpectatingMultiplayerGame; - } + loadComponentSingleFile(new BackgroundBeatmapProcessor(), Add); Add(difficultyRecommender); Add(externalLinkOpener = new ExternalLinkOpener()); Add(new MusicKeyBindingHandler()); // side overlays which cancel each other. - var singleDisplaySideOverlays = new OverlayContainer[] { Settings, Notifications, firstRunOverlay }; + var singleDisplaySideOverlays = new OverlayContainer[] { Settings, Notifications, FirstRunOverlay }; foreach (var overlay in singleDisplaySideOverlays) { @@ -921,7 +1011,7 @@ namespace osu.Game } // ensure only one of these overlays are open at once. - var singleDisplayOverlays = new OverlayContainer[] { firstRunOverlay, chatOverlay, news, dashboard, beatmapListing, changelogOverlay, rankingsOverlay, wikiOverlay }; + var singleDisplayOverlays = new OverlayContainer[] { FirstRunOverlay, chatOverlay, news, dashboard, beatmapListing, changelogOverlay, rankingsOverlay, wikiOverlay }; foreach (var overlay in singleDisplayOverlays) { @@ -970,6 +1060,9 @@ namespace osu.Game { otherOverlays.Where(o => o != overlay).ForEach(o => o.Hide()); + Settings.Hide(); + Notifications.Hide(); + // Partially visible so leave it at the current depth. if (overlay.IsPresent) return; @@ -981,7 +1074,7 @@ namespace osu.Game overlay.Depth = (float)-Clock.CurrentTime; } - private void forwardLoggedErrorsToNotifications() + private void forwardGeneralLogsToNotifications() { int recentLogCount = 0; @@ -989,7 +1082,7 @@ namespace osu.Game Logger.NewEntry += entry => { - if (entry.Level < LogLevel.Important || entry.Target == null) return; + if (entry.Level < LogLevel.Important || entry.Target > LoggingTarget.Database || entry.Target == null) return; const int short_term_display_limit = 3; @@ -1003,7 +1096,7 @@ namespace osu.Game } else if (recentLogCount == short_term_display_limit) { - string logFile = $@"{entry.Target.ToString().ToLowerInvariant()}.log"; + string logFile = $@"{entry.Target.Value.ToString().ToLowerInvariant()}.log"; Schedule(() => Notifications.Post(new SimpleNotification { @@ -1022,6 +1115,52 @@ namespace osu.Game }; } + private void forwardTabletLogsToNotifications() + { + const string tablet_prefix = @"[Tablet] "; + bool notifyOnWarning = true; + + Logger.NewEntry += entry => + { + if (entry.Level < LogLevel.Important || entry.Target != LoggingTarget.Input || !entry.Message.StartsWith(tablet_prefix, StringComparison.OrdinalIgnoreCase)) + return; + + string message = entry.Message.Replace(tablet_prefix, string.Empty); + + if (entry.Level == LogLevel.Error) + { + Schedule(() => Notifications.Post(new SimpleNotification + { + Text = $"Encountered tablet error: \"{message}\"", + Icon = FontAwesome.Solid.PenSquare, + IconColour = Colours.RedDark, + })); + } + else if (notifyOnWarning) + { + Schedule(() => Notifications.Post(new SimpleNotification + { + Text = @"Encountered tablet warning, your tablet may not function correctly. Click here for a list of all tablets supported.", + Icon = FontAwesome.Solid.PenSquare, + IconColour = Colours.YellowDark, + Activated = () => + { + OpenUrlExternally("https://opentabletdriver.net/Tablets", true); + return true; + } + })); + + notifyOnWarning = false; + } + }; + + Schedule(() => + { + ITabletHandler tablet = Host.AvailableInputHandlers.OfType().SingleOrDefault(); + tablet?.Tablet.BindValueChanged(_ => notifyOnWarning = true, true); + }); + } + private Task asyncLoadStream; /// @@ -1097,6 +1236,10 @@ namespace osu.Game switch (e.Action) { + case GlobalAction.ToggleFPSDisplay: + fpsCounter.ToggleVisibility(); + return true; + case GlobalAction.ToggleSkinEditor: skinEditor.ToggleVisibility(); return true; @@ -1111,6 +1254,13 @@ namespace osu.Game mouseDisableButtons.Value = !mouseDisableButtons.Value; return true; + case GlobalAction.ToggleProfile: + if (userProfile.State.Value == Visibility.Visible) + userProfile.Hide(); + else + ShowUser(API.LocalUser.Value); + return true; + case GlobalAction.RandomSkin: // Don't allow random skin selection while in the skin editor. // This is mainly to stop many "osu! default (modified)" skins being created via the SkinManager.EnsureMutableSkin() path. @@ -1199,7 +1349,7 @@ namespace osu.Game ScreenOffsetContainer.X = horizontalOffset; overlayContent.X = horizontalOffset * 1.2f; - MenuCursorContainer.CanShowCursor = (ScreenStack.CurrentScreen as IOsuScreen)?.CursorVisible ?? false; + GlobalCursorDisplay.ShowCursor = (ScreenStack.CurrentScreen as IOsuScreen)?.CursorVisible ?? false; } private void screenChanged(IScreen current, IScreen newScreen) @@ -1246,6 +1396,8 @@ namespace osu.Game OverlayActivationMode.BindTo(newOsuScreen.OverlayActivationMode); API.Activity.BindTo(newOsuScreen.Activity); + GlobalCursorDisplay.MenuCursor.HideCursorOnNonMouseInput = newOsuScreen.HideMenuCursorOnNonMouseInput; + if (newOsuScreen.HideOverlaysOnEnter) CloseAllOverlays(); else diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 5dbdf6f602..34e31b0d61 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.IO; @@ -16,9 +18,14 @@ using osu.Framework.Development; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Performance; using osu.Framework.Graphics.Textures; using osu.Framework.Input; +using osu.Framework.Input.Handlers; +using osu.Framework.Input.Handlers.Joystick; +using osu.Framework.Input.Handlers.Midi; +using osu.Framework.Input.Handlers.Mouse; +using osu.Framework.Input.Handlers.Tablet; +using osu.Framework.Input.Handlers.Touch; using osu.Framework.IO.Stores; using osu.Framework.Logging; using osu.Framework.Platform; @@ -37,15 +44,21 @@ using osu.Game.IO; using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.Chat; +using osu.Game.Online.Metadata; using osu.Game.Online.Multiplayer; +using osu.Game.Online.Solo; using osu.Game.Online.Spectator; using osu.Game.Overlays; +using osu.Game.Overlays.Settings; +using osu.Game.Overlays.Settings.Sections; +using osu.Game.Overlays.Settings.Sections.Input; using osu.Game.Resources; using osu.Game.Rulesets; 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 @@ -55,8 +68,11 @@ namespace osu.Game /// Unlike , this class will not load any kind of UI, allowing it to be used /// for provide dependencies to test cases without interfering with them. /// + [Cached(typeof(OsuGameBase))] public partial class OsuGameBase : Framework.Game, ICanAcceptFiles, IBeatSyncProvider { + public static readonly string[] VIDEO_EXTENSIONS = { ".mp4", ".mov", ".avi", ".flv", ".mpg", ".wmv", ".m4v" }; + public const string OSU_PROTOCOL = "osu://"; public const string CLIENT_STREAM_NAME = @"lazer"; @@ -68,6 +84,8 @@ namespace osu.Game public const int SAMPLE_CONCURRENCY = 6; + public const double SFX_STEREO_STRENGTH = 0.75; + /// /// Length of debounce (in milliseconds) for commonly occuring sample playbacks that could stack. /// @@ -80,8 +98,8 @@ namespace osu.Game public virtual bool UseDevelopmentServer => DebugUtils.IsDebugBuild; - internal EndpointConfiguration CreateEndpoints() => - UseDevelopmentServer ? (EndpointConfiguration)new DevelopmentEndpointConfiguration() : new ProductionEndpointConfiguration(); + public virtual EndpointConfiguration CreateEndpoints() => + UseDevelopmentServer ? new DevelopmentEndpointConfiguration() : new ExperimentalEndpointConfiguration(); public virtual Version AssemblyVersion => Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(); @@ -116,6 +134,8 @@ namespace osu.Game protected SessionStatics SessionStatics { get; private set; } + protected OsuColour Colours { get; private set; } + protected BeatmapManager BeatmapManager { get; private set; } protected BeatmapModelDownloader BeatmapDownloader { get; private set; } @@ -130,7 +150,7 @@ namespace osu.Game protected RealmKeyBindingStore KeyBindingStore { get; private set; } - protected MenuCursorContainer MenuCursorContainer { get; private set; } + protected GlobalCursorDisplay GlobalCursorDisplay { get; private set; } protected MusicController MusicController { get; private set; } @@ -140,9 +160,12 @@ namespace osu.Game protected Bindable Beatmap { get; private set; } // cached via load() method + /// + /// The current ruleset selection for the local user. + /// [Cached] [Cached(typeof(IBindable))] - protected readonly Bindable Ruleset = new Bindable(); + protected internal readonly Bindable Ruleset = new Bindable(); /// /// The current mod selection for the local user. @@ -159,36 +182,41 @@ namespace osu.Game /// /// Mods available for the current . /// - public readonly Bindable>> AvailableMods = new Bindable>>(); + public readonly Bindable>> AvailableMods = new Bindable>>(new Dictionary>()); private BeatmapDifficultyCache difficultyCache; + private BeatmapUpdater beatmapUpdater; private UserLookupCache userCache; private BeatmapLookupCache beatmapCache; private RulesetConfigCache rulesetConfigCache; - private SpectatorClient spectatorClient; + protected SpectatorClient SpectatorClient { get; private set; } - private MultiplayerClient multiplayerClient; + protected MultiplayerClient MultiplayerClient { get; private set; } + + private MetadataClient metadataClient; + private SoloStatisticsWatcher soloStatisticsWatcher; private RealmAccess realm; + protected SafeAreaContainer SafeAreaContainer { get; private set; } + + /// + /// For now, this is used as a source specifically for beat synced components. + /// Going forward, it could potentially be used as the single source-of-truth for beatmap timing. + /// + private readonly FramedBeatmapClock beatmapClock = new FramedBeatmapClock(true); + protected override Container Content => content; private Container content; private DependencyContainer dependencies; - private Bindable fpsDisplayVisible; - private readonly BindableNumber globalTrackVolumeAdjust = new BindableNumber(global_track_volume_adjust); - /// - /// A legacy EF context factory if migration has not been performed to realm yet. - /// - protected DatabaseContextFactory EFContextFactory { get; private set; } - /// /// Number of unhandled exceptions to allow before aborting execution. /// @@ -203,6 +231,10 @@ namespace osu.Game { Name = @"osu!"; +#if DEBUG + Name += " (development)"; +#endif + allowableExceptions = UnhandledExceptionsBeforeCrash; } @@ -223,49 +255,26 @@ namespace osu.Game Resources.AddStore(new DllResourceStore(OsuResources.ResourceAssembly)); - if (Storage.Exists(DatabaseContextFactory.DATABASE_NAME)) - dependencies.Cache(EFContextFactory = new DatabaseContextFactory(Storage)); - - dependencies.Cache(realm = new RealmAccess(Storage, CLIENT_DATABASE_FILENAME, Host.UpdateThread, EFContextFactory)); + dependencies.Cache(realm = new RealmAccess(Storage, CLIENT_DATABASE_FILENAME, Host.UpdateThread)); dependencies.CacheAs(RulesetStore = new RealmRulesetStore(realm, Storage)); dependencies.CacheAs(RulesetStore); Decoder.RegisterDependencies(RulesetStore); - // Backup is taken here rather than in EFToRealmMigrator to avoid recycling realm contexts - // after initial usages below. It can be moved once a direction is established for handling re-subscription. - // See https://github.com/ppy/osu/pull/16547 for more discussion. - if (EFContextFactory != null) - { - const string backup_folder = "backups"; - - string migration = $"before_final_migration_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"; - - EFContextFactory.CreateBackup(Path.Combine(backup_folder, $"client.{migration}.db")); - realm.CreateBackup(Path.Combine(backup_folder, $"client.{migration}.realm")); - - using (var source = Storage.GetStream("collection.db")) - { - if (source != null) - { - using (var destination = Storage.CreateFileSafely(Path.Combine(backup_folder, $"collection.{migration}.db"))) - source.CopyTo(destination); - } - } - } - dependencies.CacheAs(Storage); - var largeStore = new LargeTextureStore(Host.CreateTextureLoaderStore(new NamespacedResourceStore(Resources, @"Textures"))); - largeStore.AddStore(Host.CreateTextureLoaderStore(new OnlineStore())); + var largeStore = new LargeTextureStore(Host.Renderer, Host.CreateTextureLoaderStore(new NamespacedResourceStore(Resources, @"Textures"))); + largeStore.AddTextureSource(Host.CreateTextureLoaderStore(new OnlineStore())); dependencies.Cache(largeStore); - dependencies.CacheAs(this); dependencies.CacheAs(LocalConfig); + dependencies.CacheAs(LocalConfig); InitialiseFonts(); + addFilesWarning(); + Audio.Samples.PlaybackConcurrency = SAMPLE_CONCURRENCY; dependencies.Cache(SkinManager = new SkinManager(Storage, realm, Host, Resources, Audio, Scheduler)); @@ -277,30 +286,41 @@ namespace osu.Game dependencies.CacheAs(API ??= new APIAccess(LocalConfig, endpoints, VersionHash)); - dependencies.CacheAs(spectatorClient = new OnlineSpectatorClient(endpoints)); - dependencies.CacheAs(multiplayerClient = new OnlineMultiplayerClient(endpoints)); - var defaultBeatmap = new DummyWorkingBeatmap(Audio, Textures); + dependencies.Cache(difficultyCache = new BeatmapDifficultyCache()); + // ordering is important here to ensure foreign keys rules are not broken in ModelStore.Cleanup() - dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, realm, Scheduler, () => difficultyCache, LocalConfig)); - dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, realm, RulesetStore, API, Audio, Resources, Host, defaultBeatmap, performOnlineLookups: true)); + dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, realm, API, LocalConfig)); + + dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, realm, API, Audio, Resources, Host, defaultBeatmap, difficultyCache, performOnlineLookups: true)); dependencies.Cache(BeatmapDownloader = new BeatmapModelDownloader(BeatmapManager, API)); dependencies.Cache(ScoreDownloader = new ScoreModelDownloader(ScoreManager, API)); - dependencies.Cache(difficultyCache = new BeatmapDifficultyCache()); - AddInternal(difficultyCache); + // Add after all the above cache operations as it depends on them. + base.Content.Add(difficultyCache); + + // TODO: OsuGame or OsuGameBase? + dependencies.CacheAs(beatmapUpdater = new BeatmapUpdater(BeatmapManager, difficultyCache, API, Storage)); + dependencies.CacheAs(SpectatorClient = new OnlineSpectatorClient(endpoints)); + dependencies.CacheAs(MultiplayerClient = new OnlineMultiplayerClient(endpoints)); + dependencies.CacheAs(metadataClient = new OnlineMetadataClient(endpoints)); + dependencies.CacheAs(soloStatisticsWatcher = new SoloStatisticsWatcher()); + + base.Content.Add(new BeatmapOnlineChangeIngest(beatmapUpdater, realm, metadataClient)); + + BeatmapManager.ProcessBeatmap = (beatmapSet, scope) => beatmapUpdater.Process(beatmapSet, scope); dependencies.Cache(userCache = new UserLookupCache()); - AddInternal(userCache); + base.Content.Add(userCache); dependencies.Cache(beatmapCache = new BeatmapLookupCache()); - AddInternal(beatmapCache); + base.Content.Add(beatmapCache); var scorePerformanceManager = new ScorePerformanceCache(); dependencies.Cache(scorePerformanceManager); - AddInternal(scorePerformanceManager); + base.Content.Add(scorePerformanceManager); dependencies.CacheAs(rulesetConfigCache = new RulesetConfigCache(realm, RulesetStore)); @@ -309,7 +329,7 @@ namespace osu.Game dependencies.CacheAs(powerStatus); dependencies.Cache(SessionStatics = new SessionStatics()); - dependencies.Cache(new OsuColour()); + dependencies.Cache(Colours = new OsuColour()); RegisterImportHandler(BeatmapManager); RegisterImportHandler(ScoreManager); @@ -327,24 +347,37 @@ namespace osu.Game // add api components to hierarchy. if (API is APIAccess apiAccess) - AddInternal(apiAccess); - AddInternal(spectatorClient); - AddInternal(multiplayerClient); + base.Content.Add(apiAccess); - AddInternal(rulesetConfigCache); + base.Content.Add(SpectatorClient); + base.Content.Add(MultiplayerClient); + base.Content.Add(metadataClient); + base.Content.Add(soloStatisticsWatcher); + + base.Content.Add(rulesetConfigCache); + + PreviewTrackManager previewTrackManager; + dependencies.Cache(previewTrackManager = new PreviewTrackManager(BeatmapManager.BeatmapTrackStore)); + base.Content.Add(previewTrackManager); + + base.Content.Add(MusicController = new MusicController()); + dependencies.CacheAs(MusicController); + + MusicController.TrackChanged += onTrackChanged; + base.Content.Add(beatmapClock); GlobalActionContainer globalBindings; - base.Content.Add(new SafeAreaContainer + base.Content.Add(SafeAreaContainer = new SafeAreaContainer { SafeAreaOverrideEdges = SafeAreaOverrideEdges, RelativeSizeAxes = Axes.Both, Child = CreateScalingContainer().WithChildren(new Drawable[] { - (MenuCursorContainer = new MenuCursorContainer + (GlobalCursorDisplay = new GlobalCursorDisplay { RelativeSizeAxes = Axes.Both - }).WithChild(content = new OsuTooltipContainer(MenuCursorContainer.Cursor) + }).WithChild(content = new OsuTooltipContainer(GlobalCursorDisplay.MenuCursor) { RelativeSizeAxes = Axes.Both }), @@ -358,17 +391,44 @@ namespace osu.Game dependencies.Cache(globalBindings); - PreviewTrackManager previewTrackManager; - dependencies.Cache(previewTrackManager = new PreviewTrackManager(BeatmapManager.BeatmapTrackStore)); - Add(previewTrackManager); - - AddInternal(MusicController = new MusicController()); - dependencies.CacheAs(MusicController); - Ruleset.BindValueChanged(onRulesetChanged); Beatmap.BindValueChanged(onBeatmapChanged); } + private void addFilesWarning() + { + var realmStore = new RealmFileStore(realm, Storage); + + const string filename = "IMPORTANT READ ME.txt"; + + if (!realmStore.Storage.Exists(filename)) + { + using (var stream = realmStore.Storage.CreateFileSafely(filename)) + using (var textWriter = new StreamWriter(stream)) + { + textWriter.WriteLine(@"This folder contains all your user files (beatmaps, skins, replays etc.)"); + textWriter.WriteLine(@"Please do not touch or delete this folder!!"); + textWriter.WriteLine(); + textWriter.WriteLine(@"If you are really looking to completely delete user data, please delete"); + textWriter.WriteLine(@"the parent folder including all other files and directories"); + textWriter.WriteLine(); + textWriter.WriteLine(@"For more information on how these files are organised,"); + textWriter.WriteLine(@"see https://github.com/ppy/osu/wiki/User-file-storage"); + } + } + } + + private void onTrackChanged(WorkingBeatmap beatmap, TrackChangeDirection direction) + { + // FramedBeatmapClock uses a decoupled clock internally which will mutate the source if it is an `IAdjustableClock`. + // We don't want this for now, as the intention of beatmapClock is to be a read-only source for beat sync components. + // + // Encapsulating in a FramedClock will avoid any mutations. + var framedClock = new FramedClock(beatmap.Track); + + beatmapClock.ChangeSource(framedClock); + } + protected virtual void InitialiseFonts() { AddFont(Resources, @"Fonts/osuFont"); @@ -403,19 +463,6 @@ namespace osu.Game AddFont(Resources, @"Fonts/Venera/Venera-Black"); } - protected override void LoadComplete() - { - base.LoadComplete(); - - // TODO: This is temporary until we reimplement the local FPS display. - // It's just to allow end-users to access the framework FPS display without knowing the shortcut key. - fpsDisplayVisible = LocalConfig.GetBindable(OsuSetting.ShowFpsDisplay); - fpsDisplayVisible.ValueChanged += visible => { FrameStatistics.Value = visible.NewValue ? FrameStatisticsMode.Minimal : FrameStatisticsMode.None; }; - fpsDisplayVisible.TriggerChange(); - - FrameStatistics.ValueChanged += e => fpsDisplayVisible.Value = e.NewValue != FrameStatisticsMode.None; - } - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); @@ -435,14 +482,15 @@ namespace osu.Game /// /// Use to programatically exit the game as if the user was triggering via alt-f4. - /// Will keep persisting until an exit occurs (exit may be blocked multiple times). + /// By default, will keep persisting until an exit occurs (exit may be blocked multiple times). + /// May be interrupted (see 's override). /// - public void GracefullyExit() + public virtual void AttemptExit() { if (!OnExiting()) Exit(); else - Scheduler.AddDelayed(GracefullyExit, 2000); + Scheduler.AddDelayed(AttemptExit, 2000); } public bool Migrate(string path) @@ -457,12 +505,13 @@ namespace osu.Game Scheduler.Add(() => { - realmBlocker = realm.BlockAllOperations(); + realmBlocker = realm.BlockAllOperations("migration"); readyToRun.Set(); }, false); - readyToRun.Wait(); + if (!readyToRun.Wait(30000)) + throw new TimeoutException("Attempting to block for migration took too long."); bool? cleanupSucceded = (Storage as OsuStorage)?.Migrate(Host.GetStorage(path)); @@ -483,10 +532,52 @@ namespace osu.Game protected override Storage CreateStorage(GameHost host, Storage defaultStorage) => new OsuStorage(host, defaultStorage); - private void onBeatmapChanged(ValueChangedEvent valueChangedEvent) + /// + /// Creates an input settings subsection for an . + /// + /// Should be overriden per-platform to provide settings for platform-specific handlers. + public virtual SettingsSubsection CreateSettingsSubsectionFor(InputHandler handler) + { + // One would think that this could be moved to the `OsuGameDesktop` class, but doing so means that + // OsuGameTestScenes will not show any input options (as they are based on OsuGame not OsuGameDesktop). + // + // This in turn makes it hard for ruleset creators to adjust input settings while testing their ruleset + // within the test browser interface. + if (RuntimeInfo.IsDesktop) + { + switch (handler) + { + case ITabletHandler th: + return new TabletSettings(th); + + case MouseHandler mh: + return new MouseSettings(mh); + + case JoystickHandler jh: + return new JoystickSettings(jh); + + case TouchHandler th: + return new TouchSettings(th); + } + } + + switch (handler) + { + case MidiHandler: + return new InputSection.HandlerSection(handler); + + // return null for handlers that shouldn't have settings. + default: + return null; + } + } + + private void onBeatmapChanged(ValueChangedEvent beatmap) { if (IsLoaded && !ThreadSafety.IsUpdateThread) throw new InvalidOperationException("Global beatmap bindable must be changed from update thread."); + + Logger.Log($"Game-wide working beatmap updated to {beatmap.NewValue}"); } private void onRulesetChanged(ValueChangedEvent r) @@ -511,21 +602,41 @@ namespace osu.Game if (instance == null) { // reject the change if the ruleset is not available. - Ruleset.Value = r.OldValue?.Available == true ? r.OldValue : RulesetStore.AvailableRulesets.First(); + revertRulesetChange(); return; } var dict = new Dictionary>(); - foreach (ModType type in Enum.GetValues(typeof(ModType))) + try { - dict[type] = instance.GetModsFor(type).ToList(); + foreach (ModType type in Enum.GetValues()) + { + dict[type] = instance.GetModsFor(type) + // Rulesets should never return null mods, but let's be defensive just in case. + // ReSharper disable once ConditionIsAlwaysTrueOrFalse + .Where(mod => mod != null) + .ToList(); + } } + catch (Exception e) + { + Logger.Error(e, $"Could not load mods for \"{instance.RulesetInfo.Name}\" ruleset. Current ruleset has been rolled back."); + revertRulesetChange(); + 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(); + + void revertRulesetChange() => Ruleset.Value = r.OldValue?.Available == true ? r.OldValue : RulesetStore.AvailableRulesets.First(); } private int allowableExceptions; @@ -550,17 +661,18 @@ namespace osu.Game base.Dispose(isDisposing); RulesetStore?.Dispose(); - BeatmapManager?.Dispose(); LocalConfig?.Dispose(); + beatmapUpdater?.Dispose(); + realm?.Dispose(); if (Host != null) Host.ExceptionThrown -= onExceptionThrown; } - ControlPointInfo IBeatSyncProvider.ControlPoints => Beatmap.Value.Beatmap.ControlPointInfo; - IClock IBeatSyncProvider.Clock => Beatmap.Value.TrackLoaded ? Beatmap.Value.Track : (IClock)null; - ChannelAmplitudes? IBeatSyncProvider.Amplitudes => Beatmap.Value.TrackLoaded ? Beatmap.Value.Track.CurrentAmplitudes : (ChannelAmplitudes?)null; + ControlPointInfo IBeatSyncProvider.ControlPoints => Beatmap.Value.BeatmapLoaded ? Beatmap.Value.Beatmap.ControlPointInfo : null; + IClock IBeatSyncProvider.Clock => beatmapClock; + ChannelAmplitudes IHasAmplitudes.CurrentAmplitudes => Beatmap.Value.TrackLoaded ? Beatmap.Value.Track.CurrentAmplitudes : ChannelAmplitudes.Empty; } } diff --git a/osu.Game/OsuGameBase_Importing.cs b/osu.Game/OsuGameBase_Importing.cs index d712da3553..cf65460bab 100644 --- a/osu.Game/OsuGameBase_Importing.cs +++ b/osu.Game/OsuGameBase_Importing.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.IO; using System.Linq; @@ -42,13 +44,13 @@ namespace osu.Game } } - public virtual async Task Import(params ImportTask[] tasks) + public virtual async Task Import(ImportTask[] tasks, ImportParameters parameters = default) { var tasksPerExtension = tasks.GroupBy(t => Path.GetExtension(t.Path).ToLowerInvariant()); await Task.WhenAll(tasksPerExtension.Select(taskGroup => { var importer = fileImporters.FirstOrDefault(i => i.HandledExtensions.Contains(taskGroup.Key)); - return importer?.Import(taskGroup.ToArray()) ?? Task.CompletedTask; + return importer?.Import(taskGroup.ToArray(), parameters) ?? Task.CompletedTask; })).ConfigureAwait(false); } diff --git a/osu.Game/Overlays/AccountCreation/AccountCreationBackground.cs b/osu.Game/Overlays/AccountCreation/AccountCreationBackground.cs index 2e52ca74f3..0042b9f8f6 100644 --- a/osu.Game/Overlays/AccountCreation/AccountCreationBackground.cs +++ b/osu.Game/Overlays/AccountCreation/AccountCreationBackground.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; @@ -8,7 +10,7 @@ using osu.Framework.Graphics.Textures; namespace osu.Game.Overlays.AccountCreation { - public class AccountCreationBackground : Sprite + public partial class AccountCreationBackground : Sprite { public AccountCreationBackground() { diff --git a/osu.Game/Overlays/AccountCreation/AccountCreationScreen.cs b/osu.Game/Overlays/AccountCreation/AccountCreationScreen.cs index 6aef358b2e..a7dd53f511 100644 --- a/osu.Game/Overlays/AccountCreation/AccountCreationScreen.cs +++ b/osu.Game/Overlays/AccountCreation/AccountCreationScreen.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Screens; namespace osu.Game.Overlays.AccountCreation { - public abstract class AccountCreationScreen : Screen + public abstract partial class AccountCreationScreen : Screen { public override void OnEntering(ScreenTransitionEvent e) { diff --git a/osu.Game/Overlays/AccountCreation/ScreenEntry.cs b/osu.Game/Overlays/AccountCreation/ScreenEntry.cs index f4f958e4a4..219cbe7eef 100644 --- a/osu.Game/Overlays/AccountCreation/ScreenEntry.cs +++ b/osu.Game/Overlays/AccountCreation/ScreenEntry.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using System.Threading.Tasks; @@ -23,7 +25,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.AccountCreation { - public class ScreenEntry : AccountCreationScreen + public partial class ScreenEntry : AccountCreationScreen { private ErrorTextFlowContainer usernameDescription; private ErrorTextFlowContainer emailAddressDescription; @@ -45,6 +47,9 @@ namespace osu.Game.Overlays.AccountCreation [Resolved] private GameHost host { get; set; } + [Resolved] + private OsuGame game { get; set; } + [BackgroundDependencyLoader] private void load() { @@ -192,9 +197,20 @@ namespace osu.Game.Overlays.AccountCreation { if (errors != null) { - usernameDescription.AddErrors(errors.User.Username); - emailAddressDescription.AddErrors(errors.User.Email); - passwordDescription.AddErrors(errors.User.Password); + if (errors.User != null) + { + usernameDescription.AddErrors(errors.User.Username); + emailAddressDescription.AddErrors(errors.User.Email); + passwordDescription.AddErrors(errors.User.Password); + } + + if (!string.IsNullOrEmpty(errors.Redirect)) + { + if (!string.IsNullOrEmpty(errors.Message)) + passwordDescription.AddErrors(new[] { errors.Message }); + + game.OpenUrlExternally($"{errors.Redirect}?username={usernameTextBox.Text}&email={emailTextBox.Text}", true); + } } else { diff --git a/osu.Game/Overlays/AccountCreation/ScreenWarning.cs b/osu.Game/Overlays/AccountCreation/ScreenWarning.cs index 780a79f8f9..a833a871f9 100644 --- a/osu.Game/Overlays/AccountCreation/ScreenWarning.cs +++ b/osu.Game/Overlays/AccountCreation/ScreenWarning.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -18,7 +20,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.AccountCreation { - public class ScreenWarning : AccountCreationScreen + public partial class ScreenWarning : AccountCreationScreen { private OsuTextFlowContainer multiAccountExplanationText; private LinkFlowContainer furtherAssistance; diff --git a/osu.Game/Overlays/AccountCreation/ScreenWelcome.cs b/osu.Game/Overlays/AccountCreation/ScreenWelcome.cs index f6ddb135ec..4becb225f8 100644 --- a/osu.Game/Overlays/AccountCreation/ScreenWelcome.cs +++ b/osu.Game/Overlays/AccountCreation/ScreenWelcome.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -13,7 +15,7 @@ using osuTK; namespace osu.Game.Overlays.AccountCreation { - public class ScreenWelcome : AccountCreationScreen + public partial class ScreenWelcome : AccountCreationScreen { [BackgroundDependencyLoader] private void load() diff --git a/osu.Game/Overlays/AccountCreationOverlay.cs b/osu.Game/Overlays/AccountCreationOverlay.cs index a96aff2a5d..6f79316670 100644 --- a/osu.Game/Overlays/AccountCreationOverlay.cs +++ b/osu.Game/Overlays/AccountCreationOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -18,7 +20,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays { - public class AccountCreationOverlay : OsuFocusedOverlayContainer + public partial class AccountCreationOverlay : OsuFocusedOverlayContainer { private const float transition_time = 400; diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingCardSizeTabControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingCardSizeTabControl.cs index 1f9a63e3b9..feb0c27ee7 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingCardSizeTabControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingCardSizeTabControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -15,7 +17,7 @@ using osuTK; namespace osu.Game.Overlays.BeatmapListing { - public class BeatmapListingCardSizeTabControl : OsuTabControl + public partial class BeatmapListingCardSizeTabControl : OsuTabControl { public BeatmapListingCardSizeTabControl() { @@ -33,7 +35,7 @@ namespace osu.Game.Overlays.BeatmapListing protected override TabItem CreateTabItem(BeatmapCardSize value) => new TabItem(value); - private class TabItem : TabItem + private partial class TabItem : TabItem { private Box background; private SpriteIcon icon; diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs index e4628e3723..37a29b1c50 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -24,7 +26,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.BeatmapListing { - public class BeatmapListingFilterControl : CompositeDrawable + public partial class BeatmapListingFilterControl : CompositeDrawable { /// /// Fired when a search finishes. @@ -141,7 +143,13 @@ namespace osu.Game.Overlays.BeatmapListing } public void Search(string query) - => searchControl.Query.Value = query; + => Schedule(() => searchControl.Query.Value = query); + + public void FilterGenre(SearchGenre genre) + => Schedule(() => searchControl.Genre.Value = genre); + + public void FilterLanguage(SearchLanguage language) + => Schedule(() => searchControl.Language.Value = language); protected override void LoadComplete() { @@ -149,28 +157,29 @@ namespace osu.Game.Overlays.BeatmapListing config.BindWith(OsuSetting.BeatmapListingCardSize, cardSize); - var sortCriteria = sortControl.Current; - var sortDirection = sortControl.SortDirection; - - searchControl.Query.BindValueChanged(query => + searchControl.Query.BindValueChanged(_ => { - sortCriteria.Value = string.IsNullOrEmpty(query.NewValue) ? SortCriteria.Ranked : SortCriteria.Relevance; - sortDirection.Value = SortDirection.Descending; + resetSortControl(); queueUpdateSearch(true); }); - searchControl.General.CollectionChanged += (_, __) => queueUpdateSearch(); + searchControl.Category.BindValueChanged(_ => + { + resetSortControl(); + queueUpdateSearch(); + }); + + searchControl.General.CollectionChanged += (_, _) => queueUpdateSearch(); searchControl.Ruleset.BindValueChanged(_ => queueUpdateSearch()); - searchControl.Category.BindValueChanged(_ => queueUpdateSearch()); searchControl.Genre.BindValueChanged(_ => queueUpdateSearch()); searchControl.Language.BindValueChanged(_ => queueUpdateSearch()); - searchControl.Extra.CollectionChanged += (_, __) => queueUpdateSearch(); - searchControl.Ranks.CollectionChanged += (_, __) => queueUpdateSearch(); + searchControl.Extra.CollectionChanged += (_, _) => queueUpdateSearch(); + searchControl.Ranks.CollectionChanged += (_, _) => queueUpdateSearch(); searchControl.Played.BindValueChanged(_ => queueUpdateSearch()); searchControl.ExplicitContent.BindValueChanged(_ => queueUpdateSearch()); - sortCriteria.BindValueChanged(_ => queueUpdateSearch()); - sortDirection.BindValueChanged(_ => queueUpdateSearch()); + sortControl.Current.BindValueChanged(_ => queueUpdateSearch()); + sortControl.SortDirection.BindValueChanged(_ => queueUpdateSearch()); apiUser = api.LocalUser.GetBoundCopy(); apiUser.BindValueChanged(_ => queueUpdateSearch()); @@ -197,6 +206,8 @@ namespace osu.Game.Overlays.BeatmapListing performRequest(); } + private void resetSortControl() => sortControl.Reset(searchControl.Category.Value, !string.IsNullOrEmpty(searchControl.Query.Value)); + private void queueUpdateSearch(bool queryTextChanged = false) { SearchStarted?.Invoke(); diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs index 3568fe9e4f..3336c383ff 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs @@ -1,16 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using osu.Framework.Graphics; using osu.Game.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapListing { - public class BeatmapListingHeader : OverlayHeader + public partial class BeatmapListingHeader : OverlayHeader { + public BeatmapListingFilterControl FilterControl { get; private set; } + protected override OverlayTitle CreateTitle() => new BeatmapListingTitle(); - private class BeatmapListingTitle : OverlayTitle + 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 eeaa31a013..3fa0fc7a77 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -22,7 +24,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.BeatmapListing { - public class BeatmapListingSearchControl : CompositeDrawable + public partial class BeatmapListingSearchControl : CompositeDrawable { /// /// Any time the text box receives key events (even while masked). @@ -105,7 +107,7 @@ namespace osu.Game.Overlays.BeatmapListing Padding = new MarginPadding { Vertical = 20, - Horizontal = 40, + Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING, }, Child = new FillFlowContainer { @@ -144,6 +146,7 @@ namespace osu.Game.Overlays.BeatmapListing } }); + generalFilter.Current.Add(SearchGeneral.FeaturedArtists); categoryFilter.Current.Value = SearchCategory.Leaderboard; } @@ -163,7 +166,7 @@ namespace osu.Game.Overlays.BeatmapListing public void TakeFocus() => textBox.TakeFocus(); - private class BeatmapSearchTextBox : BasicSearchTextBox + private partial class BeatmapSearchTextBox : BasicSearchTextBox { /// /// Any time the text box receives key events (even while masked). @@ -196,7 +199,7 @@ namespace osu.Game.Overlays.BeatmapListing } } - private class TopSearchBeatmapSetCover : UpdateableOnlineBeatmapSetCover + private partial class TopSearchBeatmapSetCover : UpdateableOnlineBeatmapSetCover { protected override bool TransformImmediately => true; } diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingSortTabControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingSortTabControl.cs index 4c77a736ac..2f290d05e9 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingSortTabControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingSortTabControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; @@ -11,22 +13,73 @@ using osu.Framework.Input.Events; namespace osu.Game.Overlays.BeatmapListing { - public class BeatmapListingSortTabControl : OverlaySortTabControl + public partial class BeatmapListingSortTabControl : OverlaySortTabControl { public readonly Bindable SortDirection = new Bindable(Overlays.SortDirection.Descending); - public BeatmapListingSortTabControl() + private (SearchCategory category, bool hasQuery)? currentParameters; + + protected override void LoadComplete() { - Current.Value = SortCriteria.Ranked; + base.LoadComplete(); + + if (currentParameters == null) + Reset(SearchCategory.Leaderboard, false); + + Current.BindValueChanged(_ => SortDirection.Value = Overlays.SortDirection.Descending); + } + + public void Reset(SearchCategory category, bool hasQuery) + { + var newParameters = (category, hasQuery); + + if (currentParameters != newParameters) + { + TabControl.Clear(); + + TabControl.AddItem(SortCriteria.Title); + TabControl.AddItem(SortCriteria.Artist); + TabControl.AddItem(SortCriteria.Difficulty); + + if (category == SearchCategory.Any || category > SearchCategory.Loved) + TabControl.AddItem(SortCriteria.Updated); + + if (category < SearchCategory.Pending || category == SearchCategory.Mine) + TabControl.AddItem(SortCriteria.Ranked); + + TabControl.AddItem(SortCriteria.Rating); + TabControl.AddItem(SortCriteria.Plays); + TabControl.AddItem(SortCriteria.Favourites); + + if (hasQuery) + TabControl.AddItem(SortCriteria.Relevance); + + if (category == SearchCategory.Pending) + TabControl.AddItem(SortCriteria.Nominations); + } + + var nonQueryCriteria = category >= SearchCategory.Pending ? SortCriteria.Updated : SortCriteria.Ranked; + + Current.Value = hasQuery ? SortCriteria.Relevance : nonQueryCriteria; + SortDirection.Value = Overlays.SortDirection.Descending; + + // if the new criteria isn't different from the previous one, + // then re-adding tab items will not mark the current tab as selected. + // see: https://github.com/ppy/osu-framework/issues/5412 + TabControl.Current.TriggerChange(); + + currentParameters = newParameters; } protected override SortTabControl CreateControl() => new BeatmapSortTabControl { - SortDirection = { BindTarget = SortDirection } + SortDirection = { BindTarget = SortDirection }, }; - private class BeatmapSortTabControl : SortTabControl + private partial class BeatmapSortTabControl : SortTabControl { + protected override bool AddEnumEntriesAutomatically => false; + public readonly Bindable SortDirection = new Bindable(); protected override TabItem CreateTabItem(SortCriteria value) => new BeatmapSortTabItem(value) @@ -35,7 +88,7 @@ namespace osu.Game.Overlays.BeatmapListing }; } - private class BeatmapSortTabItem : SortTabItem + private partial class BeatmapSortTabItem : SortTabItem { public readonly Bindable SortDirection = new Bindable(); @@ -51,7 +104,7 @@ namespace osu.Game.Overlays.BeatmapListing }; } - private class BeatmapTabButton : TabButton + public partial class BeatmapTabButton : TabButton { public readonly Bindable SortDirection = new Bindable(); @@ -85,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/BeatmapListing/BeatmapSearchFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs index 4c831543fe..3ab0e47a6c 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -16,7 +18,7 @@ using osu.Framework.Localisation; namespace osu.Game.Overlays.BeatmapListing { - public class BeatmapSearchFilterRow : CompositeDrawable, IHasCurrentValue + public partial class BeatmapSearchFilterRow : CompositeDrawable, IHasCurrentValue { private readonly BindableWithCurrent current = new BindableWithCurrent(); @@ -67,7 +69,7 @@ namespace osu.Game.Overlays.BeatmapListing [NotNull] protected virtual Drawable CreateFilter() => new BeatmapSearchFilter(); - protected class BeatmapSearchFilter : TabControl + protected partial class BeatmapSearchFilter : TabControl { public BeatmapSearchFilter() { @@ -96,7 +98,7 @@ namespace osu.Game.Overlays.BeatmapListing protected override TabItem CreateTabItem(T value) => new FilterTabItem(value); - private class FilterDropdown : OsuTabDropdown + private partial class FilterDropdown : OsuTabDropdown { protected override DropdownHeader CreateHeader() => new FilterHeader { @@ -104,7 +106,7 @@ namespace osu.Game.Overlays.BeatmapListing Origin = Anchor.TopRight }; - private class FilterHeader : OsuTabDropdownHeader + private partial class FilterHeader : OsuTabDropdownHeader { public FilterHeader() { diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchGeneralFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchGeneralFilterRow.cs index 51dad100c2..a4a914db55 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapSearchGeneralFilterRow.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchGeneralFilterRow.cs @@ -1,14 +1,24 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System; using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Configuration; using osu.Game.Graphics; +using osu.Game.Localisation; +using osu.Game.Overlays.Dialog; using osu.Game.Resources.Localisation.Web; using osuTK.Graphics; +using CommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; namespace osu.Game.Overlays.BeatmapListing { - public class BeatmapSearchGeneralFilterRow : BeatmapSearchMultipleSelectionFilterRow + public partial class BeatmapSearchGeneralFilterRow : BeatmapSearchMultipleSelectionFilterRow { public BeatmapSearchGeneralFilterRow() : base(BeatmapsStrings.ListingSearchFiltersGeneral) @@ -17,7 +27,7 @@ namespace osu.Game.Overlays.BeatmapListing protected override MultipleSelectionFilter CreateMultipleSelectionFilter() => new GeneralFilter(); - private class GeneralFilter : MultipleSelectionFilter + private partial class GeneralFilter : MultipleSelectionFilter { protected override MultipleSelectionFilterTabItem CreateTabItem(SearchGeneral value) { @@ -28,8 +38,10 @@ namespace osu.Game.Overlays.BeatmapListing } } - private class FeaturedArtistsTabItem : MultipleSelectionFilterTabItem + private partial class FeaturedArtistsTabItem : MultipleSelectionFilterTabItem { + private Bindable disclaimerShown; + public FeaturedArtistsTabItem() : base(SearchGeneral.FeaturedArtists) { @@ -38,7 +50,60 @@ namespace osu.Game.Overlays.BeatmapListing [Resolved] private OsuColour colours { get; set; } + [Resolved] + private SessionStatics sessionStatics { get; set; } + + [Resolved(canBeNull: true)] + private IDialogOverlay dialogOverlay { get; set; } + protected override Color4 GetStateColour() => colours.Orange1; + + protected override void LoadComplete() + { + base.LoadComplete(); + + disclaimerShown = sessionStatics.GetBindable(Static.FeaturedArtistDisclaimerShownOnce); + } + + protected override bool OnClick(ClickEvent e) + { + if (!disclaimerShown.Value && dialogOverlay != null) + { + dialogOverlay.Push(new FeaturedArtistConfirmDialog(() => + { + disclaimerShown.Value = true; + base.OnClick(e); + })); + + return true; + } + + return base.OnClick(e); + } + } + } + + internal partial class FeaturedArtistConfirmDialog : PopupDialog + { + public FeaturedArtistConfirmDialog(Action confirm) + { + HeaderText = BeatmapOverlayStrings.UserContentDisclaimerHeader; + BodyText = BeatmapOverlayStrings.UserContentDisclaimerDescription; + + Icon = FontAwesome.Solid.ExclamationTriangle; + + Buttons = new PopupDialogButton[] + { + new PopupDialogDangerousButton + { + Text = BeatmapOverlayStrings.UserContentConfirmButtonText, + Action = confirm + }, + new PopupDialogCancelButton + { + Text = CommonStrings.ButtonsCancel, + }, + }; } } } diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchMultipleSelectionFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchMultipleSelectionFilterRow.cs index 461a06a634..abd2643a41 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapSearchMultipleSelectionFilterRow.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchMultipleSelectionFilterRow.cs @@ -1,21 +1,26 @@ // 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.Collections.Specialized; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osuTK; namespace osu.Game.Overlays.BeatmapListing { - public class BeatmapSearchMultipleSelectionFilterRow : BeatmapSearchFilterRow> + public partial class BeatmapSearchMultipleSelectionFilterRow : BeatmapSearchFilterRow> + where T : Enum { public new readonly BindableList Current = new BindableList(); @@ -29,7 +34,7 @@ namespace osu.Game.Overlays.BeatmapListing [BackgroundDependencyLoader] private void load() { - Current.BindTo(filter.Current); + filter.Current.BindTo(Current); } protected sealed override Drawable CreateFilter() => filter = CreateMultipleSelectionFilter(); @@ -40,7 +45,7 @@ namespace osu.Game.Overlays.BeatmapListing [NotNull] protected virtual MultipleSelectionFilter CreateMultipleSelectionFilter() => new MultipleSelectionFilter(); - protected class MultipleSelectionFilter : FillFlowContainer + protected partial class MultipleSelectionFilter : FillFlowContainer { public readonly BindableList Current = new BindableList(); @@ -62,6 +67,14 @@ namespace osu.Game.Overlays.BeatmapListing foreach (var item in Children) item.Active.BindValueChanged(active => toggleItem(item.Value, active.NewValue)); + + Current.BindCollectionChanged(currentChanged, true); + } + + private void currentChanged(object sender, NotifyCollectionChangedEventArgs e) + { + foreach (var c in Children) + c.Active.Value = Current.Contains(c.Value); } /// @@ -77,17 +90,41 @@ namespace osu.Game.Overlays.BeatmapListing private void toggleItem(T value, bool active) { if (active) - Current.Add(value); + { + if (!Current.Contains(value)) + Current.Add(value); + } else Current.Remove(value); } } - protected class MultipleSelectionFilterTabItem : FilterTabItem + protected partial class MultipleSelectionFilterTabItem : FilterTabItem { + private readonly Box selectedUnderline; + + protected override bool HighlightOnHoverWhenActive => true; + public MultipleSelectionFilterTabItem(T value) : base(value) { + // This doesn't match any actual design, but should make it easier for the user to understand + // that filters are applied until we settle on a final design. + AddInternal(selectedUnderline = new Box + { + Depth = float.MaxValue, + RelativeSizeAxes = Axes.X, + Height = 1.5f, + Anchor = Anchor.BottomLeft, + Origin = Anchor.CentreLeft, + }); + } + + protected override void UpdateState() + { + base.UpdateState(); + selectedUnderline.FadeTo(Active.Value ? 1 : 0, 200, Easing.OutQuint); + selectedUnderline.FadeColour(IsHovered ? ColourProvider.Content2 : GetStateColour(), 200, Easing.OutQuint); } protected override bool OnClick(ClickEvent e) diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchRulesetFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchRulesetFilterRow.cs index e2c84c537c..96626d0ac6 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapSearchRulesetFilterRow.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchRulesetFilterRow.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 osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; +using osu.Game.Extensions; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; namespace osu.Game.Overlays.BeatmapListing { - public class BeatmapSearchRulesetFilterRow : BeatmapSearchFilterRow + public partial class BeatmapSearchRulesetFilterRow : BeatmapSearchFilterRow { public BeatmapSearchRulesetFilterRow() : base(BeatmapsStrings.ListingSearchFiltersMode) @@ -18,7 +21,7 @@ namespace osu.Game.Overlays.BeatmapListing protected override Drawable CreateFilter() => new RulesetFilter(); - private class RulesetFilter : BeatmapSearchFilter + private partial class RulesetFilter : BeatmapSearchFilter { [BackgroundDependencyLoader] private void load(RulesetStore rulesets) @@ -26,11 +29,17 @@ namespace osu.Game.Overlays.BeatmapListing AddTabItem(new RulesetFilterTabItemAny()); foreach (var r in rulesets.AvailableRulesets) + { + // Don't display non-legacy rulesets + if (!r.IsLegacyRuleset()) + continue; + AddItem(r); + } } } - private class RulesetFilterTabItemAny : FilterTabItem + private partial class RulesetFilterTabItemAny : FilterTabItem { protected override LocalisableString LabelFor(RulesetInfo info) => BeatmapsStrings.ModeAny; diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchScoreFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchScoreFilterRow.cs index b39934b56f..031833a107 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapSearchScoreFilterRow.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchScoreFilterRow.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Extensions; @@ -10,7 +12,7 @@ using osu.Game.Scoring; namespace osu.Game.Overlays.BeatmapListing { - public class BeatmapSearchScoreFilterRow : BeatmapSearchMultipleSelectionFilterRow + public partial class BeatmapSearchScoreFilterRow : BeatmapSearchMultipleSelectionFilterRow { public BeatmapSearchScoreFilterRow() : base(BeatmapsStrings.ListingSearchFiltersRank) @@ -19,14 +21,14 @@ namespace osu.Game.Overlays.BeatmapListing protected override MultipleSelectionFilter CreateMultipleSelectionFilter() => new RankFilter(); - private class RankFilter : MultipleSelectionFilter + private partial class RankFilter : MultipleSelectionFilter { protected override MultipleSelectionFilterTabItem CreateTabItem(ScoreRank value) => new RankItem(value); - protected override IEnumerable GetValues() => base.GetValues().Reverse(); + protected override IEnumerable GetValues() => base.GetValues().Where(r => r > ScoreRank.F).Reverse(); } - private class RankItem : MultipleSelectionFilterTabItem + private partial class RankItem : MultipleSelectionFilterTabItem { public RankItem(ScoreRank value) : base(value) diff --git a/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs b/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs index ff43170207..c33d5056fa 100644 --- a/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs +++ b/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Extensions; @@ -15,10 +17,10 @@ using osuTK.Graphics; namespace osu.Game.Overlays.BeatmapListing { - public class FilterTabItem : TabItem + public partial class FilterTabItem : TabItem { [Resolved] - private OverlayColourProvider colourProvider { get; set; } + protected OverlayColourProvider ColourProvider { get; private set; } private OsuSpriteText text; @@ -50,38 +52,42 @@ namespace osu.Game.Overlays.BeatmapListing { base.LoadComplete(); - updateState(); + UpdateState(); FinishTransforms(true); } protected override bool OnHover(HoverEvent e) { base.OnHover(e); - updateState(); + UpdateState(); return true; } protected override void OnHoverLost(HoverLostEvent e) { base.OnHoverLost(e); - updateState(); + UpdateState(); } - protected override void OnActivated() => updateState(); + protected override void OnActivated() => UpdateState(); - protected override void OnDeactivated() => updateState(); + protected override void OnDeactivated() => UpdateState(); /// /// Returns the label text to be used for the supplied . /// protected virtual LocalisableString LabelFor(T value) => (value as Enum)?.GetLocalisableDescription() ?? value.ToString(); - private void updateState() + protected virtual bool HighlightOnHoverWhenActive => false; + + protected virtual void UpdateState() { - text.FadeColour(IsHovered ? colourProvider.Light1 : GetStateColour(), 200, Easing.OutQuint); - text.Font = text.Font.With(weight: Active.Value ? FontWeight.SemiBold : FontWeight.Regular); + bool highlightHover = IsHovered && (!Active.Value || HighlightOnHoverWhenActive); + + text.FadeColour(highlightHover ? ColourProvider.Content2 : GetStateColour(), 200, Easing.OutQuint); + text.Font = text.Font.With(weight: Active.Value ? FontWeight.Bold : FontWeight.Regular); } - protected virtual Color4 GetStateColour() => Active.Value ? colourProvider.Content1 : colourProvider.Light2; + protected virtual Color4 GetStateColour() => Active.Value ? ColourProvider.Content1 : ColourProvider.Light2; } } diff --git a/osu.Game/Overlays/BeatmapListing/SearchCategory.cs b/osu.Game/Overlays/BeatmapListing/SearchCategory.cs index b52df6234f..b3e12d00a6 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchCategory.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchCategory.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; diff --git a/osu.Game/Overlays/BeatmapListing/SearchExplicit.cs b/osu.Game/Overlays/BeatmapListing/SearchExplicit.cs index 80482b32a0..10fea6d5b2 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchExplicit.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchExplicit.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; diff --git a/osu.Game/Overlays/BeatmapListing/SearchExtra.cs b/osu.Game/Overlays/BeatmapListing/SearchExtra.cs index e54632acd8..d307cd09eb 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchExtra.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchExtra.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; diff --git a/osu.Game/Overlays/BeatmapListing/SearchGeneral.cs b/osu.Game/Overlays/BeatmapListing/SearchGeneral.cs index 34ff5b9840..ebcbef1ad9 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchGeneral.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchGeneral.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; diff --git a/osu.Game/Overlays/BeatmapListing/SearchGenre.cs b/osu.Game/Overlays/BeatmapListing/SearchGenre.cs index 08855284cb..7746eb50d7 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchGenre.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchGenre.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; diff --git a/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs b/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs index 7ffa0282b7..d52f923158 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Resources.Localisation.Web; diff --git a/osu.Game/Overlays/BeatmapListing/SearchPlayed.cs b/osu.Game/Overlays/BeatmapListing/SearchPlayed.cs index 3b04ac01ca..0c379b3825 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchPlayed.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchPlayed.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; diff --git a/osu.Game/Overlays/BeatmapListing/SortCriteria.cs b/osu.Game/Overlays/BeatmapListing/SortCriteria.cs index 871b3c162b..6c010c7504 100644 --- a/osu.Game/Overlays/BeatmapListing/SortCriteria.cs +++ b/osu.Game/Overlays/BeatmapListing/SortCriteria.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; @@ -17,6 +19,9 @@ namespace osu.Game.Overlays.BeatmapListing [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingDifficulty))] Difficulty, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingUpdated))] + Updated, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingRanked))] Ranked, @@ -30,6 +35,9 @@ namespace osu.Game.Overlays.BeatmapListing Favourites, [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingRelevance))] - Relevance + Relevance, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingNominations))] + Nominations, } } diff --git a/osu.Game/Overlays/BeatmapListingOverlay.cs b/osu.Game/Overlays/BeatmapListingOverlay.cs index 3476968ded..f8784504b8 100644 --- a/osu.Game/Overlays/BeatmapListingOverlay.cs +++ b/osu.Game/Overlays/BeatmapListingOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using System.Threading; @@ -29,7 +31,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays { - public class BeatmapListingOverlay : OnlineOverlay + public partial class BeatmapListingOverlay : OnlineOverlay { [Resolved] private PreviewTrackManager previewTrackManager { get; set; } @@ -39,12 +41,10 @@ namespace osu.Game.Overlays private IBindable apiUser; - private Drawable currentContent; private Container panelTarget; private FillFlowContainer foundContent; - private NotFoundDrawable notFoundContent; - private SupporterRequiredDrawable supporterRequiredContent; - private BeatmapListingFilterControl filterControl; + + private BeatmapListingFilterControl filterControl => Header.FilterControl; public BeatmapListingOverlay() : base(OverlayColourScheme.Blue) @@ -61,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, @@ -84,16 +78,15 @@ namespace osu.Game.Overlays RelativeSizeAxes = Axes.X, Masking = true, Padding = new MarginPadding { Horizontal = 20 }, - Children = new Drawable[] - { - notFoundContent = new NotFoundDrawable(), - supporterRequiredContent = new SupporterRequiredDrawable(), - } } }, }, } }; + + filterControl.TypingStarted = onTypingStarted; + filterControl.SearchStarted = onSearchStarted; + filterControl.SearchFinished = onSearchFinished; } protected override void LoadComplete() @@ -102,17 +95,30 @@ namespace osu.Game.Overlays filterControl.CardSize.BindValueChanged(_ => onCardSizeChanged()); apiUser = api.LocalUser.GetBoundCopy(); - apiUser.BindValueChanged(_ => + apiUser.BindValueChanged(_ => Schedule(() => { if (api.IsLoggedIn) - addContentToResultsArea(Drawable.Empty()); - }); + replaceResultsAreaContent(Drawable.Empty()); + })); } public void ShowWithSearch(string query) { filterControl.Search(query); Show(); + ScrollFlow.ScrollToStart(); + } + + public void ShowWithGenreFilter(SearchGenre genre) + { + ShowWithSearch(string.Empty); + filterControl.FilterGenre(genre); + } + + public void ShowWithLanguageFilter(SearchLanguage language) + { + ShowWithSearch(string.Empty); + filterControl.FilterLanguage(language); } protected override BeatmapListingHeader CreateHeader() => new BeatmapListingHeader(); @@ -152,8 +158,8 @@ namespace osu.Game.Overlays if (searchResult.Type == BeatmapListingFilterControl.SearchResultType.SupporterOnlyFilters) { - supporterRequiredContent.UpdateText(searchResult.SupporterOnlyFiltersUsed); - addContentToResultsArea(supporterRequiredContent); + var supporterOnly = new SupporterRequiredDrawable(searchResult.SupporterOnlyFiltersUsed); + replaceResultsAreaContent(supporterOnly); return; } @@ -164,16 +170,23 @@ namespace osu.Game.Overlays //No matches case if (!newCards.Any()) { - addContentToResultsArea(notFoundContent); + replaceResultsAreaContent(new NotFoundDrawable()); return; } var content = createCardContainerFor(newCards); - panelLoadTask = LoadComponentAsync(foundContent = content, addContentToResultsArea, (cancellationToken = new CancellationTokenSource()).Token); + panelLoadTask = LoadComponentAsync(foundContent = content, replaceResultsAreaContent, (cancellationToken = new CancellationTokenSource()).Token); } else { + // new results may contain beatmaps from a previous page, + // this is dodgy but matches web behaviour for now. + // see: https://github.com/ppy/osu-web/issues/9270 + // todo: replace custom equality compraer with ExceptBy in net6.0 + // newCards = newCards.ExceptBy(foundContent.Select(c => c.BeatmapSet.OnlineID), c => c.BeatmapSet.OnlineID); + newCards = newCards.Except(foundContent, BeatmapCardEqualityComparer.Default); + panelLoadTask = LoadComponentsAsync(newCards, loaded => { lastFetchDisplayedTime = Time.Current; @@ -183,7 +196,7 @@ namespace osu.Game.Overlays } } - private BeatmapCard[] createCardsFor(IEnumerable beatmapSets) => beatmapSets.Select(set => BeatmapCard.Create(set, filterControl.CardSize.Value).With(c => + private IEnumerable createCardsFor(IEnumerable beatmapSets) => beatmapSets.Select(set => BeatmapCard.Create(set, filterControl.CardSize.Value).With(c => { c.Anchor = Anchor.TopCentre; c.Origin = Anchor.TopCentre; @@ -211,36 +224,16 @@ namespace osu.Game.Overlays return content; } - private void addContentToResultsArea(Drawable content) + private void replaceResultsAreaContent(Drawable content) { Loading.Hide(); lastFetchDisplayedTime = Time.Current; - if (content == currentContent) - return; - - var lastContent = currentContent; - - if (lastContent != null) - { - lastContent.FadeOut(); - if (!isPlaceholderContent(lastContent)) - lastContent.Expire(); - } - - if (!content.IsAlive) - panelTarget.Add(content); + panelTarget.Child = content; content.FadeInFromZero(); - currentContent = content; } - /// - /// Whether is a static placeholder reused multiple times by this overlay. - /// - private bool isPlaceholderContent(Drawable drawable) - => drawable == notFoundContent || drawable == supporterRequiredContent; - private void onCardSizeChanged() { if (foundContent?.IsAlive != true || !foundContent.Any()) @@ -266,7 +259,7 @@ namespace osu.Game.Overlays base.Dispose(isDisposing); } - public class NotFoundDrawable : CompositeDrawable + public partial class NotFoundDrawable : CompositeDrawable { public NotFoundDrawable() { @@ -277,7 +270,7 @@ namespace osu.Game.Overlays } [BackgroundDependencyLoader] - private void load(TextureStore textures) + private void load(LargeTextureStore textures) { AddInternal(new FillFlowContainer { @@ -310,19 +303,23 @@ namespace osu.Game.Overlays // TODO: localisation requires Text/LinkFlowContainer support for localising strings with links inside // (https://github.com/ppy/osu-framework/issues/4530) - public class SupporterRequiredDrawable : CompositeDrawable + public partial class SupporterRequiredDrawable : CompositeDrawable { private LinkFlowContainer supporterRequiredText; - public SupporterRequiredDrawable() + private readonly List filtersUsed; + + public SupporterRequiredDrawable(List filtersUsed) { RelativeSizeAxes = Axes.X; Height = 225; Alpha = 0; + + this.filtersUsed = filtersUsed; } [BackgroundDependencyLoader] - private void load(TextureStore textures) + private void load(LargeTextureStore textures) { AddInternal(new FillFlowContainer { @@ -350,14 +347,9 @@ namespace osu.Game.Overlays }, } }); - } - - public void UpdateText(List filters) - { - supporterRequiredText.Clear(); supporterRequiredText.AddText( - BeatmapsStrings.ListingSearchSupporterFilterQuoteDefault(string.Join(" and ", filters), "").ToString(), + BeatmapsStrings.ListingSearchSupporterFilterQuoteDefault(string.Join(" and ", filtersUsed), "").ToString(), t => { t.Font = OsuFont.GetFont(size: 16); @@ -386,5 +378,21 @@ namespace osu.Game.Overlays if (shouldShowMore) filterControl.FetchNextPage(); } + + private class BeatmapCardEqualityComparer : IEqualityComparer + { + public static BeatmapCardEqualityComparer Default { get; } = new BeatmapCardEqualityComparer(); + + public bool Equals(BeatmapCard x, BeatmapCard y) + { + if (ReferenceEquals(x, y)) return true; + if (ReferenceEquals(x, null)) return false; + if (ReferenceEquals(y, null)) return false; + + return x.BeatmapSet.Equals(y.BeatmapSet); + } + + public int GetHashCode(BeatmapCard obj) => obj.BeatmapSet.GetHashCode(); + } } } diff --git a/osu.Game/Overlays/BeatmapSet/AuthorInfo.cs b/osu.Game/Overlays/BeatmapSet/AuthorInfo.cs index 9131f6f74c..1d01495188 100644 --- a/osu.Game/Overlays/BeatmapSet/AuthorInfo.cs +++ b/osu.Game/Overlays/BeatmapSet/AuthorInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -19,7 +21,7 @@ using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Overlays.BeatmapSet { - public class AuthorInfo : Container + public partial class AuthorInfo : Container { private const float height = 50; @@ -103,7 +105,7 @@ namespace osu.Game.Overlays.BeatmapSet } } - private class Field : FillFlowContainer + private partial class Field : FillFlowContainer { public Field(string first, string second, FontUsage secondFont) { diff --git a/osu.Game/Overlays/BeatmapSet/BasicStats.cs b/osu.Game/Overlays/BeatmapSet/BasicStats.cs index 8f848edf24..4a9a3d8089 100644 --- a/osu.Game/Overlays/BeatmapSet/BasicStats.cs +++ b/osu.Game/Overlays/BeatmapSet/BasicStats.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -20,7 +22,7 @@ using osuTK; namespace osu.Game.Overlays.BeatmapSet { - public class BasicStats : Container + public partial class BasicStats : Container { private readonly Statistic length, bpm, circleCount, sliderCount; @@ -115,7 +117,7 @@ namespace osu.Game.Overlays.BeatmapSet updateDisplay(); } - private class Statistic : Container, IHasTooltip + private partial class Statistic : Container, IHasTooltip { private readonly OsuSpriteText value; diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapAvailability.cs b/osu.Game/Overlays/BeatmapSet/BeatmapAvailability.cs index b6e768d632..d18e1c93c9 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapAvailability.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapAvailability.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -13,7 +15,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.BeatmapSet { - public class BeatmapAvailability : Container + public partial class BeatmapAvailability : Container { private APIBeatmapSet beatmapSet; diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapBadge.cs b/osu.Game/Overlays/BeatmapSet/BeatmapBadge.cs index a75fc8e888..feb27aaa60 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapBadge.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapBadge.cs @@ -10,11 +10,9 @@ using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -#nullable enable - namespace osu.Game.Overlays.BeatmapSet { - public abstract class BeatmapBadge : CompositeDrawable + public abstract partial class BeatmapBadge : CompositeDrawable { /// /// The text displayed on the badge's label. diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs b/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs index 031442814d..104f861df7 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs @@ -25,21 +25,22 @@ using osuTK; namespace osu.Game.Overlays.BeatmapSet { - public class BeatmapPicker : Container + public partial class BeatmapPicker : Container { private const float tile_icon_padding = 7; private const float tile_spacing = 2; private readonly OsuSpriteText version, starRating, starRatingText; + private readonly LinkFlowContainer guestMapperContainer; private readonly FillFlowContainer starRatingContainer; private readonly Statistic plays, favourites; public readonly DifficultiesContainer Difficulties; - public readonly Bindable Beatmap = new Bindable(); - private APIBeatmapSet beatmapSet; + public readonly Bindable Beatmap = new Bindable(); + private APIBeatmapSet? beatmapSet; - public APIBeatmapSet BeatmapSet + public APIBeatmapSet? BeatmapSet { get => beatmapSet; set @@ -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, @@ -140,7 +149,7 @@ namespace osu.Game.Overlays.BeatmapSet } [Resolved] - private IBindable ruleset { get; set; } + private IBindable ruleset { get; set; } = null!; [BackgroundDependencyLoader] private void load(OsuColour colours) @@ -154,7 +163,7 @@ namespace osu.Game.Overlays.BeatmapSet { base.LoadComplete(); - ruleset.ValueChanged += r => updateDisplay(); + ruleset.ValueChanged += _ => updateDisplay(); // done here so everything can bind in intialization and get the first trigger Beatmap.TriggerChange(); @@ -166,10 +175,11 @@ namespace osu.Game.Overlays.BeatmapSet if (BeatmapSet != null) { - Difficulties.ChildrenEnumerable = BeatmapSet.Beatmaps + Difficulties.ChildrenEnumerable = BeatmapSet.Beatmaps.Concat(BeatmapSet.Converts ?? Array.Empty()) .Where(b => b.Ruleset.MatchesOnlineID(ruleset.Value)) - .OrderBy(b => b.StarRating) - .Select(b => new DifficultySelectorButton(b) + .OrderBy(b => !b.Convert) + .ThenBy(b => b.StarRating) + .Select(b => new DifficultySelectorButton(b, b.Convert ? new RulesetInfo { OnlineID = 0 } : null) { State = DifficultySelectorState.NotSelected, OnHovered = beatmap => @@ -197,9 +207,22 @@ namespace osu.Game.Overlays.BeatmapSet updateDifficultyButtons(); } - private void showBeatmap(IBeatmapInfo beatmapInfo) + private void showBeatmap(APIBeatmap? beatmapInfo) { - version.Text = beatmapInfo?.DifficultyName; + 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; } private void updateDifficultyButtons() @@ -207,9 +230,9 @@ namespace osu.Game.Overlays.BeatmapSet Difficulties.Children.ToList().ForEach(diff => diff.State = diff.Beatmap == Beatmap.Value ? DifficultySelectorState.Selected : DifficultySelectorState.NotSelected); } - public class DifficultiesContainer : FillFlowContainer + public partial class DifficultiesContainer : FillFlowContainer { - public Action OnLostHover; + public Action? OnLostHover; protected override void OnHoverLost(HoverLostEvent e) { @@ -218,7 +241,7 @@ namespace osu.Game.Overlays.BeatmapSet } } - public class DifficultySelectorButton : OsuClickableContainer, IStateful + public partial class DifficultySelectorButton : OsuClickableContainer, IStateful { private const float transition_duration = 100; private const float size = 54; @@ -230,9 +253,9 @@ namespace osu.Game.Overlays.BeatmapSet public readonly APIBeatmap Beatmap; - public Action OnHovered; - public Action OnClicked; - public event Action StateChanged; + public Action? OnHovered; + public Action? OnClicked; + public event Action? StateChanged; private DifficultySelectorState state; @@ -253,7 +276,7 @@ namespace osu.Game.Overlays.BeatmapSet } } - public DifficultySelectorButton(APIBeatmap beatmapInfo) + public DifficultySelectorButton(APIBeatmap beatmapInfo, IRulesetInfo? ruleset) { Beatmap = beatmapInfo; Size = new Vector2(size); @@ -272,8 +295,10 @@ namespace osu.Game.Overlays.BeatmapSet Alpha = 0.5f } }, - icon = new DifficultyIcon(beatmapInfo, shouldShowTooltip: false) + icon = new DifficultyIcon(beatmapInfo, ruleset) { + ShowTooltip = false, + Current = { Value = new StarDifficulty(beatmapInfo.StarRating, 0) }, Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(size - tile_icon_padding * 2), @@ -321,7 +346,7 @@ namespace osu.Game.Overlays.BeatmapSet } } - private class Statistic : FillFlowContainer + private partial class Statistic : FillFlowContainer { private readonly OsuSpriteText text; diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapRulesetSelector.cs b/osu.Game/Overlays/BeatmapSet/BeatmapRulesetSelector.cs index 6564ca3d41..9291988367 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapRulesetSelector.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapRulesetSelector.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Graphics.UserInterface; using osu.Game.Rulesets; @@ -9,7 +11,7 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Overlays.BeatmapSet { - public class BeatmapRulesetSelector : OverlayRulesetSelector + public partial class BeatmapRulesetSelector : OverlayRulesetSelector { private readonly Bindable beatmapSet = new Bindable(); diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapRulesetTabItem.cs b/osu.Game/Overlays/BeatmapSet/BeatmapRulesetTabItem.cs index e8cdc6913b..76e2f256b0 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapRulesetTabItem.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapRulesetTabItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -15,7 +17,7 @@ using osu.Game.Rulesets; namespace osu.Game.Overlays.BeatmapSet { - public class BeatmapRulesetTabItem : OverlayRulesetTabItem + public partial class BeatmapRulesetTabItem : OverlayRulesetTabItem { public readonly Bindable BeatmapSet = new Bindable(); @@ -66,11 +68,12 @@ namespace osu.Game.Overlays.BeatmapSet BeatmapSet.BindValueChanged(setInfo => { int beatmapsCount = setInfo.NewValue?.Beatmaps.Count(b => b.Ruleset.MatchesOnlineID(Value)) ?? 0; + int osuBeatmaps = setInfo.NewValue?.Beatmaps.Count(b => b.Ruleset.OnlineID == 0) ?? 0; count.Text = beatmapsCount.ToString(); countContainer.FadeTo(beatmapsCount > 0 ? 1 : 0); - Enabled.Value = beatmapsCount > 0; + Enabled.Value = beatmapsCount > 0 || osuBeatmaps > 0; }, true); } } diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeader.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeader.cs index 102cddfa92..858742648c 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeader.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeader.cs @@ -1,11 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Effects; +using osu.Framework.Localisation; using osu.Game.Online.API.Requests.Responses; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; @@ -14,7 +17,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.BeatmapSet { - public class BeatmapSetHeader : OverlayHeader + public partial class BeatmapSetHeader : TabControlOverlayHeader { public readonly Bindable BeatmapSet = new Bindable(); @@ -44,14 +47,14 @@ namespace osu.Game.Overlays.BeatmapSet BeatmapSet = { BindTarget = BeatmapSet } }; - protected override Drawable CreateTitleContent() => RulesetSelector = new BeatmapRulesetSelector + protected override Drawable CreateTabControlContent() => RulesetSelector = new BeatmapRulesetSelector { Current = ruleset }; protected override OverlayTitle CreateTitle() => new BeatmapHeaderTitle(); - private class BeatmapHeaderTitle : OverlayTitle + private partial class BeatmapHeaderTitle : OverlayTitle { public BeatmapHeaderTitle() { @@ -60,4 +63,10 @@ namespace osu.Game.Overlays.BeatmapSet } } } + + public enum BeatmapSetTabs + { + [LocalisableDescription(typeof(LayoutStrings), nameof(LayoutStrings.HeaderBeatmapsetsShow))] + Info, + } } diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs index e0fc7482f6..7ff8352054 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs @@ -1,6 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -9,21 +12,24 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; using osu.Game.Overlays.BeatmapSet.Buttons; using osuTK; +using osuTK.Graphics; namespace osu.Game.Overlays.BeatmapSet { - public class BeatmapSetHeaderContent : CompositeDrawable + public partial class BeatmapSetHeaderContent : CompositeDrawable { public readonly Bindable BeatmapSet = new Bindable(); @@ -38,12 +44,10 @@ namespace osu.Game.Overlays.BeatmapSet private readonly UpdateableOnlineBeatmapSetCover cover; private readonly Box coverGradient; - private readonly OsuSpriteText title, artist; + private readonly LinkFlowContainer title, artist; private readonly AuthorInfo author; - private readonly ExplicitContentBeatmapBadge explicitContent; - private readonly SpotlightBeatmapBadge spotlight; - private readonly FeaturedArtistBeatmapBadge featuredArtist; + private ExternalLinkButton externalLink; private readonly FillFlowContainer downloadButtonsContainer; private readonly BeatmapAvailability beatmapAvailability; @@ -62,8 +66,6 @@ namespace osu.Game.Overlays.BeatmapSet public BeatmapSetHeaderContent() { - ExternalLinkButton externalLink; - RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; InternalChild = new Container @@ -95,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[] { @@ -113,58 +115,19 @@ namespace osu.Game.Overlays.BeatmapSet AutoSizeAxes = Axes.Y, Child = Picker = new BeatmapPicker(), }, - new FillFlowContainer + title = new MetadataFlowContainer(s => + { + s.Font = OsuFont.GetFont(size: 30, weight: FontWeight.SemiBold, italics: true); + }) { - Direction = FillDirection.Horizontal, - AutoSizeAxes = Axes.Both, Margin = new MarginPadding { Top = 15 }, - Children = new Drawable[] - { - title = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 30, weight: FontWeight.SemiBold, italics: true) - }, - externalLink = new ExternalLinkButton - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Margin = new MarginPadding { Left = 5, Bottom = 4 }, // To better lineup with the font - }, - explicitContent = new ExplicitContentBeatmapBadge - { - Alpha = 0f, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Margin = new MarginPadding { Left = 10, Bottom = 4 }, - }, - spotlight = new SpotlightBeatmapBadge - { - Alpha = 0f, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Margin = new MarginPadding { Left = 10, Bottom = 4 }, - } - } }, - new FillFlowContainer + artist = new MetadataFlowContainer(s => + { + s.Font = OsuFont.GetFont(size: 20, weight: FontWeight.Medium, italics: true); + }) { - Direction = FillDirection.Horizontal, - AutoSizeAxes = Axes.Both, Margin = new MarginPadding { Bottom = 20 }, - Children = new Drawable[] - { - artist = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 20, weight: FontWeight.Medium, italics: true), - }, - featuredArtist = new FeaturedArtistBeatmapBadge - { - Alpha = 0f, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Margin = new MarginPadding { Left = 10 } - } - } }, new Container { @@ -190,7 +153,7 @@ namespace osu.Game.Overlays.BeatmapSet Padding = new MarginPadding { Left = buttons_height + buttons_spacing }, Spacing = new Vector2(buttons_spacing), }, - }, + } }, }, }, @@ -207,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[] @@ -229,12 +192,17 @@ namespace osu.Game.Overlays.BeatmapSet Picker.Beatmap.ValueChanged += b => { Details.BeatmapInfo = b.NewValue; - externalLink.Link = $@"{api.WebsiteRootUrl}/beatmapsets/{BeatmapSet.Value?.OnlineID}#{b.NewValue?.Ruleset.ShortName}/{b.NewValue?.OnlineID}"; + updateExternalLink(); onlineStatusPill.Status = b.NewValue?.Status ?? BeatmapOnlineStatus.None; }; } + private void updateExternalLink() + { + if (externalLink != null) externalLink.Link = $@"{api.WebsiteRootUrl}/beatmapsets/{BeatmapSet.Value?.OnlineID}#{Picker.Beatmap.Value?.Ruleset.ShortName}/{Picker.Beatmap.Value?.OnlineID}"; + } + [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) { @@ -267,12 +235,38 @@ namespace osu.Game.Overlays.BeatmapSet loading.Hide(); - title.Text = new RomanisableString(setInfo.NewValue.TitleUnicode, setInfo.NewValue.Title); - artist.Text = new RomanisableString(setInfo.NewValue.ArtistUnicode, setInfo.NewValue.Artist); + var titleText = new RomanisableString(setInfo.NewValue.TitleUnicode, setInfo.NewValue.Title); + var artistText = new RomanisableString(setInfo.NewValue.ArtistUnicode, setInfo.NewValue.Artist); - explicitContent.Alpha = setInfo.NewValue.HasExplicitContent ? 1 : 0; - spotlight.Alpha = setInfo.NewValue.FeaturedInSpotlight ? 1 : 0; - featuredArtist.Alpha = setInfo.NewValue.TrackId != null ? 1 : 0; + title.Clear(); + artist.Clear(); + + title.AddLink(titleText, LinkAction.SearchBeatmapSet, titleText); + + title.AddArbitraryDrawable(Empty().With(d => d.Width = 5)); + title.AddArbitraryDrawable(externalLink = new ExternalLinkButton()); + + if (setInfo.NewValue.HasExplicitContent) + { + title.AddArbitraryDrawable(Empty().With(d => d.Width = 10)); + title.AddArbitraryDrawable(new ExplicitContentBeatmapBadge()); + } + + if (setInfo.NewValue.FeaturedInSpotlight) + { + title.AddArbitraryDrawable(Empty().With(d => d.Width = 10)); + title.AddArbitraryDrawable(new SpotlightBeatmapBadge()); + } + + artist.AddLink(artistText, LinkAction.SearchBeatmapSet, artistText); + + if (setInfo.NewValue.TrackId != null) + { + artist.AddArbitraryDrawable(Empty().With(d => d.Width = 10)); + artist.AddArbitraryDrawable(new FeaturedArtistBeatmapBadge()); + } + + updateExternalLink(); onlineStatusPill.FadeIn(500, Easing.OutQuint); @@ -319,5 +313,32 @@ namespace osu.Game.Overlays.BeatmapSet break; } } + + public partial class MetadataFlowContainer : LinkFlowContainer + { + public MetadataFlowContainer(Action defaultCreationParameters = null) + : base(defaultCreationParameters) + { + TextAnchor = Anchor.CentreLeft; + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + protected override DrawableLinkCompiler CreateLinkCompiler(ITextPart textPart) => new MetadataLinkCompiler(textPart); + + public partial class MetadataLinkCompiler : DrawableLinkCompiler + { + public MetadataLinkCompiler(ITextPart part) + : base(part) + { + } + + [BackgroundDependencyLoader] + private void load() + { + IdleColour = Color4.White; + } + } + } } } diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetLayoutSection.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetLayoutSection.cs index e6d433f7bc..305a3661a7 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapSetLayoutSection.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetLayoutSection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,7 +12,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.BeatmapSet { - public class BeatmapSetLayoutSection : Container + public partial class BeatmapSetLayoutSection : Container { public BeatmapSetLayoutSection() { diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs index 28100e5fff..cbdb2ea190 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -19,7 +21,7 @@ using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Overlays.BeatmapSet.Buttons { - public class FavouriteButton : HeaderButton, IHasTooltip + public partial class FavouriteButton : HeaderButton, IHasTooltip { public readonly Bindable BeatmapSet = new Bindable(); diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/HeaderButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/HeaderButton.cs index 99b0b2ed3b..6e4386bb48 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/HeaderButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/HeaderButton.cs @@ -4,11 +4,11 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Overlays.BeatmapSet.Buttons { - public class HeaderButton : TriangleButton + public partial class HeaderButton : RoundedButton { public HeaderButton() { @@ -20,9 +20,6 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons private void load() { BackgroundColour = Color4Extensions.FromHex(@"094c5f"); - Triangles.ColourLight = Color4Extensions.FromHex(@"0f7c9b"); - Triangles.ColourDark = Color4Extensions.FromHex(@"094c5f"); - Triangles.TriangleScale = 1.5f; } } } diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs index 74417b0ac6..d8dcf2b51b 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -24,7 +26,7 @@ using CommonStrings = osu.Game.Localisation.CommonStrings; namespace osu.Game.Overlays.BeatmapSet.Buttons { - public class HeaderDownloadButton : CompositeDrawable, IHasTooltip + public partial class HeaderDownloadButton : CompositeDrawable, IHasTooltip { private const int text_size = 12; diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/PlayButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/PlayButton.cs index 63b7a0a062..c43be33290 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/PlayButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/PlayButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -16,7 +18,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.BeatmapSet.Buttons { - public class PlayButton : Container + public partial class PlayButton : Container { public IBindable Playing => playing; @@ -145,7 +147,10 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons { // beatmapset may have changed. if (Preview != preview) + { + preview?.Dispose(); return; + } AddInternal(preview); loading = false; diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs index 751299161d..b3b8b80a0d 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -15,7 +17,7 @@ using osuTK; namespace osu.Game.Overlays.BeatmapSet.Buttons { - public class PreviewButton : OsuClickableContainer + public partial class PreviewButton : OsuClickableContainer { private readonly Box background, progress; private readonly PlayButton playButton; diff --git a/osu.Game/Overlays/BeatmapSet/Details.cs b/osu.Game/Overlays/BeatmapSet/Details.cs index dfc8d5e680..cf78f605aa 100644 --- a/osu.Game/Overlays/BeatmapSet/Details.cs +++ b/osu.Game/Overlays/BeatmapSet/Details.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -13,7 +15,7 @@ using osuTK; namespace osu.Game.Overlays.BeatmapSet { - public class Details : FillFlowContainer + public partial class Details : FillFlowContainer { protected readonly UserRatings Ratings; @@ -105,7 +107,7 @@ namespace osu.Game.Overlays.BeatmapSet updateDisplay(); } - private class DetailBox : Container + private partial class DetailBox : Container { private readonly Container content; private readonly Box background; diff --git a/osu.Game/Overlays/BeatmapSet/ExplicitContentBeatmapBadge.cs b/osu.Game/Overlays/BeatmapSet/ExplicitContentBeatmapBadge.cs index 2a20d22b61..f666961784 100644 --- a/osu.Game/Overlays/BeatmapSet/ExplicitContentBeatmapBadge.cs +++ b/osu.Game/Overlays/BeatmapSet/ExplicitContentBeatmapBadge.cs @@ -5,11 +5,9 @@ using osu.Framework.Allocation; using osu.Game.Graphics; using osu.Game.Resources.Localisation.Web; -#nullable enable - namespace osu.Game.Overlays.BeatmapSet { - public class ExplicitContentBeatmapBadge : BeatmapBadge + public partial class ExplicitContentBeatmapBadge : BeatmapBadge { [BackgroundDependencyLoader] private void load(OsuColour colours) diff --git a/osu.Game/Overlays/BeatmapSet/FeaturedArtistBeatmapBadge.cs b/osu.Game/Overlays/BeatmapSet/FeaturedArtistBeatmapBadge.cs index 20ee11c7f6..6090ec6fae 100644 --- a/osu.Game/Overlays/BeatmapSet/FeaturedArtistBeatmapBadge.cs +++ b/osu.Game/Overlays/BeatmapSet/FeaturedArtistBeatmapBadge.cs @@ -5,11 +5,9 @@ using osu.Framework.Allocation; using osu.Game.Graphics; using osu.Game.Resources.Localisation.Web; -#nullable enable - namespace osu.Game.Overlays.BeatmapSet { - public class FeaturedArtistBeatmapBadge : BeatmapBadge + public partial class FeaturedArtistBeatmapBadge : BeatmapBadge { [BackgroundDependencyLoader] private void load(OsuColour colours) diff --git a/osu.Game/Overlays/BeatmapSet/Info.cs b/osu.Game/Overlays/BeatmapSet/Info.cs index 3ef52d718d..8758b9c5cf 100644 --- a/osu.Game/Overlays/BeatmapSet/Info.cs +++ b/osu.Game/Overlays/BeatmapSet/Info.cs @@ -1,18 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays.BeatmapListing; namespace osu.Game.Overlays.BeatmapSet { - public class Info : Container + public partial class Info : Container { private const float metadata_width = 175; private const float spacing = 20; @@ -32,7 +37,10 @@ namespace osu.Game.Overlays.BeatmapSet public Info() { - MetadataSection source, tags, genre, language; + MetadataSectionNominators nominators; + MetadataSection source, tags; + MetadataSectionGenre genre; + MetadataSectionLanguage language; OsuSpriteText notRankedPlaceholder; RelativeSizeAxes = Axes.X; @@ -47,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 @@ -57,7 +65,7 @@ namespace osu.Game.Overlays.BeatmapSet Child = new Container { RelativeSizeAxes = Axes.Both, - Child = new MetadataSection(MetadataType.Description), + Child = new MetadataSectionDescription(), }, }, new Container @@ -68,17 +76,19 @@ namespace osu.Game.Overlays.BeatmapSet Width = metadata_width, Padding = new MarginPadding { Horizontal = 10 }, Margin = new MarginPadding { Right = BeatmapSetOverlay.RIGHT_WIDTH + spacing }, + Masking = true, Child = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Full, - Children = new[] + Children = new Drawable[] { - source = new MetadataSection(MetadataType.Source), - genre = new MetadataSection(MetadataType.Genre) { Width = 0.5f }, - language = new MetadataSection(MetadataType.Language) { Width = 0.5f }, - tags = new MetadataSection(MetadataType.Tags), + nominators = new MetadataSectionNominators(), + source = new MetadataSectionSource(), + genre = new MetadataSectionGenre { Width = 0.5f }, + language = new MetadataSectionLanguage { Width = 0.5f }, + tags = new MetadataSectionTags(), }, }, }, @@ -115,10 +125,11 @@ namespace osu.Game.Overlays.BeatmapSet BeatmapSet.ValueChanged += b => { - source.Text = b.NewValue?.Source ?? string.Empty; - tags.Text = b.NewValue?.Tags ?? string.Empty; - genre.Text = b.NewValue?.Genre.Name ?? string.Empty; - language.Text = b.NewValue?.Language.Name ?? string.Empty; + nominators.Metadata = (b.NewValue?.CurrentNominations ?? Array.Empty(), b.NewValue?.RelatedUsers ?? Array.Empty()); + source.Metadata = b.NewValue?.Source ?? string.Empty; + tags.Metadata = b.NewValue?.Tags ?? string.Empty; + genre.Metadata = b.NewValue?.Genre ?? new BeatmapSetOnlineGenre { Id = (int)SearchGenre.Unspecified }; + language.Metadata = b.NewValue?.Language ?? new BeatmapSetOnlineLanguage { Id = (int)SearchLanguage.Unspecified }; bool setHasLeaderboard = b.NewValue?.Status > 0; successRate.Alpha = setHasLeaderboard ? 1 : 0; notRankedPlaceholder.Alpha = setHasLeaderboard ? 0 : 1; diff --git a/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs b/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs index 25aed4c980..9c8f810607 100644 --- a/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs +++ b/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Containers; using osu.Framework.Graphics; using osu.Game.Rulesets.Mods; @@ -18,7 +20,7 @@ using osu.Framework.Extensions.IEnumerableExtensions; namespace osu.Game.Overlays.BeatmapSet { - public class LeaderboardModSelector : CompositeDrawable + public partial class LeaderboardModSelector : CompositeDrawable { public readonly BindableList SelectedMods = new BindableList(); public readonly Bindable Ruleset = new Bindable(); @@ -105,7 +107,7 @@ namespace osu.Game.Overlays.BeatmapSet public void DeselectAll() => modsContainer.ForEach(mod => mod.Selected.Value = false); - private class ModButton : ModIcon + private partial class ModButton : ModIcon { private const int duration = 200; diff --git a/osu.Game/Overlays/BeatmapSet/LeaderboardScopeSelector.cs b/osu.Game/Overlays/BeatmapSet/LeaderboardScopeSelector.cs index 607355b7bf..476a252c7b 100644 --- a/osu.Game/Overlays/BeatmapSet/LeaderboardScopeSelector.cs +++ b/osu.Game/Overlays/BeatmapSet/LeaderboardScopeSelector.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Screens.Select.Leaderboards; using osu.Game.Graphics.UserInterface; using osu.Framework.Allocation; @@ -11,7 +13,7 @@ using osu.Framework.Graphics; namespace osu.Game.Overlays.BeatmapSet { - public class LeaderboardScopeSelector : GradientLineTabControl + public partial class LeaderboardScopeSelector : GradientLineTabControl { protected override bool AddEnumEntriesAutomatically => false; @@ -31,7 +33,7 @@ namespace osu.Game.Overlays.BeatmapSet LineColour = colourProvider.Background1; } - private class ScopeSelectorTabItem : PageTabItem + private partial class ScopeSelectorTabItem : PageTabItem { public ScopeSelectorTabItem(BeatmapLeaderboardScope value) : base(value) diff --git a/osu.Game/Overlays/BeatmapSet/MetadataSection.cs b/osu.Game/Overlays/BeatmapSet/MetadataSection.cs index f1b9f98528..d32d8e83fb 100644 --- a/osu.Game/Overlays/BeatmapSet/MetadataSection.cs +++ b/osu.Game/Overlays/BeatmapSet/MetadataSection.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -8,23 +9,45 @@ using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; -using osu.Game.Online.Chat; using osuTK; using osuTK.Graphics; namespace osu.Game.Overlays.BeatmapSet { - public class MetadataSection : Container + public abstract partial class MetadataSection : MetadataSection + { + public override string Metadata + { + set + { + if (string.IsNullOrEmpty(value)) + { + this.FadeOut(TRANSITION_DURATION); + return; + } + + base.Metadata = value; + } + } + + protected MetadataSection(MetadataType type, Action? searchAction = null) + : base(type, searchAction) + { + } + } + + public abstract partial class MetadataSection : Container { private readonly FillFlowContainer textContainer; - private readonly MetadataType type; - private TextFlowContainer textFlow; + private TextFlowContainer? textFlow; - private const float transition_duration = 250; + protected readonly Action? SearchAction; - public MetadataSection(MetadataType type) + protected const float TRANSITION_DURATION = 250; + + protected MetadataSection(MetadataType type, Action? searchAction = null) { - this.type = type; + SearchAction = searchAction; Alpha = 0; @@ -47,7 +70,7 @@ namespace osu.Game.Overlays.BeatmapSet AutoSizeAxes = Axes.Y, Child = new OsuSpriteText { - Text = this.type.GetLocalisableDescription(), + Text = type.GetLocalisableDescription(), Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 14), }, }, @@ -55,23 +78,23 @@ namespace osu.Game.Overlays.BeatmapSet }; } - public string Text + public virtual T Metadata { set { - if (string.IsNullOrEmpty(value)) + if (value == null) { - this.FadeOut(transition_duration); + this.FadeOut(TRANSITION_DURATION); return; } - this.FadeIn(transition_duration); + this.FadeIn(TRANSITION_DURATION); - setTextAsync(value); + setTextFlowAsync(value); } } - private void setTextAsync(string text) + private void setTextFlowAsync(T metadata) { LoadComponentAsync(new LinkFlowContainer(s => s.Font = s.Font.With(size: 14)) { @@ -82,35 +105,15 @@ namespace osu.Game.Overlays.BeatmapSet { textFlow?.Expire(); - switch (type) - { - case MetadataType.Tags: - string[] tags = text.Split(" "); - - for (int i = 0; i <= tags.Length - 1; i++) - { - loaded.AddLink(tags[i], LinkAction.SearchBeatmapSet, tags[i]); - - if (i != tags.Length - 1) - loaded.AddText(" "); - } - - break; - - case MetadataType.Source: - loaded.AddLink(text, LinkAction.SearchBeatmapSet, text); - break; - - default: - loaded.AddText(text); - break; - } + AddMetadata(metadata, loaded); textContainer.Add(textFlow = loaded); // fade in if we haven't yet. - textContainer.FadeIn(transition_duration); + textContainer.FadeIn(TRANSITION_DURATION); }); } + + protected abstract void AddMetadata(T metadata, LinkFlowContainer loaded); } } diff --git a/osu.Game/Overlays/BeatmapSet/MetadataSectionDescription.cs b/osu.Game/Overlays/BeatmapSet/MetadataSectionDescription.cs new file mode 100644 index 0000000000..e6837951c9 --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/MetadataSectionDescription.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Overlays.BeatmapSet +{ + public partial class MetadataSectionDescription : MetadataSection + { + public MetadataSectionDescription(Action? searchAction = null) + : base(MetadataType.Description, searchAction) + { + } + + protected override void AddMetadata(string metadata, LinkFlowContainer loaded) + { + loaded.AddText(metadata); + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/MetadataSectionGenre.cs b/osu.Game/Overlays/BeatmapSet/MetadataSectionGenre.cs new file mode 100644 index 0000000000..d41115f2b8 --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/MetadataSectionGenre.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Extensions; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; +using osu.Game.Online.Chat; +using osu.Game.Overlays.BeatmapListing; + +namespace osu.Game.Overlays.BeatmapSet +{ + public partial class MetadataSectionGenre : MetadataSection + { + public MetadataSectionGenre(Action? searchAction = null) + : base(MetadataType.Genre, searchAction) + { + } + + protected override void AddMetadata(BeatmapSetOnlineGenre metadata, LinkFlowContainer loaded) + { + var genre = (SearchGenre)metadata.Id; + + if (Enum.IsDefined(genre)) + loaded.AddLink(genre.GetLocalisableDescription(), LinkAction.FilterBeatmapSetGenre, genre); + else + loaded.AddText(metadata.Name); + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/MetadataSectionLanguage.cs b/osu.Game/Overlays/BeatmapSet/MetadataSectionLanguage.cs new file mode 100644 index 0000000000..e831b1eaca --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/MetadataSectionLanguage.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Extensions; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; +using osu.Game.Online.Chat; +using osu.Game.Overlays.BeatmapListing; + +namespace osu.Game.Overlays.BeatmapSet +{ + public partial class MetadataSectionLanguage : MetadataSection + { + public MetadataSectionLanguage(Action? searchAction = null) + : base(MetadataType.Language, searchAction) + { + } + + protected override void AddMetadata(BeatmapSetOnlineLanguage metadata, LinkFlowContainer loaded) + { + var language = (SearchLanguage)metadata.Id; + + if (Enum.IsDefined(language)) + loaded.AddLink(language.GetLocalisableDescription(), LinkAction.FilterBeatmapSetLanguage, language); + else + loaded.AddText(metadata.Name); + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/MetadataSectionNominators.cs b/osu.Game/Overlays/BeatmapSet/MetadataSectionNominators.cs new file mode 100644 index 0000000000..76dbda3d5e --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/MetadataSectionNominators.cs @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Overlays.BeatmapSet +{ + public partial class MetadataSectionNominators : MetadataSection<(BeatmapSetOnlineNomination[] CurrentNominations, APIUser[] RelatedUsers)> + { + public override (BeatmapSetOnlineNomination[] CurrentNominations, APIUser[] RelatedUsers) Metadata + { + set + { + if (value.CurrentNominations.Length == 0) + { + this.FadeOut(TRANSITION_DURATION); + return; + } + + base.Metadata = value; + } + } + + public MetadataSectionNominators(Action<(BeatmapSetOnlineNomination[] CurrentNominations, APIUser[] RelatedUsers)>? searchAction = null) + : base(MetadataType.Nominators, searchAction) + { + } + + protected override void AddMetadata((BeatmapSetOnlineNomination[] CurrentNominations, APIUser[] RelatedUsers) metadata, LinkFlowContainer loaded) + { + int[] nominatorIds = metadata.CurrentNominations.Select(n => n.UserId).ToArray(); + + int nominatorsFound = 0; + + foreach (int nominatorId in nominatorIds) + { + foreach (var user in metadata.RelatedUsers) + { + if (nominatorId != user.OnlineID) continue; + + nominatorsFound++; + + loaded.AddUserLink(new APIUser + { + Username = user.Username, + Id = nominatorId, + }); + + if (nominatorsFound < nominatorIds.Length) + loaded.AddText(CommonStrings.ArrayAndWordsConnector); + + break; + } + } + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/MetadataSectionSource.cs b/osu.Game/Overlays/BeatmapSet/MetadataSectionSource.cs new file mode 100644 index 0000000000..544dc0dfe4 --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/MetadataSectionSource.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Graphics.Containers; +using osu.Game.Online.Chat; + +namespace osu.Game.Overlays.BeatmapSet +{ + public partial class MetadataSectionSource : MetadataSection + { + public MetadataSectionSource(Action? searchAction = null) + : base(MetadataType.Source, searchAction) + { + } + + protected override void AddMetadata(string metadata, LinkFlowContainer loaded) + { + if (SearchAction != null) + loaded.AddLink(metadata, () => SearchAction(metadata)); + else + loaded.AddLink(metadata, LinkAction.SearchBeatmapSet, metadata); + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/MetadataSectionTags.cs b/osu.Game/Overlays/BeatmapSet/MetadataSectionTags.cs new file mode 100644 index 0000000000..fc16ba19d8 --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/MetadataSectionTags.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Graphics.Containers; +using osu.Game.Online.Chat; + +namespace osu.Game.Overlays.BeatmapSet +{ + public partial class MetadataSectionTags : MetadataSection + { + public MetadataSectionTags(Action? searchAction = null) + : base(MetadataType.Tags, searchAction) + { + } + + protected override void AddMetadata(string metadata, LinkFlowContainer loaded) + { + string[] tags = metadata.Split(" "); + + for (int i = 0; i <= tags.Length - 1; i++) + { + string tag = tags[i]; + + if (SearchAction != null) + loaded.AddLink(tag, () => SearchAction(tag)); + else + loaded.AddLink(tag, LinkAction.SearchBeatmapSet, tag); + + if (i != tags.Length - 1) + loaded.AddText(" "); + } + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/MetadataType.cs b/osu.Game/Overlays/BeatmapSet/MetadataType.cs index f992dca0ef..dc96ce99e9 100644 --- a/osu.Game/Overlays/BeatmapSet/MetadataType.cs +++ b/osu.Game/Overlays/BeatmapSet/MetadataType.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; @@ -21,6 +23,9 @@ namespace osu.Game.Overlays.BeatmapSet Genre, [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowInfoLanguage))] - Language + Language, + + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowInfoNominators))] + Nominators, } } diff --git a/osu.Game/Overlays/BeatmapSet/Scores/DrawableTopScore.cs b/osu.Game/Overlays/BeatmapSet/Scores/DrawableTopScore.cs index 0ae8a8bef5..01e4416156 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/DrawableTopScore.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/DrawableTopScore.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -13,7 +15,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.BeatmapSet.Scores { - public class DrawableTopScore : CompositeDrawable + public partial class DrawableTopScore : CompositeDrawable { private readonly Box background; @@ -87,7 +89,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores background.Colour = colourProvider.Background4; } - private class AutoSizingGrid : GridContainer + private partial class AutoSizingGrid : GridContainer { public AutoSizingGrid() { diff --git a/osu.Game/Overlays/BeatmapSet/Scores/NoScoresPlaceholder.cs b/osu.Game/Overlays/BeatmapSet/Scores/NoScoresPlaceholder.cs index 82ca3e030f..f7703af27d 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/NoScoresPlaceholder.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/NoScoresPlaceholder.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Screens.Select.Leaderboards; @@ -10,7 +12,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapSet.Scores { - public class NoScoresPlaceholder : Container + public partial class NoScoresPlaceholder : Container { private readonly SpriteText text; diff --git a/osu.Game/Overlays/BeatmapSet/Scores/NotSupporterPlaceholder.cs b/osu.Game/Overlays/BeatmapSet/Scores/NotSupporterPlaceholder.cs index d1a0960a08..04ab3ec72f 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/NotSupporterPlaceholder.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/NotSupporterPlaceholder.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -11,7 +13,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapSet.Scores { - public class NotSupporterPlaceholder : Container + public partial class NotSupporterPlaceholder : Container { public NotSupporterPlaceholder() { diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs index 86e72e9faa..425f40258e 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs @@ -1,10 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions; @@ -21,11 +22,13 @@ using osuTK; using osuTK.Graphics; using osu.Framework.Localisation; using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics.Cursor; using osu.Game.Resources.Localisation.Web; +using osu.Game.Scoring.Drawables; namespace osu.Game.Overlays.BeatmapSet.Scores { - public class ScoreTable : TableContainer + public partial class ScoreTable : TableContainer { private const float horizontal_inset = 20; private const float row_height = 22; @@ -36,8 +39,6 @@ namespace osu.Game.Overlays.BeatmapSet.Scores private readonly FillFlowContainer backgroundFlow; - private Color4 highAccuracyColour; - public ScoreTable() { RelativeSizeAxes = Axes.X; @@ -55,16 +56,10 @@ namespace osu.Game.Overlays.BeatmapSet.Scores }); } - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - highAccuracyColour = colours.GreenLight; - } - /// /// The statistics that appear in the table, in order of appearance. /// - private readonly List<(HitResult result, string displayName)> statisticResultTypes = new List<(HitResult, string)>(); + private readonly List<(HitResult result, LocalisableString displayName)> statisticResultTypes = new List<(HitResult, LocalisableString)>(); private bool showPerformancePoints; @@ -100,8 +95,8 @@ namespace osu.Game.Overlays.BeatmapSet.Scores new TableColumn(BeatmapsetsStrings.ShowScoreboardHeadersScore, Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize)), new TableColumn(BeatmapsetsStrings.ShowScoreboardHeadersAccuracy, Anchor.CentreLeft, new Dimension(GridSizeMode.Absolute, minSize: 60, maxSize: 70)), new TableColumn("", Anchor.CentreLeft, new Dimension(GridSizeMode.Absolute, 25)), // flag - new TableColumn(BeatmapsetsStrings.ShowScoreboardHeadersPlayer, Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 125)), - new TableColumn(BeatmapsetsStrings.ShowScoreboardHeadersCombo, Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 70, maxSize: 120)) + new TableColumn(BeatmapsetsStrings.ShowScoreboardHeadersPlayer, Anchor.CentreLeft, new Dimension(minSize: 125)), + new TableColumn(BeatmapsetsStrings.ShowScoreboardHeadersCombo, Anchor.CentreLeft, new Dimension(minSize: 70, maxSize: 120)) }; // All statistics across all scores, unordered. @@ -119,9 +114,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores if (result.IsBonus()) continue; - string displayName = ruleset.GetDisplayNameForHitResult(result); + var displayName = ruleset.GetDisplayNameForHitResult(result); - columns.Add(new TableColumn(displayName, Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 35, maxSize: 60))); + columns.Add(new TableColumn(displayName, Anchor.CentreLeft, new Dimension(minSize: 35, maxSize: 60))); statisticResultTypes.Add((result, displayName)); } @@ -156,27 +151,20 @@ namespace osu.Game.Overlays.BeatmapSet.Scores Current = scoreManager.GetBindableTotalScoreString(score), Font = OsuFont.GetFont(size: text_size, weight: index == 0 ? FontWeight.Bold : FontWeight.Medium) }, - new OsuSpriteText + new StatisticText(score.Accuracy, 1, showTooltip: false) { Margin = new MarginPadding { Right = horizontal_inset }, Text = score.DisplayAccuracy, - Font = OsuFont.GetFont(size: text_size), - Colour = score.Accuracy == 1 ? highAccuracyColour : Color4.White }, - new UpdateableFlag(score.User.Country) + new UpdateableFlag(score.User.CountryCode) { - Size = new Vector2(19, 13), - ShowPlaceholderOnNull = false, + Size = new Vector2(19, 14), + ShowPlaceholderOnUnknown = false, }, username, - new OsuSpriteText - { - Text = score.MaxCombo.ToLocalisableString(@"0\x"), - Font = OsuFont.GetFont(size: text_size), #pragma warning disable 618 - Colour = score.MaxCombo == score.BeatmapInfo.MaxCombo ? highAccuracyColour : Color4.White + new StatisticText(score.MaxCombo, score.BeatmapInfo.MaxCombo, @"0\x"), #pragma warning restore 618 - } }; var availableStatistics = score.GetStatisticsForDisplay().ToDictionary(tuple => tuple.Result); @@ -186,23 +174,15 @@ namespace osu.Game.Overlays.BeatmapSet.Scores if (!availableStatistics.TryGetValue(result.result, out var stat)) stat = new HitResultDisplayStatistic(result.result, 0, null, result.displayName); - content.Add(new OsuSpriteText - { - Text = stat.MaxCount == null ? stat.Count.ToLocalisableString(@"N0") : (LocalisableString)$"{stat.Count}/{stat.MaxCount}", - Font = OsuFont.GetFont(size: text_size), - Colour = stat.Count == 0 ? Color4.Gray : Color4.White - }); + content.Add(new StatisticText(stat.Count, stat.MaxCount, @"N0") { Colour = stat.Count == 0 ? Color4.Gray : Color4.White }); } if (showPerformancePoints) { - Debug.Assert(score.PP != null); - - content.Add(new OsuSpriteText - { - Text = score.PP.ToLocalisableString(@"N0"), - Font = OsuFont.GetFont(size: text_size) - }); + if (score.PP != null) + content.Add(new StatisticText(score.PP, format: @"N0")); + else + content.Add(new UnprocessedPerformancePointsPlaceholder { Size = new Vector2(text_size) }); } content.Add(new ScoreboardTime(score.Date, text_size) @@ -227,7 +207,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores protected override Drawable CreateHeader(int index, TableColumn column) => new HeaderText(column?.Header ?? default); - private class HeaderText : OsuSpriteText + private partial class HeaderText : OsuSpriteText { public HeaderText(LocalisableString text) { @@ -241,5 +221,31 @@ namespace osu.Game.Overlays.BeatmapSet.Scores Colour = colourProvider.Foreground1; } } + + private partial class StatisticText : OsuSpriteText, IHasTooltip + { + private readonly double? count; + private readonly double? maxCount; + private readonly bool showTooltip; + + public LocalisableString TooltipText => maxCount == null || !showTooltip ? string.Empty : $"{count}/{maxCount}"; + + public StatisticText(double? count, double? maxCount = null, string format = null, bool showTooltip = true) + { + this.count = count; + this.maxCount = maxCount; + this.showTooltip = showTooltip; + + Text = count?.ToLocalisableString(format) ?? default; + Font = OsuFont.GetFont(size: text_size); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + if (count == maxCount) + Colour = colours.GreenLight; + } + } } } diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTableRowBackground.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTableRowBackground.cs index b6079b36ab..130dfd45e7 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTableRowBackground.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTableRowBackground.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -12,7 +14,7 @@ using osu.Game.Scoring; namespace osu.Game.Overlays.BeatmapSet.Scores { - public class ScoreTableRowBackground : CompositeDrawable + public partial class ScoreTableRowBackground : CompositeDrawable { private const int fade_duration = 100; diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreboardTime.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreboardTime.cs index 5018fb8c70..04cbf171f6 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreboardTime.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreboardTime.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Game.Extensions; using osu.Game.Graphics; namespace osu.Game.Overlays.BeatmapSet.Scores { - public class ScoreboardTime : DrawableDate + public partial class ScoreboardTime : DrawableDate { public ScoreboardTime(DateTimeOffset date, float textSize = OsuFont.DEFAULT_FONT_SIZE, bool italic = true) : base(date, textSize, italic) diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs index 5f24a6549d..6d89313979 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs @@ -1,13 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Diagnostics; using System.Linq; using System.Threading; -using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -24,7 +24,7 @@ using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Overlays.BeatmapSet.Scores { - public class ScoresContainer : BeatmapSetLayoutSection + public partial class ScoresContainer : BeatmapSetLayoutSection { private const int spacing = 15; @@ -85,27 +85,19 @@ namespace osu.Game.Overlays.BeatmapSet.Scores MD5Hash = apiBeatmap.MD5Hash }; - scoreManager.OrderByTotalScoreAsync(value.Scores.Select(s => s.CreateScoreInfo(rulesets, beatmapInfo)).ToArray(), loadCancellationSource.Token) - .ContinueWith(task => Schedule(() => - { - if (loadCancellationSource.IsCancellationRequested) - return; + var scores = scoreManager.OrderByTotalScore(value.Scores.Select(s => s.ToScoreInfo(rulesets, beatmapInfo))).ToArray(); + var topScore = scores.First(); - var scores = task.GetResultSafely(); + scoreTable.DisplayScores(scores, apiBeatmap.Status.GrantsPerformancePoints()); + scoreTable.Show(); - var topScore = scores.First(); + var userScore = value.UserScore; + var userScoreInfo = userScore?.Score.ToScoreInfo(rulesets, beatmapInfo); - scoreTable.DisplayScores(scores, apiBeatmap.Status.GrantsPerformancePoints()); - scoreTable.Show(); + topScoresContainer.Add(new DrawableTopScore(topScore)); - var userScore = value.UserScore; - var userScoreInfo = userScore?.Score.CreateScoreInfo(rulesets, beatmapInfo); - - topScoresContainer.Add(new DrawableTopScore(topScore)); - - if (userScoreInfo != null && userScoreInfo.OnlineID != topScore.OnlineID) - topScoresContainer.Add(new DrawableTopScore(userScoreInfo, userScore.Position)); - }), TaskContinuationOptions.OnlyOnRanToCompletion); + if (userScoreInfo != null && userScoreInfo.OnlineID != topScore.OnlineID) + topScoresContainer.Add(new DrawableTopScore(userScoreInfo, userScore.Position)); }); } @@ -124,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[] { @@ -215,7 +207,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores scope.BindValueChanged(_ => getScores()); ruleset.BindValueChanged(_ => getScores()); - modSelector.SelectedMods.CollectionChanged += (_, __) => getScores(); + modSelector.SelectedMods.CollectionChanged += (_, _) => getScores(); Beatmap.BindValueChanged(onBeatmapChanged); user.BindValueChanged(onUserChanged, true); @@ -242,8 +234,6 @@ namespace osu.Game.Overlays.BeatmapSet.Scores modSelector.DeselectAll(); else getScores(); - - modSelector.FadeTo(userIsSupporter ? 1 : 0); } private void getScores() @@ -260,7 +250,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores return; } - if (scope.Value != BeatmapLeaderboardScope.Global && !userIsSupporter) + if ((scope.Value != BeatmapLeaderboardScope.Global || modSelector.SelectedMods.Count > 0) && !userIsSupporter) { Scores = null; notSupporterPlaceholder.Show(); diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs index f528f54cdf..e030b1e34f 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -10,19 +12,22 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; using osu.Game.Scoring; +using osu.Game.Scoring.Drawables; using osuTK; namespace osu.Game.Overlays.BeatmapSet.Scores { - public class TopScoreStatisticsSection : CompositeDrawable + public partial class TopScoreStatisticsSection : CompositeDrawable { private const float margin = 10; private const float top_columns_min_width = 64; @@ -119,7 +124,11 @@ namespace osu.Game.Overlays.BeatmapSet.Scores maxComboColumn.Text = value.MaxCombo.ToLocalisableString(@"0\x"); ppColumn.Alpha = value.BeatmapInfo.Status.GrantsPerformancePoints() ? 1 : 0; - ppColumn.Text = value.PP?.ToLocalisableString(@"N0") ?? default; + + if (value.PP is double pp) + ppColumn.Text = pp.ToLocalisableString(@"N0"); + else + ppColumn.Drawable = new UnprocessedPerformancePointsPlaceholder { Size = new Vector2(smallFont.Size) }; statisticsColumns.ChildrenEnumerable = value.GetStatisticsForDisplay().Select(createStatisticsColumn); modsColumn.Mods = value.Mods; @@ -134,7 +143,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores Text = stat.MaxCount == null ? stat.Count.ToLocalisableString(@"N0") : (LocalisableString)$"{stat.Count}/{stat.MaxCount}" }; - private class InfoColumn : CompositeDrawable + private partial class InfoColumn : CompositeDrawable { private readonly Box separator; private readonly OsuSpriteText text; @@ -195,34 +204,52 @@ namespace osu.Game.Overlays.BeatmapSet.Scores } } - private class TextColumn : InfoColumn + private partial class TextColumn : InfoColumn, IHasCurrentValue { - private readonly SpriteText text; - - public TextColumn(LocalisableString title, FontUsage font, float? minWidth = null) - : this(title, new OsuSpriteText { Font = font }, minWidth) - { - } - - private TextColumn(LocalisableString title, SpriteText text, float? minWidth = null) - : base(title, text, minWidth) - { - this.text = text; - } + private readonly OsuTextFlowContainer text; public LocalisableString Text { set => text.Text = value; } + public Drawable Drawable + { + set + { + text.Clear(); + text.AddArbitraryDrawable(value); + } + } + + private Bindable current; + public Bindable Current { - get => text.Current; - set => text.Current = value; + get => current; + set + { + text.Clear(); + text.AddText(value.Value, t => t.Current = current = value); + } + } + + public TextColumn(LocalisableString title, FontUsage font, float? minWidth = null) + : this(title, new OsuTextFlowContainer(t => t.Font = font) + { + AutoSizeAxes = Axes.Both + }, minWidth) + { + } + + private TextColumn(LocalisableString title, OsuTextFlowContainer text, float? minWidth = null) + : base(title, text, minWidth) + { + this.text = text; } } - private class ModsInfoColumn : InfoColumn + private partial class ModsInfoColumn : InfoColumn { private readonly FillFlowContainer modsContainer; diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs index 5c3906cb39..afaed85250 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.LocalisationExtensions; @@ -20,7 +22,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.BeatmapSet.Scores { - public class TopScoreUserSection : CompositeDrawable + public partial class TopScoreUserSection : CompositeDrawable { private readonly SpriteText rankText; private readonly UpdateableRank rank; @@ -116,9 +118,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Size = new Vector2(19, 13), + Size = new Vector2(19, 14), Margin = new MarginPadding { Top = 3 }, // makes spacing look more even - ShowPlaceholderOnNull = false, + ShowPlaceholderOnUnknown = false, }, } } @@ -139,7 +141,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores set { avatar.User = value.User; - flag.Country = value.User.Country; + flag.CountryCode = value.User.CountryCode; achievedOn.Date = value.Date; usernameText.Clear(); diff --git a/osu.Game/Overlays/BeatmapSet/SpotlightBeatmapBadge.cs b/osu.Game/Overlays/BeatmapSet/SpotlightBeatmapBadge.cs index 9c5378a967..00534ff700 100644 --- a/osu.Game/Overlays/BeatmapSet/SpotlightBeatmapBadge.cs +++ b/osu.Game/Overlays/BeatmapSet/SpotlightBeatmapBadge.cs @@ -5,11 +5,9 @@ using osu.Framework.Allocation; using osu.Game.Graphics; using osu.Game.Resources.Localisation.Web; -#nullable enable - namespace osu.Game.Overlays.BeatmapSet { - public class SpotlightBeatmapBadge : BeatmapBadge + public partial class SpotlightBeatmapBadge : BeatmapBadge { [BackgroundDependencyLoader] private void load(OsuColour colours) diff --git a/osu.Game/Overlays/BeatmapSet/SuccessRate.cs b/osu.Game/Overlays/BeatmapSet/SuccessRate.cs index fed3d7ddaa..48732ac586 100644 --- a/osu.Game/Overlays/BeatmapSet/SuccessRate.cs +++ b/osu.Game/Overlays/BeatmapSet/SuccessRate.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; @@ -16,7 +18,7 @@ using osu.Game.Screens.Select.Details; namespace osu.Game.Overlays.BeatmapSet { - public class SuccessRate : Container + public partial class SuccessRate : Container { protected readonly FailRetryGraph Graph; @@ -125,7 +127,7 @@ namespace osu.Game.Overlays.BeatmapSet Graph.Padding = new MarginPadding { Top = header.DrawHeight }; } - private class SuccessRatePercentage : OsuSpriteText, IHasTooltip + private partial class SuccessRatePercentage : OsuSpriteText, IHasTooltip { public LocalisableString TooltipText { get; set; } } diff --git a/osu.Game/Overlays/BeatmapSetOverlay.cs b/osu.Game/Overlays/BeatmapSetOverlay.cs index bd63c997df..873336bb6e 100644 --- a/osu.Game/Overlays/BeatmapSetOverlay.cs +++ b/osu.Game/Overlays/BeatmapSetOverlay.cs @@ -1,28 +1,50 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System; +using System.Collections.Generic; using System.Linq; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.BeatmapSet; using osu.Game.Overlays.BeatmapSet.Scores; using osu.Game.Overlays.Comments; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Select.Details; using osuTK; using osuTK.Graphics; namespace osu.Game.Overlays { - public class BeatmapSetOverlay : OnlineOverlay + public partial class BeatmapSetOverlay : OnlineOverlay { - public const float X_PADDING = 40; public const float Y_PADDING = 25; public const float RIGHT_WIDTH = 275; private readonly Bindable beatmapSet = new Bindable(); + [Resolved] + private IAPIProvider api { get; set; } + + private IBindable apiUser; + + private (BeatmapSetLookupType type, int id)? lastLookup; + + /// + /// Isolates the beatmap set overlay from the game-wide selected mods bindable + /// to avoid affecting the beatmap details section (i.e. ). + /// + [Cached] + [Cached(typeof(IBindable>))] + protected readonly Bindable> SelectedMods = new Bindable>(Array.Empty()); + public BeatmapSetOverlay() : base(OverlayColourScheme.Blue) { @@ -57,6 +79,17 @@ namespace osu.Game.Overlays }; } + [BackgroundDependencyLoader] + private void load() + { + apiUser = api.LocalUser.GetBoundCopy(); + apiUser.BindValueChanged(_ => Schedule(() => + { + if (api.IsLoggedIn) + performFetch(); + })); + } + protected override BeatmapSetHeader CreateHeader() => new BeatmapSetHeader(); protected override Color4 BackgroundColour => ColourProvider.Background6; @@ -69,27 +102,20 @@ namespace osu.Game.Overlays public void FetchAndShowBeatmap(int beatmapId) { + lastLookup = (BeatmapSetLookupType.BeatmapId, beatmapId); beatmapSet.Value = null; - var req = new GetBeatmapSetRequest(beatmapId, BeatmapSetLookupType.BeatmapId); - req.Success += res => - { - beatmapSet.Value = res; - Header.HeaderContent.Picker.Beatmap.Value = Header.BeatmapSet.Value.Beatmaps.First(b => b.OnlineID == beatmapId); - }; - API.Queue(req); - + performFetch(); Show(); } public void FetchAndShowBeatmapSet(int beatmapSetId) { + lastLookup = (BeatmapSetLookupType.SetId, beatmapSetId); + beatmapSet.Value = null; - var req = new GetBeatmapSetRequest(beatmapSetId); - req.Success += res => beatmapSet.Value = res; - API.Queue(req); - + performFetch(); Show(); } @@ -103,7 +129,25 @@ namespace osu.Game.Overlays Show(); } - private class CommentsSection : BeatmapSetLayoutSection + private void performFetch() + { + if (!api.IsLoggedIn) + return; + + if (lastLookup == null) + return; + + var req = new GetBeatmapSetRequest(lastLookup.Value.id, lastLookup.Value.type); + req.Success += res => + { + beatmapSet.Value = res; + if (lastLookup.Value.type == BeatmapSetLookupType.BeatmapId) + Header.HeaderContent.Picker.Beatmap.Value = Header.BeatmapSet.Value.Beatmaps.First(b => b.OnlineID == lastLookup.Value.id); + }; + API.Queue(req); + } + + private partial class CommentsSection : BeatmapSetLayoutSection { public readonly Bindable BeatmapSet = new Bindable(); diff --git a/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs b/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs index 0d383c374f..e730496b5c 100644 --- a/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs +++ b/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; @@ -9,11 +11,11 @@ using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays { - public abstract class BreadcrumbControlOverlayHeader : TabControlOverlayHeader + public abstract partial class BreadcrumbControlOverlayHeader : TabControlOverlayHeader { protected override OsuTabControl CreateTabControl() => new OverlayHeaderBreadcrumbControl(); - public class OverlayHeaderBreadcrumbControl : BreadcrumbControl + public partial class OverlayHeaderBreadcrumbControl : BreadcrumbControl { public OverlayHeaderBreadcrumbControl() { @@ -32,7 +34,7 @@ namespace osu.Game.Overlays AccentColour = AccentColour, }; - private class ControlTabItem : BreadcrumbTabItem + private partial class ControlTabItem : BreadcrumbTabItem { protected override float ChevronSize => 8; diff --git a/osu.Game/Overlays/Changelog/ChangelogBuild.cs b/osu.Game/Overlays/Changelog/ChangelogBuild.cs index c65eefdee4..08978ac2ab 100644 --- a/osu.Game/Overlays/Changelog/ChangelogBuild.cs +++ b/osu.Game/Overlays/Changelog/ChangelogBuild.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -14,10 +16,8 @@ using osu.Framework.Allocation; namespace osu.Game.Overlays.Changelog { - public class ChangelogBuild : FillFlowContainer + public partial class ChangelogBuild : FillFlowContainer { - public const float HORIZONTAL_PADDING = 70; - public Action SelectBuild; protected readonly APIChangelogBuild Build; @@ -31,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[] { @@ -66,11 +66,15 @@ namespace osu.Game.Overlays.Changelog Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, + Direction = FillDirection.Vertical, Margin = new MarginPadding { Top = 20 }, - Children = new Drawable[] + Child = new FillFlowContainer { - new OsuHoverContainer + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Child = new OsuHoverContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game/Overlays/Changelog/ChangelogContent.cs b/osu.Game/Overlays/Changelog/ChangelogContent.cs index 49dd9bb835..51ef4ddf94 100644 --- a/osu.Game/Overlays/Changelog/ChangelogContent.cs +++ b/osu.Game/Overlays/Changelog/ChangelogContent.cs @@ -8,9 +8,9 @@ using System; namespace osu.Game.Overlays.Changelog { - public class ChangelogContent : FillFlowContainer + public partial class ChangelogContent : FillFlowContainer { - public Action BuildSelected; + public Action? BuildSelected; public void SelectBuild(APIChangelogBuild build) => BuildSelected?.Invoke(build); diff --git a/osu.Game/Overlays/Changelog/ChangelogEntry.cs b/osu.Game/Overlays/Changelog/ChangelogEntry.cs index da062ca617..ab671d9c86 100644 --- a/osu.Game/Overlays/Changelog/ChangelogEntry.cs +++ b/osu.Game/Overlays/Changelog/ChangelogEntry.cs @@ -1,7 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; +using System.Diagnostics; using System.Net; using System.Text.RegularExpressions; using osu.Framework.Allocation; @@ -18,7 +21,7 @@ using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Overlays.Changelog { - public class ChangelogEntry : FillFlowContainer + public partial class ChangelogEntry : FillFlowContainer { private readonly APIChangelogEntry entry; @@ -91,7 +94,7 @@ namespace osu.Game.Overlays.Changelog t.Colour = entryColour; }); - if (!string.IsNullOrEmpty(entry.Repository)) + if (!string.IsNullOrEmpty(entry.Repository) && !string.IsNullOrEmpty(entry.GithubUrl)) addRepositoryReference(title, entryColour); if (entry.GithubUser != null) @@ -102,17 +105,22 @@ namespace osu.Game.Overlays.Changelog private void addRepositoryReference(LinkFlowContainer title, Color4 entryColour) { + Debug.Assert(!string.IsNullOrEmpty(entry.Repository)); + Debug.Assert(!string.IsNullOrEmpty(entry.GithubUrl)); + title.AddText(" (", t => { t.Font = fontLarge; t.Colour = entryColour; }); + title.AddLink($"{entry.Repository.Replace("ppy/", "")}#{entry.GithubPullRequestId}", entry.GithubUrl, t => { t.Font = fontLarge; t.Colour = entryColour; }); + title.AddText(")", t => { t.Font = fontLarge; diff --git a/osu.Game/Overlays/Changelog/ChangelogHeader.cs b/osu.Game/Overlays/Changelog/ChangelogHeader.cs index 49a33ee5d6..e9be67e977 100644 --- a/osu.Game/Overlays/Changelog/ChangelogHeader.cs +++ b/osu.Game/Overlays/Changelog/ChangelogHeader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -16,7 +18,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Changelog { - public class ChangelogHeader : BreadcrumbControlOverlayHeader + public partial class ChangelogHeader : BreadcrumbControlOverlayHeader { public readonly Bindable Build = new Bindable(); @@ -91,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 }, @@ -115,7 +117,7 @@ namespace osu.Game.Overlays.Changelog currentStream.Value = Streams.Items.FirstOrDefault(s => s.Name == Build.Value.UpdateStream.Name); } - private class ChangelogHeaderTitle : OverlayTitle + private partial class ChangelogHeaderTitle : OverlayTitle { public ChangelogHeaderTitle() { diff --git a/osu.Game/Overlays/Changelog/ChangelogListing.cs b/osu.Game/Overlays/Changelog/ChangelogListing.cs index 9b74a8da6d..4b784c7a28 100644 --- a/osu.Game/Overlays/Changelog/ChangelogListing.cs +++ b/osu.Game/Overlays/Changelog/ChangelogListing.cs @@ -1,9 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -13,7 +16,7 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Overlays.Changelog { - public class ChangelogListing : ChangelogContent + public partial class ChangelogListing : ChangelogContent { private readonly List entries; @@ -49,7 +52,7 @@ namespace osu.Game.Overlays.Changelog Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Margin = new MarginPadding { Top = 20 }, - Text = build.CreatedAt.Date.ToString("dd MMMM yyyy"), + Text = build.CreatedAt.Date.ToLocalisableString("dd MMMM yyyy"), Font = OsuFont.GetFont(weight: FontWeight.Regular, size: 24), }); @@ -61,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/ChangelogSingleBuild.cs b/osu.Game/Overlays/Changelog/ChangelogSingleBuild.cs index 93486274fc..13a19de22a 100644 --- a/osu.Game/Overlays/Changelog/ChangelogSingleBuild.cs +++ b/osu.Game/Overlays/Changelog/ChangelogSingleBuild.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; -using System.Linq; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -22,7 +24,7 @@ using osuTK; namespace osu.Game.Overlays.Changelog { - public class ChangelogSingleBuild : ChangelogContent + public partial class ChangelogSingleBuild : ChangelogContent { private APIChangelogBuild build; @@ -89,7 +91,7 @@ namespace osu.Game.Overlays.Changelog } } - public class ChangelogBuildWithNavigation : ChangelogBuild + public partial class ChangelogBuildWithNavigation : ChangelogBuild { public ChangelogBuildWithNavigation(APIChangelogBuild build) : base(build) @@ -102,27 +104,29 @@ namespace osu.Game.Overlays.Changelog { var fill = base.CreateHeader(); - foreach (var existing in fill.Children.OfType()) + var nestedFill = (FillFlowContainer)fill.Child; + + var buildDisplay = (OsuHoverContainer)nestedFill.Child; + + buildDisplay.Scale = new Vector2(1.25f); + buildDisplay.Action = null; + + fill.Add(date = new OsuSpriteText { - existing.Scale = new Vector2(1.25f); - existing.Action = null; + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = Build.CreatedAt.Date.ToLocalisableString("dd MMMM yyyy"), + Font = OsuFont.GetFont(weight: FontWeight.Regular, size: 14), + Margin = new MarginPadding { Top = 5 }, + Scale = new Vector2(1.25f), + }); - existing.Add(date = new OsuSpriteText - { - Text = Build.CreatedAt.Date.ToString("dd MMMM yyyy"), - Font = OsuFont.GetFont(weight: FontWeight.Regular, size: 14), - Anchor = Anchor.BottomCentre, - Origin = Anchor.TopCentre, - Margin = new MarginPadding { Top = 5 }, - }); - } - - fill.Insert(-1, new NavigationIconButton(Build.Versions?.Previous) + nestedFill.Insert(-1, new NavigationIconButton(Build.Versions?.Previous) { Icon = FontAwesome.Solid.ChevronLeft, SelectBuild = b => SelectBuild(b) }); - fill.Insert(1, new NavigationIconButton(Build.Versions?.Next) + nestedFill.Insert(1, new NavigationIconButton(Build.Versions?.Next) { Icon = FontAwesome.Solid.ChevronRight, SelectBuild = b => SelectBuild(b) @@ -138,7 +142,7 @@ namespace osu.Game.Overlays.Changelog } } - private class NavigationIconButton : IconButton + private partial class NavigationIconButton : IconButton { public Action SelectBuild; diff --git a/osu.Game/Overlays/Changelog/ChangelogSupporterPromo.cs b/osu.Game/Overlays/Changelog/ChangelogSupporterPromo.cs index e84eee15be..4aded1dd59 100644 --- a/osu.Game/Overlays/Changelog/ChangelogSupporterPromo.cs +++ b/osu.Game/Overlays/Changelog/ChangelogSupporterPromo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -20,7 +22,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Changelog { - public class ChangelogSupporterPromo : CompositeDrawable + public partial class ChangelogSupporterPromo : CompositeDrawable { private const float image_container_width = 164; private const float heart_size = 75; @@ -32,7 +34,7 @@ namespace osu.Game.Overlays.Changelog Padding = new MarginPadding { Vertical = 20, - Horizontal = 50, + Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING, }; } @@ -77,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 @@ -158,7 +160,7 @@ namespace osu.Game.Overlays.Changelog supportLinkText.AddText(" today!"); } - private class SupporterPromoLinkFlowContainer : LinkFlowContainer + private partial class SupporterPromoLinkFlowContainer : LinkFlowContainer { public SupporterPromoLinkFlowContainer(Action defaultCreationParameters) : base(defaultCreationParameters) @@ -167,7 +169,7 @@ namespace osu.Game.Overlays.Changelog protected override DrawableLinkCompiler CreateLinkCompiler(ITextPart textPart) => new SupporterPromoLinkCompiler(textPart); - private class SupporterPromoLinkCompiler : DrawableLinkCompiler + private partial class SupporterPromoLinkCompiler : DrawableLinkCompiler { public SupporterPromoLinkCompiler(ITextPart part) : base(part) diff --git a/osu.Game/Overlays/Changelog/ChangelogUpdateStreamControl.cs b/osu.Game/Overlays/Changelog/ChangelogUpdateStreamControl.cs index aa36a5c8fd..155cbc7d65 100644 --- a/osu.Game/Overlays/Changelog/ChangelogUpdateStreamControl.cs +++ b/osu.Game/Overlays/Changelog/ChangelogUpdateStreamControl.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Overlays.Changelog { - public class ChangelogUpdateStreamControl : OverlayStreamControl + public partial class ChangelogUpdateStreamControl : OverlayStreamControl { public ChangelogUpdateStreamControl() { diff --git a/osu.Game/Overlays/Changelog/ChangelogUpdateStreamItem.cs b/osu.Game/Overlays/Changelog/ChangelogUpdateStreamItem.cs index cb144defbf..08ea373fb1 100644 --- a/osu.Game/Overlays/Changelog/ChangelogUpdateStreamItem.cs +++ b/osu.Game/Overlays/Changelog/ChangelogUpdateStreamItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Humanizer; using osu.Framework.Localisation; using osu.Game.Graphics; @@ -9,7 +11,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Changelog { - public class ChangelogUpdateStreamItem : OverlayStreamItem + public partial class ChangelogUpdateStreamItem : OverlayStreamItem { public ChangelogUpdateStreamItem(APIUpdateStream stream) : base(stream) diff --git a/osu.Game/Overlays/ChangelogOverlay.cs b/osu.Game/Overlays/ChangelogOverlay.cs index ab97ae950d..4cc38c41e4 100644 --- a/osu.Game/Overlays/ChangelogOverlay.cs +++ b/osu.Game/Overlays/ChangelogOverlay.cs @@ -1,14 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; using osu.Game.Input.Bindings; using osu.Game.Online.API.Requests; @@ -18,10 +22,8 @@ using osuTK.Graphics; namespace osu.Game.Overlays { - public class ChangelogOverlay : OnlineOverlay + public partial class ChangelogOverlay : OnlineOverlay { - public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; - public readonly Bindable Current = new Bindable(); private List builds; @@ -68,7 +70,7 @@ namespace osu.Game.Overlays /// are specified, the header will instantly display them. public void ShowBuild([NotNull] APIChangelogBuild build) { - if (build == null) throw new ArgumentNullException(nameof(build)); + ArgumentNullException.ThrowIfNull(build); Current.Value = build; Show(); @@ -76,8 +78,10 @@ namespace osu.Game.Overlays public void ShowBuild([NotNull] string updateStream, [NotNull] string version) { - if (updateStream == null) throw new ArgumentNullException(nameof(updateStream)); - if (version == null) throw new ArgumentNullException(nameof(version)); + ArgumentNullException.ThrowIfNull(updateStream); + ArgumentNullException.ThrowIfNull(version); + + Show(); performAfterFetch(() => { @@ -87,8 +91,6 @@ namespace osu.Game.Overlays if (build != null) ShowBuild(build); }); - - Show(); } public override bool OnPressed(KeyBindingPressEvent e) @@ -125,11 +127,16 @@ namespace osu.Game.Overlays private Task initialFetchTask; - private void performAfterFetch(Action action) => Schedule(() => + private void performAfterFetch(Action action) { - fetchListing()?.ContinueWith(_ => - Schedule(action), TaskContinuationOptions.OnlyOnRanToCompletion); - }); + Debug.Assert(State.Value == Visibility.Visible); + + Schedule(() => + { + fetchListing()?.ContinueWith(_ => + Schedule(action), TaskContinuationOptions.OnlyOnRanToCompletion); + }); + } private Task fetchListing() { diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelList.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelList.cs index 47a2d234d1..e6fe97f3c6 100644 --- a/osu.Game/Overlays/Chat/ChannelList/ChannelList.cs +++ b/osu.Game/Overlays/Chat/ChannelList/ChannelList.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 enable - using System; using System.Linq; using System.Collections.Generic; @@ -21,7 +19,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Chat.ChannelList { - public class ChannelList : Container + public partial class ChannelList : Container { public Action? OnRequestSelect; public Action? OnRequestLeave; @@ -101,7 +99,7 @@ namespace osu.Game.Overlays.Chat.ChannelList FillFlowContainer flow = getFlowForChannel(channel); channelMap.Remove(channel); - flow.Remove(item); + flow.Remove(item, true); updateVisibility(); } @@ -142,7 +140,7 @@ namespace osu.Game.Overlays.Chat.ChannelList announceChannelGroup.Show(); } - private class ChannelGroup : FillFlowContainer + private partial class ChannelGroup : FillFlowContainer { public readonly FillFlowContainer ItemFlow; diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs index 9ab0c2792a..57b6f6268c 100644 --- a/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs +++ b/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.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 enable - using System; using System.Linq; using osu.Framework.Allocation; @@ -21,7 +19,7 @@ using osuTK; namespace osu.Game.Overlays.Chat.ChannelList { - public class ChannelListItem : OsuClickableContainer + public partial class ChannelListItem : OsuClickableContainer { public event Action? OnRequestSelect; public event Action? OnRequestLeave; diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelListItemCloseButton.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelListItemCloseButton.cs index 65b9c4a79b..204b9aab19 100644 --- a/osu.Game/Overlays/Chat/ChannelList/ChannelListItemCloseButton.cs +++ b/osu.Game/Overlays/Chat/ChannelList/ChannelListItemCloseButton.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 enable - using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; @@ -14,7 +12,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Chat.ChannelList { - public class ChannelListItemCloseButton : OsuClickableContainer + public partial class ChannelListItemCloseButton : OsuClickableContainer { private SpriteIcon icon = null!; diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelListItemMentionPill.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelListItemMentionPill.cs index 5018c8cd64..bbd042cfc9 100644 --- a/osu.Game/Overlays/Chat/ChannelList/ChannelListItemMentionPill.cs +++ b/osu.Game/Overlays/Chat/ChannelList/ChannelListItemMentionPill.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 enable - using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -15,7 +13,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Chat.ChannelList { - public class ChannelListItemMentionPill : CircularContainer + public partial class ChannelListItemMentionPill : CircularContainer { public readonly BindableInt Mentions = new BindableInt(); diff --git a/osu.Game/Overlays/Chat/ChannelScrollContainer.cs b/osu.Game/Overlays/Chat/ChannelScrollContainer.cs index 139c091f03..090f7835ae 100644 --- a/osu.Game/Overlays/Chat/ChannelScrollContainer.cs +++ b/osu.Game/Overlays/Chat/ChannelScrollContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Graphics.Containers; @@ -9,7 +11,7 @@ namespace osu.Game.Overlays.Chat /// /// An with functionality to automatically scroll whenever the maximum scrollable distance increases. /// - public class ChannelScrollContainer : OsuScrollContainer + public partial class ChannelScrollContainer : OsuScrollContainer { /// /// The chat will be automatically scrolled to end if and only if diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs index 13f66089d9..70c3bf181c 100644 --- a/osu.Game/Overlays/Chat/ChatLine.cs +++ b/osu.Game/Overlays/Chat/ChatLine.cs @@ -1,57 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Linq; using System.Collections.Generic; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; -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.Graphics.UserInterface; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; -using osuTK; -using osuTK.Graphics; +using osu.Framework.Graphics.Sprites; namespace osu.Game.Overlays.Chat { - public class ChatLine : CompositeDrawable + public partial class ChatLine : CompositeDrawable { - protected virtual float TextSize => 20; - - protected virtual float Spacing => 15; - - protected virtual float TimestampWidth => 60; - - protected virtual float UsernameWidth => 130; - - private Color4 usernameColour; - - private OsuSpriteText timestamp; - - public ChatLine(Message message) - { - Message = message; - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - } - - [Resolved(CanBeNull = true)] - private ChannelManager chatManager { get; set; } - - private Message message; - private OsuSpriteText username; - - public LinkFlowContainer ContentFlow { get; private set; } + private Message message = null!; public Message Message { @@ -69,17 +39,35 @@ namespace osu.Game.Overlays.Chat } } - private bool senderHasColour => !string.IsNullOrEmpty(message.Sender.Colour); + public IReadOnlyCollection DrawableContentFlow => drawableContentFlow; + + protected virtual float FontSize => 20; + + protected virtual float Spacing => 15; + + protected virtual float UsernameWidth => 130; [Resolved] - private OsuColour colours { get; set; } + private ChannelManager? chatManager { get; set; } - [BackgroundDependencyLoader] - private void load() + [Resolved] + private OverlayColourProvider? colourProvider { get; set; } + + private readonly OsuSpriteText drawableTimestamp; + + private readonly DrawableUsername drawableUsername; + + private readonly LinkFlowContainer drawableContentFlow; + + private readonly Bindable prefer24HourTime = new Bindable(); + + private Container? highlight; + + public ChatLine(Message message) { - usernameColour = senderHasColour - ? Color4Extensions.FromHex(message.Sender.Colour) - : username_colours[message.Sender.Id % username_colours.Length]; + Message = message; + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; InternalChild = new GridContainer { @@ -88,52 +76,32 @@ namespace osu.Game.Overlays.Chat RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, ColumnDimensions = new[] { - new Dimension(GridSizeMode.Absolute, TimestampWidth + Spacing + UsernameWidth + Spacing), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, Spacing + UsernameWidth + Spacing), new Dimension(), }, Content = new[] { new Drawable[] { - new Container + drawableTimestamp = new OsuSpriteText { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] - { - timestamp = new OsuSpriteText - { - Shadow = false, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: TextSize * 0.75f, weight: FontWeight.SemiBold, fixedWidth: true), - MaxWidth = TimestampWidth, - }, - new MessageSender(message.Sender) - { - Width = UsernameWidth, - AutoSizeAxes = Axes.Y, - Origin = Anchor.TopRight, - Anchor = Anchor.TopRight, - Child = createUsername(), - Margin = new MarginPadding { Horizontal = Spacing }, - }, - }, + Shadow = false, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: FontSize * 0.75f, weight: FontWeight.SemiBold, fixedWidth: true), + AlwaysPresent = true, }, - ContentFlow = new LinkFlowContainer(t => + drawableUsername = new DrawableUsername(message.Sender) { - t.Shadow = false; - - if (Message.IsAction) - { - t.Font = OsuFont.GetFont(italics: true); - - if (senderHasColour) - t.Colour = Color4Extensions.FromHex(message.Sender.Colour); - } - - t.Font = t.Font.With(size: TextSize); - }) + Width = UsernameWidth, + FontSize = FontSize, + AutoSizeAxes = Axes.Y, + Origin = Anchor.TopRight, + Anchor = Anchor.TopRight, + Margin = new MarginPadding { Horizontal = Spacing }, + }, + drawableContentFlow = new LinkFlowContainer(styleMessageContent) { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, @@ -143,16 +111,23 @@ namespace osu.Game.Overlays.Chat }; } + [BackgroundDependencyLoader] + private void load(OsuConfigManager configManager) + { + configManager.BindWith(OsuSetting.Prefer24HourTime, prefer24HourTime); + prefer24HourTime.BindValueChanged(_ => updateTimestamp()); + } + protected override void LoadComplete() { base.LoadComplete(); + drawableTimestamp.Colour = colourProvider?.Background1 ?? Colour4.White; + updateMessageContent(); FinishTransforms(true); } - private Container highlight; - /// /// Performs a highlight animation on this . /// @@ -165,7 +140,7 @@ namespace osu.Game.Overlays.Chat CornerRadius = 2f, Masking = true, RelativeSizeAxes = Axes.Both, - Colour = usernameColour.Darken(1f), + Colour = drawableUsername.AccentColour.Darken(1f), Depth = float.MaxValue, Child = new Box { RelativeSizeAxes = Axes.Both } }); @@ -175,155 +150,33 @@ namespace osu.Game.Overlays.Chat highlight.Expire(); } + private void styleMessageContent(SpriteText text) + { + text.Shadow = false; + text.Font = text.Font.With(size: FontSize, italics: Message.IsAction); + + bool messageHasColour = Message.IsAction && !string.IsNullOrEmpty(message.Sender.Colour); + text.Colour = messageHasColour ? Color4Extensions.FromHex(message.Sender.Colour) : colourProvider?.Content1 ?? Colour4.White; + } + private void updateMessageContent() { this.FadeTo(message is LocalEchoMessage ? 0.4f : 1.0f, 500, Easing.OutQuint); - timestamp.FadeTo(message is LocalEchoMessage ? 0 : 1, 500, Easing.OutQuint); + drawableTimestamp.FadeTo(message is LocalEchoMessage ? 0 : 1, 500, Easing.OutQuint); - timestamp.Text = $@"{message.Timestamp.LocalDateTime:HH:mm:ss}"; - username.Text = $@"{message.Sender.Username}"; + updateTimestamp(); + drawableUsername.Text = $@"{message.Sender.Username}"; // remove non-existent channels from the link list message.Links.RemoveAll(link => link.Action == LinkAction.OpenChannel && chatManager?.AvailableChannels.Any(c => c.Name == link.Argument.ToString()) != true); - ContentFlow.Clear(); - ContentFlow.AddLinks(message.DisplayContent, message.Links); + drawableContentFlow.Clear(); + drawableContentFlow.AddLinks(message.DisplayContent, message.Links); } - private Drawable createUsername() + private void updateTimestamp() { - username = new OsuSpriteText - { - Shadow = false, - Colour = senderHasColour ? colours.ChatBlue : usernameColour, - Truncate = true, - EllipsisString = "…", - Font = OsuFont.GetFont(size: TextSize, weight: FontWeight.Bold, italics: true), - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - MaxWidth = UsernameWidth, - }; - - if (!senderHasColour) - return username; - - // Background effect - return new Container - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - AutoSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 4, - EdgeEffect = new EdgeEffectParameters - { - Roundness = 1, - Radius = 1, - Colour = Color4.Black.Opacity(0.3f), - Offset = new Vector2(0, 1), - Type = EdgeEffectType.Shadow, - }, - Child = new Container - { - AutoSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 4, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = usernameColour, - }, - new Container - { - AutoSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = 4, Right = 4, Bottom = 1, Top = -2 }, - Child = username - } - } - } - }; + drawableTimestamp.Text = message.Timestamp.LocalDateTime.ToLocalisableString(prefer24HourTime.Value ? @"HH:mm:ss" : @"hh:mm:ss tt"); } - - private class MessageSender : OsuClickableContainer, IHasContextMenu - { - private readonly APIUser sender; - - private Action startChatAction; - - [Resolved] - private IAPIProvider api { get; set; } - - public MessageSender(APIUser sender) - { - this.sender = sender; - } - - [BackgroundDependencyLoader(true)] - private void load(UserProfileOverlay profile, ChannelManager chatManager) - { - Action = () => profile?.ShowUser(sender); - startChatAction = () => chatManager?.OpenPrivateChannel(sender); - } - - public MenuItem[] ContextMenuItems - { - get - { - if (sender.Equals(APIUser.SYSTEM_USER)) - return Array.Empty(); - - List items = new List - { - new OsuMenuItem("View Profile", MenuItemType.Highlighted, Action) - }; - - if (!sender.Equals(api.LocalUser.Value)) - items.Add(new OsuMenuItem("Start Chat", MenuItemType.Standard, startChatAction)); - - return items.ToArray(); - } - } - } - - private static readonly Color4[] username_colours = - { - Color4Extensions.FromHex("588c7e"), - Color4Extensions.FromHex("b2a367"), - Color4Extensions.FromHex("c98f65"), - Color4Extensions.FromHex("bc5151"), - Color4Extensions.FromHex("5c8bd6"), - Color4Extensions.FromHex("7f6ab7"), - Color4Extensions.FromHex("a368ad"), - Color4Extensions.FromHex("aa6880"), - - Color4Extensions.FromHex("6fad9b"), - Color4Extensions.FromHex("f2e394"), - Color4Extensions.FromHex("f2ae72"), - Color4Extensions.FromHex("f98f8a"), - Color4Extensions.FromHex("7daef4"), - Color4Extensions.FromHex("a691f2"), - Color4Extensions.FromHex("c894d3"), - Color4Extensions.FromHex("d895b0"), - - Color4Extensions.FromHex("53c4a1"), - Color4Extensions.FromHex("eace5c"), - Color4Extensions.FromHex("ea8c47"), - Color4Extensions.FromHex("fc4f4f"), - Color4Extensions.FromHex("3d94ea"), - Color4Extensions.FromHex("7760ea"), - Color4Extensions.FromHex("af52c6"), - Color4Extensions.FromHex("e25696"), - - Color4Extensions.FromHex("677c66"), - Color4Extensions.FromHex("9b8732"), - Color4Extensions.FromHex("8c5129"), - Color4Extensions.FromHex("8c3030"), - Color4Extensions.FromHex("1f5d91"), - Color4Extensions.FromHex("4335a5"), - Color4Extensions.FromHex("812a96"), - Color4Extensions.FromHex("992861"), - }; } } diff --git a/osu.Game/Overlays/Chat/ChatOverlayTopBar.cs b/osu.Game/Overlays/Chat/ChatOverlayTopBar.cs index 79f22b51f7..0410174dc1 100644 --- a/osu.Game/Overlays/Chat/ChatOverlayTopBar.cs +++ b/osu.Game/Overlays/Chat/ChatOverlayTopBar.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 enable - using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -19,7 +17,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Chat { - public class ChatOverlayTopBar : Container + public partial class ChatOverlayTopBar : Container { private Box background = null!; diff --git a/osu.Game/Overlays/Chat/ChatTextBar.cs b/osu.Game/Overlays/Chat/ChatTextBar.cs index 5100959eeb..fd5e0e9836 100644 --- a/osu.Game/Overlays/Chat/ChatTextBar.cs +++ b/osu.Game/Overlays/Chat/ChatTextBar.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 enable - using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -19,7 +17,7 @@ using osuTK; namespace osu.Game.Overlays.Chat { - public class ChatTextBar : Container + public partial class ChatTextBar : Container { public readonly BindableBool ShowSearch = new BindableBool(); @@ -41,6 +39,7 @@ namespace osu.Game.Overlays.Chat private const float chatting_text_width = 220; private const float search_icon_width = 40; + private const float padding = 5; [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) @@ -73,9 +72,10 @@ namespace osu.Game.Overlays.Chat RelativeSizeAxes = Axes.Y, Width = chatting_text_width, Masking = true, - Padding = new MarginPadding { Right = 5 }, + Padding = new MarginPadding { Horizontal = padding }, Child = chattingText = new OsuSpriteText { + MaxWidth = chatting_text_width - padding * 2, Font = OsuFont.Torus.With(size: 20), Colour = colourProvider.Background1, Anchor = Anchor.CentreRight, @@ -99,7 +99,7 @@ namespace osu.Game.Overlays.Chat new Container { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Right = 5 }, + Padding = new MarginPadding { Right = padding }, Child = chatTextBox = new ChatTextBox { Anchor = Anchor.CentreLeft, @@ -130,9 +130,8 @@ namespace osu.Game.Overlays.Chat chattingTextContainer.FadeTo(showSearch ? 0 : 1); searchIconContainer.FadeTo(showSearch ? 1 : 0); - // Clear search terms if any exist when switching back to chat mode - if (!showSearch) - OnSearchTermsChanged?.Invoke(string.Empty); + if (showSearch) + OnSearchTermsChanged?.Invoke(chatTextBox.Current.Value); }, true); currentChannel.BindValueChanged(change => @@ -153,6 +152,12 @@ namespace osu.Game.Overlays.Chat chattingText.Text = ChatStrings.TalkingIn(newChannel.Name); break; } + + if (change.OldValue != null) + chatTextBox.Current.UnbindFrom(change.OldValue.TextBoxMessage); + + if (newChannel != null) + chatTextBox.Current.BindTo(newChannel.TextBoxMessage); }, true); } diff --git a/osu.Game/Overlays/Chat/ChatTextBox.cs b/osu.Game/Overlays/Chat/ChatTextBox.cs index 1ee0e8445f..7cd005698e 100644 --- a/osu.Game/Overlays/Chat/ChatTextBox.cs +++ b/osu.Game/Overlays/Chat/ChatTextBox.cs @@ -1,20 +1,20 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - using osu.Framework.Bindables; using osu.Game.Graphics.UserInterface; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Chat { - public class ChatTextBox : FocusedTextBox + public partial class ChatTextBox : HistoryTextBox { public readonly BindableBool ShowSearch = new BindableBool(); public override bool HandleLeftRightArrows => !ShowSearch.Value; + protected override bool ClearTextOnBackKey => false; + protected override void LoadComplete() { base.LoadComplete(); @@ -24,7 +24,6 @@ namespace osu.Game.Overlays.Chat bool showSearch = change.NewValue; PlaceholderText = showSearch ? HomeStrings.SearchPlaceholder : ChatStrings.InputPlaceholder; - Text = string.Empty; }, true); } diff --git a/osu.Game/Overlays/Chat/DaySeparator.cs b/osu.Game/Overlays/Chat/DaySeparator.cs index 9ae35b0c38..e737b787ba 100644 --- a/osu.Game/Overlays/Chat/DaySeparator.cs +++ b/osu.Game/Overlays/Chat/DaySeparator.cs @@ -1,10 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - using System; using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -13,7 +12,7 @@ using osu.Game.Graphics.Sprites; namespace osu.Game.Overlays.Chat { - public class DaySeparator : Container + public partial class DaySeparator : Container { protected virtual float TextSize => 15; @@ -23,14 +22,14 @@ namespace osu.Game.Overlays.Chat protected virtual float Spacing => 15; - private readonly DateTimeOffset time; + public readonly DateTimeOffset Date; [Resolved(CanBeNull = true)] private OverlayColourProvider? colourProvider { get; set; } - public DaySeparator(DateTimeOffset time) + public DaySeparator(DateTimeOffset date) { - this.time = time; + Date = date; Height = 40; } @@ -81,7 +80,7 @@ namespace osu.Game.Overlays.Chat { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, - Text = time.ToLocalTime().ToString("dd MMMM yyyy").ToUpper(), + Text = Date.ToLocalTime().ToLocalisableString(@"dd MMMM yyyy").ToUpper(), Font = OsuFont.Torus.With(size: TextSize, weight: FontWeight.SemiBold), Colour = colourProvider?.Content1 ?? Colour4.White, }, diff --git a/osu.Game/Overlays/Chat/DrawableChannel.cs b/osu.Game/Overlays/Chat/DrawableChannel.cs index c3a341bca4..aa17df4907 100644 --- a/osu.Game/Overlays/Chat/DrawableChannel.cs +++ b/osu.Game/Overlays/Chat/DrawableChannel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; @@ -15,7 +17,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Chat { - public class DrawableChannel : Container + public partial class DrawableChannel : Container { public readonly Channel Channel; protected FillFlowContainer ChatLineFlow; @@ -132,35 +134,22 @@ namespace osu.Game.Overlays.Chat foreach (var message in displayMessages) { - if (lastMessage == null || lastMessage.Timestamp.ToLocalTime().Date != message.Timestamp.ToLocalTime().Date) - ChatLineFlow.Add(CreateDaySeparator(message.Timestamp)); + addDaySeparatorIfRequired(lastMessage, message); ChatLineFlow.Add(CreateChatLine(message)); lastMessage = message; } var staleMessages = chatLines.Where(c => c.LifetimeEnd == double.MaxValue).ToArray(); + int count = staleMessages.Length - Channel.MAX_HISTORY; if (count > 0) { - void expireAndAdjustScroll(Drawable d) - { - scroll.OffsetScrollPosition(-d.DrawHeight); - d.Expire(); - } - for (int i = 0; i < count; i++) expireAndAdjustScroll(staleMessages[i]); - // remove all adjacent day separators after stale message removal - for (int i = 0; i < ChatLineFlow.Count - 1; i++) - { - if (!(ChatLineFlow[i] is DaySeparator)) break; - if (!(ChatLineFlow[i + 1] is DaySeparator)) break; - - expireAndAdjustScroll(ChatLineFlow[i]); - } + removeAdjacentDaySeparators(); } // due to the scroll adjusts from old messages removal above, a scroll-to-end must be enforced, @@ -179,12 +168,48 @@ namespace osu.Game.Overlays.Chat { Trace.Assert(updated.Id.HasValue, "An updated message was returned with no ID."); - ChatLineFlow.Remove(found); + ChatLineFlow.Remove(found, false); found.Message = updated; + + addDaySeparatorIfRequired(chatLines.LastOrDefault()?.Message, updated); ChatLineFlow.Add(found); } }); + private void addDaySeparatorIfRequired(Message lastMessage, Message message) + { + if (lastMessage == null || lastMessage.Timestamp.ToLocalTime().Date != message.Timestamp.ToLocalTime().Date) + { + // A day separator is displayed even if no messages are in the channel. + // If there are no messages after it, the simplest way to ensure it is fresh is to remove it + // and add a new one instead. + if (ChatLineFlow.LastOrDefault() is DaySeparator ds) + ChatLineFlow.Remove(ds, true); + + ChatLineFlow.Add(CreateDaySeparator(message.Timestamp)); + + removeAdjacentDaySeparators(); + } + } + + private void removeAdjacentDaySeparators() + { + // remove all adjacent day separators after stale message removal + for (int i = 0; i < ChatLineFlow.Count - 1; i++) + { + if (!(ChatLineFlow[i] is DaySeparator)) break; + if (!(ChatLineFlow[i + 1] is DaySeparator)) break; + + expireAndAdjustScroll(ChatLineFlow[i]); + } + } + + private void expireAndAdjustScroll(Drawable d) + { + scroll.OffsetScrollPosition(-d.DrawHeight); + d.Expire(); + } + private void messageRemoved(Message removed) => Schedule(() => { chatLines.FirstOrDefault(c => c.Message == removed)?.FadeColour(Color4.Red, 400).FadeOut(600).Expire(); diff --git a/osu.Game/Overlays/Chat/DrawableUsername.cs b/osu.Game/Overlays/Chat/DrawableUsername.cs new file mode 100644 index 0000000000..031a0b6ae2 --- /dev/null +++ b/osu.Game/Overlays/Chat/DrawableUsername.cs @@ -0,0 +1,240 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; +using osu.Game.Resources.Localisation.Web; +using osuTK; +using osuTK.Graphics; +using ChatStrings = osu.Game.Localisation.ChatStrings; + +namespace osu.Game.Overlays.Chat +{ + public partial class DrawableUsername : OsuClickableContainer, IHasContextMenu + { + public Color4 AccentColour { get; } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => + colouredDrawable.ReceivePositionalInputAt(screenSpacePos); + + public float FontSize + { + set => drawableText.Font = OsuFont.GetFont(size: value, weight: FontWeight.Bold, italics: true); + } + + public LocalisableString Text + { + set => drawableText.Text = value; + } + + public override float Width + { + get => base.Width; + set => base.Width = drawableText.MaxWidth = value; + } + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved(canBeNull: true)] + private ChannelManager? chatManager { get; set; } + + [Resolved(canBeNull: true)] + private ChatOverlay? chatOverlay { get; set; } + + [Resolved(canBeNull: true)] + private UserProfileOverlay? profileOverlay { get; set; } + + [Resolved] + private Bindable? currentChannel { get; set; } + + private readonly APIUser user; + private readonly OsuSpriteText drawableText; + + private readonly Drawable colouredDrawable; + + public DrawableUsername(APIUser user) + { + this.user = user; + + Action = openUserProfile; + + drawableText = new OsuSpriteText + { + Shadow = false, + Truncate = true, + EllipsisString = "…", + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }; + + if (string.IsNullOrWhiteSpace(user.Colour)) + { + AccentColour = default_colours[user.Id % default_colours.Length]; + + Add(colouredDrawable = drawableText); + } + else + { + AccentColour = Color4Extensions.FromHex(user.Colour); + + Add(new Container + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 4, + EdgeEffect = new EdgeEffectParameters + { + Roundness = 1, + Radius = 1, + Colour = Color4.Black.Opacity(0.3f), + Offset = new Vector2(0, 1), + Type = EdgeEffectType.Shadow, + }, + Child = new Container + { + AutoSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 4, + Children = new[] + { + colouredDrawable = new Box + { + RelativeSizeAxes = Axes.Both, + }, + new Container + { + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = 4, Right = 4, Bottom = 1, Top = -2 }, + Child = drawableText, + } + } + } + }); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + drawableText.Colour = colours.ChatBlue; + colouredDrawable.Colour = AccentColour; + } + + public MenuItem[] ContextMenuItems + { + get + { + if (user.Equals(APIUser.SYSTEM_USER)) + return Array.Empty(); + + List items = new List + { + new OsuMenuItem(ContextMenuStrings.ViewProfile, MenuItemType.Highlighted, openUserProfile) + }; + + if (!user.Equals(api.LocalUser.Value)) + items.Add(new OsuMenuItem(UsersStrings.CardSendMessage, MenuItemType.Standard, openUserChannel)); + + if (currentChannel?.Value != null) + { + items.Add(new OsuMenuItem(ChatStrings.MentionUser, MenuItemType.Standard, () => + { + currentChannel.Value.TextBoxMessage.Value += $"@{user.Username} "; + })); + } + + return items.ToArray(); + } + } + + private void openUserChannel() + { + chatManager?.OpenPrivateChannel(user); + chatOverlay?.Show(); + } + + private void openUserProfile() + { + profileOverlay?.ShowUser(user); + } + + protected override bool OnHover(HoverEvent e) + { + colouredDrawable.FadeColour(AccentColour.Lighten(0.6f), 30, Easing.OutQuint); + + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + + colouredDrawable.FadeColour(AccentColour, 800, Easing.OutQuint); + } + + private static readonly Color4[] default_colours = + { + Color4Extensions.FromHex("588c7e"), + Color4Extensions.FromHex("b2a367"), + Color4Extensions.FromHex("c98f65"), + Color4Extensions.FromHex("bc5151"), + Color4Extensions.FromHex("5c8bd6"), + Color4Extensions.FromHex("7f6ab7"), + Color4Extensions.FromHex("a368ad"), + Color4Extensions.FromHex("aa6880"), + + Color4Extensions.FromHex("6fad9b"), + Color4Extensions.FromHex("f2e394"), + Color4Extensions.FromHex("f2ae72"), + Color4Extensions.FromHex("f98f8a"), + Color4Extensions.FromHex("7daef4"), + Color4Extensions.FromHex("a691f2"), + Color4Extensions.FromHex("c894d3"), + Color4Extensions.FromHex("d895b0"), + + Color4Extensions.FromHex("53c4a1"), + Color4Extensions.FromHex("eace5c"), + Color4Extensions.FromHex("ea8c47"), + Color4Extensions.FromHex("fc4f4f"), + Color4Extensions.FromHex("3d94ea"), + Color4Extensions.FromHex("7760ea"), + Color4Extensions.FromHex("af52c6"), + Color4Extensions.FromHex("e25696"), + + Color4Extensions.FromHex("677c66"), + Color4Extensions.FromHex("9b8732"), + Color4Extensions.FromHex("8c5129"), + Color4Extensions.FromHex("8c3030"), + Color4Extensions.FromHex("1f5d91"), + Color4Extensions.FromHex("4335a5"), + Color4Extensions.FromHex("812a96"), + Color4Extensions.FromHex("992861"), + }; + } +} diff --git a/osu.Game/Overlays/Chat/ExternalLinkDialog.cs b/osu.Game/Overlays/Chat/ExternalLinkDialog.cs deleted file mode 100644 index dbae091fb0..0000000000 --- a/osu.Game/Overlays/Chat/ExternalLinkDialog.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Framework.Graphics.Sprites; -using osu.Game.Overlays.Dialog; - -namespace osu.Game.Overlays.Chat -{ - public class ExternalLinkDialog : PopupDialog - { - public ExternalLinkDialog(string url, Action openExternalLinkAction) - { - HeaderText = "Just checking..."; - BodyText = $"You are about to leave osu! and open the following link in a web browser:\n\n{url}"; - - Icon = FontAwesome.Solid.ExclamationTriangle; - - Buttons = new PopupDialogButton[] - { - new PopupDialogOkButton - { - Text = @"Yes. Go for it.", - Action = openExternalLinkAction - }, - new PopupDialogCancelButton - { - Text = @"No! Abort mission!" - }, - }; - } - } -} diff --git a/osu.Game/Overlays/Chat/Listing/ChannelListing.cs b/osu.Game/Overlays/Chat/Listing/ChannelListing.cs index 8a5bc18cbf..809ea2f11d 100644 --- a/osu.Game/Overlays/Chat/Listing/ChannelListing.cs +++ b/osu.Game/Overlays/Chat/Listing/ChannelListing.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 enable - using System; using System.Collections.Generic; using System.Linq; @@ -15,7 +13,7 @@ using osu.Game.Online.Chat; namespace osu.Game.Overlays.Chat.Listing { - public class ChannelListing : VisibilityContainer + public partial class ChannelListing : VisibilityContainer { public event Action? OnRequestJoin; public event Action? OnRequestLeave; diff --git a/osu.Game/Overlays/Chat/Listing/ChannelListingItem.cs b/osu.Game/Overlays/Chat/Listing/ChannelListingItem.cs index 2a21d30a4a..9c85c73ee4 100644 --- a/osu.Game/Overlays/Chat/Listing/ChannelListingItem.cs +++ b/osu.Game/Overlays/Chat/Listing/ChannelListingItem.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 enable - using System; using System.Collections.Generic; using osu.Framework.Allocation; @@ -24,7 +22,7 @@ using osuTK; namespace osu.Game.Overlays.Chat.Listing { - public class ChannelListingItem : OsuClickableContainer, IFilterable + public partial class ChannelListingItem : OsuClickableContainer, IFilterable { public event Action? OnRequestJoin; public event Action? OnRequestLeave; @@ -40,7 +38,7 @@ namespace osu.Game.Overlays.Chat.Listing private Box hoverBox = null!; private SpriteIcon checkbox = null!; private OsuSpriteText channelText = null!; - private OsuSpriteText topicText = null!; + private OsuTextFlowContainer topicText = null!; private IBindable channelJoined = null!; [Resolved] @@ -67,8 +65,8 @@ namespace osu.Game.Overlays.Chat.Listing Masking = true; CornerRadius = 5; - RelativeSizeAxes = Axes.X; - Height = 20 + (vertical_margin * 2); + RelativeSizeAxes = Content.RelativeSizeAxes = Axes.X; + AutoSizeAxes = Content.AutoSizeAxes = Axes.Y; Children = new Drawable[] { @@ -81,14 +79,19 @@ namespace osu.Game.Overlays.Chat.Listing }, new GridContainer { - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, ColumnDimensions = new[] { new Dimension(GridSizeMode.Absolute, 40), new Dimension(GridSizeMode.Absolute, 200), - new Dimension(GridSizeMode.Absolute, 400), + new Dimension(maxSize: 400), new Dimension(GridSizeMode.AutoSize), - new Dimension(), + new Dimension(GridSizeMode.Absolute, 50), // enough for any 5 digit user count + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize, minSize: 20 + (vertical_margin * 2)), }, Content = new[] { @@ -110,12 +113,13 @@ namespace osu.Game.Overlays.Chat.Listing Font = OsuFont.Torus.With(size: text_size, weight: FontWeight.SemiBold), Margin = new MarginPadding { Bottom = 2 }, }, - topicText = new OsuSpriteText + topicText = new OsuTextFlowContainer(t => t.Font = OsuFont.Torus.With(size: text_size)) { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Text = Channel.Topic, - Font = OsuFont.Torus.With(size: text_size), Margin = new MarginPadding { Bottom = 2 }, }, new SpriteIcon diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index f04bf76c18..b44c7c48f5 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.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 enable - using System.Collections.Generic; using System.Collections.Specialized; using System.Diagnostics; @@ -20,6 +18,7 @@ using osu.Game.Configuration; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; +using osu.Game.Online; using osu.Game.Online.Chat; using osu.Game.Overlays.Chat; using osu.Game.Overlays.Chat.ChannelList; @@ -27,7 +26,7 @@ using osu.Game.Overlays.Chat.Listing; namespace osu.Game.Overlays { - public class ChatOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent, IKeyBindingHandler + public partial class ChatOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent, IKeyBindingHandler { public string IconTexture => "Icons/Hexacons/messaging"; public LocalisableString Title => ChatStrings.HeaderTitle; @@ -65,7 +64,7 @@ namespace osu.Game.Overlays private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); [Cached] - private readonly Bindable currentChannel = new Bindable(); + private readonly Bindable currentChannel = new Bindable(); private readonly IBindableList availableChannels = new BindableList(); private readonly IBindableList joinedChannels = new BindableList(); @@ -102,23 +101,10 @@ namespace osu.Game.Overlays RelativeSizeAxes = Axes.X, Height = top_bar_height, }, - channelList = new ChannelList - { - RelativeSizeAxes = Axes.Y, - Width = side_bar_width, - Padding = new MarginPadding { Top = top_bar_height }, - }, new Container { RelativeSizeAxes = Axes.Both, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Padding = new MarginPadding - { - Top = top_bar_height, - Left = side_bar_width, - Bottom = chat_bar_height, - }, + Padding = new MarginPadding { Top = top_bar_height }, Children = new Drawable[] { new Box @@ -126,24 +112,50 @@ namespace osu.Game.Overlays RelativeSizeAxes = Axes.Both, Colour = colourProvider.Background4, }, - currentChannelContainer = new Container + new OnlineViewContainer("Sign in to chat") { RelativeSizeAxes = Axes.Both, - }, - loading = new LoadingLayer(true), - channelListing = new ChannelListing - { - RelativeSizeAxes = Axes.Both, - }, - }, - }, - textBar = new ChatTextBar - { - RelativeSizeAxes = Axes.X, - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Padding = new MarginPadding { Left = side_bar_width }, - }, + Children = new Drawable[] + { + channelList = new ChannelList + { + RelativeSizeAxes = Axes.Y, + Width = side_bar_width, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Padding = new MarginPadding + { + Left = side_bar_width, + Bottom = chat_bar_height, + }, + Children = new Drawable[] + { + currentChannelContainer = new Container + { + RelativeSizeAxes = Axes.Both, + }, + loading = new LoadingLayer(true), + channelListing = new ChannelListing + { + RelativeSizeAxes = Axes.Both, + }, + }, + }, + textBar = new ChatTextBar + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Padding = new MarginPadding { Left = side_bar_width }, + }, + } + } + } + } }; } @@ -281,7 +293,7 @@ namespace osu.Game.Overlays base.OnFocus(e); } - private void currentChannelChanged(ValueChangedEvent channel) + private void currentChannelChanged(ValueChangedEvent channel) { Channel? newChannel = channel.NewValue; @@ -303,10 +315,10 @@ namespace osu.Game.Overlays channelListing.Hide(); textBar.ShowSearch.Value = false; - if (loadedChannels.ContainsKey(newChannel)) + if (loadedChannels.TryGetValue(newChannel, out var loadedChannel)) { currentChannelContainer.Clear(false); - currentChannelContainer.Add(loadedChannels[newChannel]); + currentChannelContainer.Add(loadedChannel); } else { @@ -340,11 +352,13 @@ namespace osu.Game.Overlays protected virtual DrawableChannel CreateDrawableChannel(Channel newChannel) => new DrawableChannel(newChannel); - private void joinedChannelsChanged(object sender, NotifyCollectionChangedEventArgs args) + private void joinedChannelsChanged(object? sender, NotifyCollectionChangedEventArgs args) { switch (args.Action) { case NotifyCollectionChangedAction.Add: + Debug.Assert(args.NewItems != null); + IEnumerable newChannels = args.NewItems.OfType().Where(isChatChannel); foreach (var channel in newChannels) @@ -353,6 +367,8 @@ namespace osu.Game.Overlays break; case NotifyCollectionChangedAction.Remove: + Debug.Assert(args.OldItems != null); + IEnumerable leftChannels = args.OldItems.OfType().Where(isChatChannel); foreach (var channel in leftChannels) @@ -372,7 +388,7 @@ namespace osu.Game.Overlays } } - private void availableChannelsChanged(object sender, NotifyCollectionChangedEventArgs args) + private void availableChannelsChanged(object? sender, NotifyCollectionChangedEventArgs args) => channelListing.UpdateAvailableChannels(channelManager.AvailableChannels); private void handleChatMessage(string message) @@ -390,7 +406,7 @@ namespace osu.Game.Overlays { List overlayChannels = channelList.Channels.ToList(); - if (overlayChannels.Count < 2) + if (overlayChannels.Count < 2 || currentChannel.Value == null) return; int currentIndex = overlayChannels.IndexOf(currentChannel.Value); diff --git a/osu.Game/Overlays/Comments/Buttons/ChevronButton.cs b/osu.Game/Overlays/Comments/Buttons/ChevronButton.cs index 48f34e8f59..88e7d00476 100644 --- a/osu.Game/Overlays/Comments/Buttons/ChevronButton.cs +++ b/osu.Game/Overlays/Comments/Buttons/ChevronButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Graphics.Containers; using osu.Framework.Bindables; @@ -10,7 +12,7 @@ using osu.Framework.Allocation; namespace osu.Game.Overlays.Comments.Buttons { - public class ChevronButton : OsuHoverContainer + public partial class ChevronButton : OsuHoverContainer { public readonly BindableBool Expanded = new BindableBool(true); diff --git a/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs b/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs index 2f7f16dd6f..d9576f5b72 100644 --- a/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs +++ b/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -15,7 +17,7 @@ using static osu.Game.Graphics.UserInterface.ShowMoreButton; namespace osu.Game.Overlays.Comments.Buttons { - public abstract class CommentRepliesButton : CompositeDrawable + public abstract partial class CommentRepliesButton : CompositeDrawable { protected LocalisableString Text { diff --git a/osu.Game/Overlays/Comments/Buttons/LoadRepliesButton.cs b/osu.Game/Overlays/Comments/Buttons/LoadRepliesButton.cs index 4bb5b9d66d..65d8685403 100644 --- a/osu.Game/Overlays/Comments/Buttons/LoadRepliesButton.cs +++ b/osu.Game/Overlays/Comments/Buttons/LoadRepliesButton.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Comments.Buttons { - public class LoadRepliesButton : LoadingButton + public partial class LoadRepliesButton : LoadingButton { private ButtonContent content; @@ -22,7 +24,7 @@ namespace osu.Game.Overlays.Comments.Buttons protected override void OnLoadFinished() => content.ToggleTextVisibility(true); - private class ButtonContent : CommentRepliesButton + private partial class ButtonContent : CommentRepliesButton { public ButtonContent() { diff --git a/osu.Game/Overlays/Comments/Buttons/ShowMoreRepliesButton.cs b/osu.Game/Overlays/Comments/Buttons/ShowMoreRepliesButton.cs index 4908e29b7d..0aedbe4c53 100644 --- a/osu.Game/Overlays/Comments/Buttons/ShowMoreRepliesButton.cs +++ b/osu.Game/Overlays/Comments/Buttons/ShowMoreRepliesButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; @@ -13,7 +15,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Comments.Buttons { - public class ShowMoreRepliesButton : LoadingButton + public partial class ShowMoreRepliesButton : LoadingButton { protected override IEnumerable EffectTargets => new[] { text }; diff --git a/osu.Game/Overlays/Comments/Buttons/ShowRepliesButton.cs b/osu.Game/Overlays/Comments/Buttons/ShowRepliesButton.cs index 04e7e25cc5..555823e996 100644 --- a/osu.Game/Overlays/Comments/Buttons/ShowRepliesButton.cs +++ b/osu.Game/Overlays/Comments/Buttons/ShowRepliesButton.cs @@ -7,13 +7,18 @@ using osu.Framework.Input.Events; namespace osu.Game.Overlays.Comments.Buttons { - public class ShowRepliesButton : CommentRepliesButton + public partial class ShowRepliesButton : CommentRepliesButton { public readonly BindableBool Expanded = new BindableBool(true); public ShowRepliesButton(int count) { - Text = "reply".ToQuantity(count); + Count = count; + } + + public int Count + { + set => Text = "reply".ToQuantity(value); } protected override void LoadComplete() diff --git a/osu.Game/Overlays/Comments/CancellableCommentEditor.cs b/osu.Game/Overlays/Comments/CancellableCommentEditor.cs index 74c221bd82..02abc5b6cf 100644 --- a/osu.Game/Overlays/Comments/CancellableCommentEditor.cs +++ b/osu.Game/Overlays/Comments/CancellableCommentEditor.cs @@ -2,71 +2,23 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Comments { - public abstract class CancellableCommentEditor : CommentEditor + public abstract partial class CancellableCommentEditor : CommentEditor { - public Action OnCancel; + public Action? OnCancel; [BackgroundDependencyLoader] private void load() { - ButtonsContainer.Add(new CancelButton + ButtonsContainer.Add(new EditorButton { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Action = () => OnCancel?.Invoke() + Action = () => OnCancel?.Invoke(), + Text = CommonStrings.ButtonsCancel, }); } - - private class CancelButton : OsuHoverContainer - { - protected override IEnumerable EffectTargets => new[] { background }; - - private readonly Box background; - - public CancelButton() - { - AutoSizeAxes = Axes.Both; - Child = new CircularContainer - { - Masking = true, - Height = 25, - AutoSizeAxes = Axes.X, - Children = new Drawable[] - { - background = new Box - { - RelativeSizeAxes = Axes.Both - }, - new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), - Margin = new MarginPadding { Horizontal = 20 }, - Text = CommonStrings.ButtonsCancel - } - } - }; - } - - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) - { - IdleColour = colourProvider.Light4; - HoverColour = colourProvider.Light3; - } - } } } diff --git a/osu.Game/Overlays/Comments/CommentEditor.cs b/osu.Game/Overlays/Comments/CommentEditor.cs index 3ff4cfad4a..2af7dd3093 100644 --- a/osu.Game/Overlays/Comments/CommentEditor.cs +++ b/osu.Game/Overlays/Comments/CommentEditor.cs @@ -2,51 +2,57 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Graphics.Containers; +using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Graphics.Sprites; -using osuTK.Graphics; using osu.Game.Graphics.UserInterface; -using System.Collections.Generic; -using System; +using osu.Game.Graphics.UserInterfaceV2; using osuTK; -using osu.Framework.Bindables; +using osuTK.Graphics; namespace osu.Game.Overlays.Comments { - public abstract class CommentEditor : CompositeDrawable + public abstract partial class CommentEditor : CompositeDrawable { private const int side_padding = 8; - public Action OnCommit; + protected abstract LocalisableString FooterText { get; } - public bool IsLoading + protected abstract LocalisableString CommitButtonText { get; } + + protected abstract LocalisableString TextBoxPlaceholder { get; } + + protected FillFlowContainer ButtonsContainer { get; private set; } = null!; + + protected readonly Bindable Current = new Bindable(string.Empty); + + private RoundedButton commitButton = null!; + private LoadingSpinner loadingSpinner = null!; + + protected TextBox TextBox { get; private set; } = null!; + + protected bool ShowLoadingSpinner { - get => commitButton.IsLoading; - set => commitButton.IsLoading = value; + set + { + if (value) + loadingSpinner.Show(); + else + loadingSpinner.Hide(); + + updateCommitButtonState(); + } } - protected abstract string FooterText { get; } - - protected abstract string CommitButtonText { get; } - - protected abstract string TextBoxPlaceholder { get; } - - protected FillFlowContainer ButtonsContainer { get; private set; } - - protected readonly Bindable Current = new Bindable(); - - private CommitButton commitButton; - [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) { - EditorTextBox textBox; - RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; Masking = true; @@ -68,7 +74,7 @@ namespace osu.Game.Overlays.Comments Direction = FillDirection.Vertical, Children = new Drawable[] { - textBox = new EditorTextBox + TextBox = new EditorTextBox { Height = 40, RelativeSizeAxes = Axes.X, @@ -77,7 +83,7 @@ namespace osu.Game.Overlays.Comments }, new Container { - Name = "Footer", + Name = @"Footer", RelativeSizeAxes = Axes.X, Height = 35, Padding = new MarginPadding { Horizontal = side_padding }, @@ -90,54 +96,64 @@ namespace osu.Game.Overlays.Comments Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), Text = FooterText }, - ButtonsContainer = new FillFlowContainer + new FillFlowContainer { - Name = "Buttons", Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(5, 0), - Child = commitButton = new CommitButton(CommitButtonText) + Children = new Drawable[] { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Action = () => + ButtonsContainer = new FillFlowContainer { - OnCommit?.Invoke(Current.Value); - Current.Value = string.Empty; - } + Name = @"Buttons", + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5, 0), + Child = commitButton = new EditorButton + { + Text = CommitButtonText, + Action = () => OnCommit(Current.Value) + } + }, + loadingSpinner = new LoadingSpinner + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Size = new Vector2(18), + }, } - } + }, } } } } }); - textBox.OnCommit += (u, v) => - { - if (commitButton.IsBlocked.Value) - return; - - commitButton.TriggerClick(); - }; + TextBox.OnCommit += (_, _) => commitButton.TriggerClick(); } protected override void LoadComplete() { base.LoadComplete(); - - Current.BindValueChanged(text => commitButton.IsBlocked.Value = string.IsNullOrEmpty(text.NewValue), true); + Current.BindValueChanged(_ => updateCommitButtonState(), true); } - private class EditorTextBox : BasicTextBox + protected abstract void OnCommit(string text); + + private void updateCommitButtonState() => + commitButton.Enabled.Value = loadingSpinner.State.Value == Visibility.Hidden && !string.IsNullOrEmpty(Current.Value); + + private partial class EditorTextBox : OsuTextBox { protected override float LeftRightPadding => side_padding; protected override Color4 SelectionColour => Color4.Gray; - private OsuSpriteText placeholder; + private OsuSpriteText placeholder = null!; public EditorTextBox() { @@ -157,92 +173,24 @@ namespace osu.Game.Overlays.Comments { Font = OsuFont.GetFont(weight: FontWeight.Regular), }; - - protected override Drawable GetDrawableCharacter(char c) => new FallingDownContainer - { - AutoSizeAxes = Axes.Both, - Child = new OsuSpriteText { Text = c.ToString(), Font = OsuFont.GetFont(size: CalculatedTextSize) }, - }; } - private class CommitButton : LoadingButton + protected partial class EditorButton : RoundedButton { - private const int duration = 200; - - public readonly BindableBool IsBlocked = new BindableBool(); - - public override bool PropagatePositionalInputSubTree => !IsBlocked.Value && base.PropagatePositionalInputSubTree; - - protected override IEnumerable EffectTargets => new[] { background }; - - private readonly string text; - - [Resolved] - private OverlayColourProvider colourProvider { get; set; } - - private OsuSpriteText drawableText; - private Box background; - private Box blockedBackground; - - public CommitButton(string text) + public EditorButton() { - this.text = text; - - AutoSizeAxes = Axes.Both; - LoadingAnimationSize = new Vector2(10); + Width = 80; + Height = 25; + Anchor = Anchor.CentreRight; + Origin = Anchor.CentreRight; } - [BackgroundDependencyLoader] - private void load() + protected override SpriteText CreateText() { - IdleColour = colourProvider.Light4; - HoverColour = colourProvider.Light3; - blockedBackground.Colour = colourProvider.Background5; + var t = base.CreateText(); + t.Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 12); + return t; } - - protected override void LoadComplete() - { - base.LoadComplete(); - IsBlocked.BindValueChanged(onBlockedStateChanged, true); - } - - private void onBlockedStateChanged(ValueChangedEvent isBlocked) - { - drawableText.FadeColour(isBlocked.NewValue ? colourProvider.Foreground1 : Color4.White, duration, Easing.OutQuint); - background.FadeTo(isBlocked.NewValue ? 0 : 1, duration, Easing.OutQuint); - } - - protected override Drawable CreateContent() => new CircularContainer - { - Masking = true, - Height = 25, - AutoSizeAxes = Axes.X, - Children = new Drawable[] - { - blockedBackground = new Box - { - RelativeSizeAxes = Axes.Both - }, - background = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0 - }, - drawableText = new OsuSpriteText - { - AlwaysPresent = true, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), - Margin = new MarginPadding { Horizontal = 20 }, - Text = text, - } - } - }; - - protected override void OnLoadStarted() => drawableText.FadeOut(duration, Easing.OutQuint); - - protected override void OnLoadFinished() => drawableText.FadeIn(duration, Easing.OutQuint); } } } diff --git a/osu.Game/Overlays/Comments/CommentMarkdownContainer.cs b/osu.Game/Overlays/Comments/CommentMarkdownContainer.cs index 3971a61363..9cc20caa05 100644 --- a/osu.Game/Overlays/Comments/CommentMarkdownContainer.cs +++ b/osu.Game/Overlays/Comments/CommentMarkdownContainer.cs @@ -1,26 +1,24 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Markdig.Syntax; -using Markdig.Syntax.Inlines; using osu.Framework.Graphics.Containers.Markdown; using osu.Game.Graphics.Containers.Markdown; namespace osu.Game.Overlays.Comments { - public class CommentMarkdownContainer : OsuMarkdownContainer + public partial class CommentMarkdownContainer : OsuMarkdownContainer { - public override MarkdownTextFlowContainer CreateTextFlow() => new CommentMarkdownTextFlowContainer(); + protected override OsuMarkdownContainerOptions Options => new OsuMarkdownContainerOptions + { + Autolinks = true + }; protected override MarkdownHeading CreateHeading(HeadingBlock headingBlock) => new CommentMarkdownHeading(headingBlock); - private class CommentMarkdownTextFlowContainer : OsuMarkdownTextFlowContainer - { - // Don't render image in comment for now - protected override void AddImage(LinkInline linkInline) { } - } - - private class CommentMarkdownHeading : OsuMarkdownHeading + private partial class CommentMarkdownHeading : OsuMarkdownHeading { public CommentMarkdownHeading(HeadingBlock headingBlock) : base(headingBlock) diff --git a/osu.Game/Overlays/Comments/CommentReportButton.cs b/osu.Game/Overlays/Comments/CommentReportButton.cs new file mode 100644 index 0000000000..ba5319094b --- /dev/null +++ b/osu.Game/Overlays/Comments/CommentReportButton.cs @@ -0,0 +1,92 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Resources.Localisation.Web; +using osuTK; + +namespace osu.Game.Overlays.Comments +{ + public partial class CommentReportButton : CompositeDrawable, IHasPopover + { + private readonly Comment comment; + + private LinkFlowContainer link = null!; + private LoadingSpinner loading = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private OverlayColourProvider? colourProvider { get; set; } + + public CommentReportButton(Comment comment) + { + this.comment = comment; + } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + link = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold)) + { + AutoSizeAxes = Axes.Both, + }, + loading = new LoadingSpinner + { + Size = new Vector2(12f), + } + }; + + link.AddLink(ReportStrings.CommentButton.ToLower(), this.ShowPopover); + } + + private void report(CommentReportReason reason, string comments) + { + var request = new CommentReportRequest(comment.Id, reason, comments); + + link.Hide(); + loading.Show(); + + request.Success += () => Schedule(() => + { + loading.Hide(); + + link.Clear(true); + link.AddText(UsersStrings.ReportThanks, s => s.Colour = colourProvider?.Content2 ?? Colour4.White); + link.Show(); + + this.FadeOut(2000, Easing.InQuint).Expire(); + }); + + request.Failure += _ => Schedule(() => + { + loading.Hide(); + link.Show(); + }); + + api.Queue(request); + } + + public Popover GetPopover() => new ReportCommentPopover(comment) + { + Action = report + }; + } +} diff --git a/osu.Game/Overlays/Comments/CommentReportReason.cs b/osu.Game/Overlays/Comments/CommentReportReason.cs new file mode 100644 index 0000000000..4fbec0164d --- /dev/null +++ b/osu.Game/Overlays/Comments/CommentReportReason.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Overlays.Comments +{ + public enum CommentReportReason + { + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.ReportOptionsInsults))] + Insults, + + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.ReportOptionsSpam))] + Spam, + + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.ReportOptionsUnwantedContent))] + UnwantedContent, + + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.ReportOptionsNonsense))] + Nonsense, + + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.ReportOptionsOther))] + Other + } +} diff --git a/osu.Game/Overlays/Comments/CommentsContainer.cs b/osu.Game/Overlays/Comments/CommentsContainer.cs index a28b13fc12..24536fe460 100644 --- a/osu.Game/Overlays/Comments/CommentsContainer.cs +++ b/osu.Game/Overlays/Comments/CommentsContainer.cs @@ -1,6 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System; using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; using osu.Game.Online.API; @@ -15,16 +18,22 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Threading; using System.Collections.Generic; using JetBrains.Annotations; +using osu.Framework.Localisation; +using osu.Framework.Logging; using osu.Game.Graphics.Sprites; using osu.Game.Resources.Localisation.Web; -using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; +using osu.Game.Users.Drawables; +using osuTK; namespace osu.Game.Overlays.Comments { - public class CommentsContainer : CompositeDrawable + [Cached] + public partial class CommentsContainer : CompositeDrawable { private readonly Bindable type = new Bindable(); private readonly BindableLong id = new BindableLong(); + public IBindable Type => type; + public IBindable Id => id; public readonly Bindable Sort = new Bindable(); public readonly BindableBool ShowDeleted = new BindableBool(); @@ -44,12 +53,14 @@ namespace osu.Game.Overlays.Comments private DeletedCommentsCounter deletedCommentsCounter; private CommentsShowMoreButton moreButton; private TotalCommentsCounter commentCounter; + private UpdateableAvatar avatar; [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; + AddRangeInternal(new Drawable[] { new Box @@ -84,6 +95,32 @@ namespace osu.Game.Overlays.Comments }, }, }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING, Vertical = 20 }, + Children = new Drawable[] + { + avatar = new UpdateableAvatar(api.LocalUser.Value) + { + Size = new Vector2(50), + CornerExponent = 2, + CornerRadius = 25, + Masking = true, + }, + new Container + { + Padding = new MarginPadding { Left = 60 }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = new NewCommentEditor + { + OnPost = prependPostedComments + } + } + } + }, new CommentsHeader { Sort = { BindTarget = Sort }, @@ -115,7 +152,7 @@ namespace osu.Game.Overlays.Comments ShowDeleted = { BindTarget = ShowDeleted }, Margin = new MarginPadding { - Horizontal = 70, + Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING, Vertical = 10 } }, @@ -149,6 +186,7 @@ namespace osu.Game.Overlays.Comments protected override void LoadComplete() { User.BindValueChanged(_ => refetchComments()); + User.BindValueChanged(e => avatar.User = e.NewValue); Sort.BindValueChanged(_ => refetchComments(), true); base.LoadComplete(); } @@ -243,14 +281,13 @@ namespace osu.Game.Overlays.Comments { pinnedContent.AddRange(loaded.Where(d => d.Comment.Pinned)); content.AddRange(loaded.Where(d => !d.Comment.Pinned)); - deletedCommentsCounter.Count.Value += topLevelComments.Select(d => d.Comment).Count(c => c.IsDeleted && c.IsTopLevel); if (bundle.HasMore) { int loadedTopLevelComments = 0; - pinnedContent.Children.OfType().ForEach(p => loadedTopLevelComments++); - content.Children.OfType().ForEach(p => loadedTopLevelComments++); + pinnedContent.Children.OfType().ForEach(_ => loadedTopLevelComments++); + content.Children.OfType().ForEach(_ => loadedTopLevelComments++); moreButton.Current.Value = bundle.TopLevelCount - loadedTopLevelComments; moreButton.IsLoading = false; @@ -264,7 +301,7 @@ namespace osu.Game.Overlays.Comments void addNewComment(Comment comment) { - var drawableComment = getDrawableComment(comment); + var drawableComment = GetDrawableComment(comment); if (comment.ParentId == null) { @@ -286,7 +323,35 @@ namespace osu.Game.Overlays.Comments } } - private DrawableComment getDrawableComment(Comment comment) + private void prependPostedComments(CommentBundle bundle) + { + var topLevelComments = new List(); + + foreach (var comment in bundle.Comments) + { + // Exclude possible duplicated comments. + if (CommentDictionary.ContainsKey(comment.Id)) + continue; + + topLevelComments.Add(GetDrawableComment(comment)); + } + + if (topLevelComments.Any()) + { + LoadComponentsAsync(topLevelComments, loaded => + { + if (content.Count > 0 && content[0] is NoCommentsPlaceholder placeholder) + content.Remove(placeholder, true); + + foreach (var comment in loaded) + { + content.Insert((int)-Clock.CurrentTime, comment); + } + }, (loadCancellation = new CancellationTokenSource()).Token); + } + } + + public DrawableComment GetDrawableComment(Comment comment) { if (CommentDictionary.TryGetValue(comment.Id, out var existing)) return existing; @@ -315,7 +380,7 @@ namespace osu.Game.Overlays.Comments base.Dispose(isDisposing); } - private class NoCommentsPlaceholder : CompositeDrawable + internal partial class NoCommentsPlaceholder : CompositeDrawable { [BackgroundDependencyLoader] private void load() @@ -328,11 +393,47 @@ 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 } }); } } + + private partial class NewCommentEditor : CommentEditor + { + [Resolved] + private CommentsContainer commentsContainer { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } + + public Action OnPost; + + //TODO should match web, left empty due to no multiline support + protected override LocalisableString FooterText => default; + + protected override LocalisableString CommitButtonText => CommonStrings.ButtonsPost; + + protected override LocalisableString TextBoxPlaceholder => CommentsStrings.PlaceholderNew; + + protected override void OnCommit(string text) + { + ShowLoadingSpinner = true; + CommentPostRequest req = new CommentPostRequest(commentsContainer.Type.Value, commentsContainer.Id.Value, text); + req.Failure += e => Schedule(() => + { + ShowLoadingSpinner = false; + Logger.Error(e, "Posting comment failed."); + }); + req.Success += cb => Schedule(() => + { + ShowLoadingSpinner = false; + Current.Value = string.Empty; + OnPost?.Invoke(cb); + }); + api.Queue(req); + } + } } } diff --git a/osu.Game/Overlays/Comments/CommentsHeader.cs b/osu.Game/Overlays/Comments/CommentsHeader.cs index e7d9e72dcc..0ae1f839a1 100644 --- a/osu.Game/Overlays/Comments/CommentsHeader.cs +++ b/osu.Game/Overlays/Comments/CommentsHeader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -18,7 +20,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Comments { - public class CommentsHeader : CompositeDrawable + public partial class CommentsHeader : CompositeDrawable { public readonly Bindable Sort = new Bindable(); public readonly BindableBool ShowDeleted = new BindableBool(); @@ -39,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 @@ -65,7 +67,7 @@ namespace osu.Game.Overlays.Comments background.Colour = colourProvider.Background4; } - private class ShowDeletedButton : HeaderButton + private partial class ShowDeletedButton : HeaderButton { public readonly BindableBool Checked = new BindableBool(); diff --git a/osu.Game/Overlays/Comments/CommentsShowMoreButton.cs b/osu.Game/Overlays/Comments/CommentsShowMoreButton.cs index b1ca39c3bf..1770fcb269 100644 --- a/osu.Game/Overlays/Comments/CommentsShowMoreButton.cs +++ b/osu.Game/Overlays/Comments/CommentsShowMoreButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Localisation; @@ -9,7 +11,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Comments { - public class CommentsShowMoreButton : ShowMoreButton + public partial class CommentsShowMoreButton : ShowMoreButton { public readonly BindableInt Current = new BindableInt(); diff --git a/osu.Game/Overlays/Comments/DeletedCommentsCounter.cs b/osu.Game/Overlays/Comments/DeletedCommentsCounter.cs index 8c40d79f7a..6adb388185 100644 --- a/osu.Game/Overlays/Comments/DeletedCommentsCounter.cs +++ b/osu.Game/Overlays/Comments/DeletedCommentsCounter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Containers; using osu.Framework.Graphics; using osu.Game.Graphics; @@ -12,7 +14,7 @@ using osu.Game.Graphics.Sprites; namespace osu.Game.Overlays.Comments { - public class DeletedCommentsCounter : CompositeDrawable + public partial class DeletedCommentsCounter : CompositeDrawable { public readonly BindableBool ShowDeleted = new BindableBool(); diff --git a/osu.Game/Overlays/Comments/DrawableComment.cs b/osu.Game/Overlays/Comments/DrawableComment.cs index 3ec91c8e63..a710406548 100644 --- a/osu.Game/Overlays/Comments/DrawableComment.cs +++ b/osu.Game/Overlays/Comments/DrawableComment.cs @@ -19,17 +19,27 @@ using System; using osu.Framework.Graphics.Shapes; using osu.Framework.Extensions.IEnumerableExtensions; using System.Collections.Specialized; +using System.Diagnostics; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Localisation; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; using osu.Game.Overlays.Comments.Buttons; +using osu.Game.Overlays.Dialog; +using osu.Game.Overlays.OSD; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Comments { - public class DrawableComment : CompositeDrawable + [Cached] + public partial class DrawableComment : CompositeDrawable { private const int avatar_size = 40; - public Action RepliesRequested; + public Action RepliesRequested = null!; public readonly Comment Comment; @@ -43,13 +53,42 @@ namespace osu.Game.Overlays.Comments private int currentPage; - private FillFlowContainer childCommentsVisibilityContainer; - private FillFlowContainer childCommentsContainer; - private LoadRepliesButton loadRepliesButton; - private ShowMoreRepliesButton showMoreButton; - private ShowRepliesButton showRepliesButton; - private ChevronButton chevronButton; - private DeletedCommentsCounter deletedCommentsCounter; + /// + /// Local field for tracking comment state. Initialized from Comment.IsDeleted, may change when deleting was requested by user. + /// + public bool WasDeleted { get; protected set; } + + /// + /// Tracks this comment's level of nesting. 0 means that this comment has no parents. + /// + public int Level { get; private set; } + + private FillFlowContainer childCommentsVisibilityContainer = null!; + private FillFlowContainer childCommentsContainer = null!; + private LoadRepliesButton loadRepliesButton = null!; + private ShowMoreRepliesButton showMoreButton = null!; + private ShowRepliesButton showRepliesButton = null!; + private ChevronButton chevronButton = null!; + private LinkFlowContainer actionsContainer = null!; + private LoadingSpinner actionsLoading = null!; + private DeletedCommentsCounter deletedCommentsCounter = null!; + private OsuSpriteText deletedLabel = null!; + private GridContainer content = null!; + private VotePill votePill = null!; + private Container replyEditorContainer = null!; + private Container repliesButtonContainer = null!; + + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private GameHost host { get; set; } = null!; + + [Resolved] + private OnScreenDisplay? onScreenDisplay { get; set; } public DrawableComment(Comment comment) { @@ -57,13 +96,15 @@ namespace osu.Game.Overlays.Comments } [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) + private void load(OverlayColourProvider colourProvider, DrawableComment? parentComment) { LinkFlowContainer username; FillFlowContainer info; CommentMarkdownContainer message; - GridContainer content; - VotePill votePill; + + Level = parentComment?.Level + 1 ?? 0; + + float childrenPadding = Level < 6 ? 20 : 5; RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -146,9 +187,9 @@ namespace osu.Game.Overlays.Comments }, Comment.Pinned ? new PinnedCommentNotice() : Empty(), new ParentUsername(Comment), - new OsuSpriteText + deletedLabel = new OsuSpriteText { - Alpha = Comment.IsDeleted ? 1 : 0, + Alpha = 0f, Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold), Text = CommentsStrings.Deleted } @@ -161,22 +202,50 @@ namespace osu.Game.Overlays.Comments DocumentMargin = new MarginPadding(0), DocumentPadding = new MarginPadding(0), }, - info = new FillFlowContainer + new FillFlowContainer { AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(10, 0), Children = new Drawable[] { - new DrawableDate(Comment.CreatedAt, 12, false) + info = new FillFlowContainer { - Colour = colourProvider.Foreground1 + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + new DrawableDate(Comment.CreatedAt, 12, false) + { + Colour = colourProvider.Foreground1 + } + } + }, + actionsContainer = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold)) + { + Name = @"Actions buttons", + AutoSizeAxes = Axes.Both, + }, + actionsLoading = new LoadingSpinner + { + Size = new Vector2(12f), + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft } } }, - new Container + replyEditorContainer = new Container + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Padding = new MarginPadding { Top = 10 }, + Alpha = 0, + }, + repliesButtonContainer = new Container { AutoSizeAxes = Axes.Both, + Alpha = 0, Children = new Drawable[] { showRepliesButton = new ShowRepliesButton(Comment.RepliesCount) @@ -196,10 +265,11 @@ namespace osu.Game.Overlays.Comments }, childCommentsVisibilityContainer = new FillFlowContainer { + Name = @"Children comments", RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, - Padding = new MarginPadding { Left = 20 }, + Padding = new MarginPadding { Left = childrenPadding }, Children = new Drawable[] { childCommentsContainer = new FillFlowContainer @@ -244,9 +314,9 @@ namespace osu.Game.Overlays.Comments if (Comment.UserId.HasValue) username.AddUserLink(Comment.User); else - username.AddText(Comment.LegacyName); + username.AddText(Comment.LegacyName!); - if (Comment.EditedAt.HasValue) + if (Comment.EditedAt.HasValue && Comment.EditedUser != null) { var font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular); var colour = colourProvider.Foreground1; @@ -280,11 +350,19 @@ namespace osu.Game.Overlays.Comments if (Comment.HasMessage) message.Text = Comment.Message; - if (Comment.IsDeleted) - { - content.FadeColour(OsuColour.Gray(0.5f)); - votePill.Hide(); - } + WasDeleted = Comment.IsDeleted; + if (WasDeleted) + makeDeleted(); + + actionsContainer.AddLink(CommonStrings.ButtonsPermalink, copyUrl); + actionsContainer.AddArbitraryDrawable(Empty().With(d => d.Width = 10)); + actionsContainer.AddLink(CommonStrings.ButtonsReply.ToLower(), toggleReply); + actionsContainer.AddArbitraryDrawable(Empty().With(d => d.Width = 10)); + + if (Comment.UserId.HasValue && Comment.UserId.Value == api.LocalUser.Value.Id) + actionsContainer.AddLink(CommonStrings.ButtonsDelete.ToLower(), deleteComment); + else + actionsContainer.AddArbitraryDrawable(new CommentReportButton(Comment)); if (Comment.IsTopLevel) { @@ -306,6 +384,8 @@ namespace osu.Game.Overlays.Comments switch (args.Action) { case NotifyCollectionChangedAction.Add: + Debug.Assert(args.NewItems != null); + onRepliesAdded(args.NewItems.Cast()); break; @@ -315,11 +395,87 @@ namespace osu.Game.Overlays.Comments }; } + /// + /// Transforms some comment's components to show it as deleted. Invoked both from loading and deleting. + /// + private void makeDeleted() + { + deletedLabel.Show(); + content.FadeColour(OsuColour.Gray(0.5f)); + votePill.Hide(); + actionsContainer.Expire(); + } + + /// + /// Invokes comment deletion with confirmation. + /// + private void deleteComment() + { + if (dialogOverlay == null) + deleteCommentRequest(); + else + dialogOverlay.Push(new ConfirmDialog("Do you really want to delete your comment?", deleteCommentRequest)); + } + + /// + /// Invokes comment deletion directly. + /// + private void deleteCommentRequest() + { + actionsContainer.Hide(); + actionsLoading.Show(); + var request = new CommentDeleteRequest(Comment.Id); + request.Success += _ => Schedule(() => + { + actionsLoading.Hide(); + makeDeleted(); + WasDeleted = true; + if (!ShowDeleted.Value) + Hide(); + }); + request.Failure += e => Schedule(() => + { + Logger.Error(e, "Failed to delete comment"); + actionsLoading.Hide(); + actionsContainer.Show(); + }); + api.Queue(request); + } + + private void copyUrl() + { + host.GetClipboard()?.SetText($@"{api.APIEndpointUrl}/comments/{Comment.Id}"); + onScreenDisplay?.Display(new CopyUrlToast()); + } + + private void toggleReply() + { + if (replyEditorContainer.Count == 0) + { + replyEditorContainer.Show(); + replyEditorContainer.Add(new ReplyCommentEditor(Comment) + { + OnPost = comments => + { + Comment.RepliesCount += comments.Length; + showRepliesButton.Count = Comment.RepliesCount; + Replies.AddRange(comments); + }, + OnCancel = toggleReply + }); + } + else + { + replyEditorContainer.ForEach(e => e.Expire()); + replyEditorContainer.Hide(); + } + } + protected override void LoadComplete() { ShowDeleted.BindValueChanged(show => { - if (Comment.IsDeleted) + if (WasDeleted) this.FadeTo(show.NewValue ? 1 : 0); }, true); childrenExpanded.BindValueChanged(expanded => childCommentsVisibilityContainer.FadeTo(expanded.NewValue ? 1 : 0), true); @@ -327,8 +483,6 @@ namespace osu.Game.Overlays.Comments base.LoadComplete(); } - public bool ContainsReply(long replyId) => loadedReplies.ContainsKey(replyId); - private void onRepliesAdded(IEnumerable replies) { var page = createRepliesPage(replies); @@ -365,9 +519,11 @@ namespace osu.Game.Overlays.Comments int loadedRepliesCount = loadedReplies.Count; bool hasUnloadedReplies = loadedRepliesCount != Comment.RepliesCount; - loadRepliesButton.FadeTo(hasUnloadedReplies && loadedRepliesCount == 0 ? 1 : 0); - showMoreButton.FadeTo(hasUnloadedReplies && loadedRepliesCount > 0 ? 1 : 0); showRepliesButton.FadeTo(loadedRepliesCount != 0 ? 1 : 0); + loadRepliesButton.FadeTo(hasUnloadedReplies && loadedRepliesCount == 0 ? 1 : 0); + repliesButtonContainer.FadeTo(repliesButtonContainer.Any(child => child.Alpha > 0) ? 1 : 0); + + showMoreButton.FadeTo(hasUnloadedReplies && loadedRepliesCount > 0 ? 1 : 0); if (Comment.IsTopLevel) chevronButton.FadeTo(loadedRepliesCount != 0 ? 1 : 0); @@ -381,7 +537,7 @@ namespace osu.Game.Overlays.Comments { return new MarginPadding { - Horizontal = 70, + Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING, Vertical = 15 }; } @@ -392,7 +548,7 @@ namespace osu.Game.Overlays.Comments }; } - private class PinnedCommentNotice : FillFlowContainer + private partial class PinnedCommentNotice : FillFlowContainer { public PinnedCommentNotice() { @@ -419,11 +575,11 @@ namespace osu.Game.Overlays.Comments } } - private class ParentUsername : FillFlowContainer, IHasTooltip + private partial class ParentUsername : FillFlowContainer, IHasTooltip { public LocalisableString TooltipText => getParentMessage(); - private readonly Comment parentComment; + private readonly Comment? parentComment; public ParentUsername(Comment comment) { @@ -443,17 +599,17 @@ namespace osu.Game.Overlays.Comments new OsuSpriteText { Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true), - Text = parentComment?.User?.Username ?? parentComment?.LegacyName + Text = parentComment?.User?.Username ?? parentComment?.LegacyName! } }; } - private string getParentMessage() + private LocalisableString getParentMessage() { if (parentComment == null) return string.Empty; - return parentComment.HasMessage ? parentComment.Message : parentComment.IsDeleted ? "deleted" : string.Empty; + return parentComment.HasMessage ? parentComment.Message : parentComment.IsDeleted ? CommentsStrings.Deleted : string.Empty; } } } diff --git a/osu.Game/Overlays/Comments/HeaderButton.cs b/osu.Game/Overlays/Comments/HeaderButton.cs index 65172aa57c..de99cd6cc8 100644 --- a/osu.Game/Overlays/Comments/HeaderButton.cs +++ b/osu.Game/Overlays/Comments/HeaderButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics; @@ -9,7 +11,7 @@ using osu.Framework.Input.Events; namespace osu.Game.Overlays.Comments { - public class HeaderButton : Container + public partial class HeaderButton : Container { private const int transition_duration = 200; diff --git a/osu.Game/Overlays/Comments/ReplyCommentEditor.cs b/osu.Game/Overlays/Comments/ReplyCommentEditor.cs new file mode 100644 index 0000000000..8aca183dee --- /dev/null +++ b/osu.Game/Overlays/Comments/ReplyCommentEditor.cs @@ -0,0 +1,68 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Localisation; +using osu.Framework.Logging; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Overlays.Comments +{ + public partial class ReplyCommentEditor : CancellableCommentEditor + { + [Resolved] + private CommentsContainer commentsContainer { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private readonly Comment parentComment; + + public Action? OnPost; + + protected override LocalisableString FooterText => default; + protected override LocalisableString CommitButtonText => CommonStrings.ButtonsReply; + protected override LocalisableString TextBoxPlaceholder => CommentsStrings.PlaceholderReply; + + public ReplyCommentEditor(Comment parent) + { + parentComment = parent; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + GetContainingInputManager().ChangeFocus(TextBox); + } + + protected override void OnCommit(string text) + { + ShowLoadingSpinner = true; + CommentPostRequest req = new CommentPostRequest(commentsContainer.Type.Value, commentsContainer.Id.Value, text, parentComment.Id); + req.Failure += e => Schedule(() => + { + ShowLoadingSpinner = false; + Logger.Error(e, "Posting reply comment failed."); + }); + req.Success += cb => Schedule(processPostedComments, cb); + api.Queue(req); + } + + private void processPostedComments(CommentBundle cb) + { + foreach (var comment in cb.Comments) + comment.ParentComment = parentComment; + + var drawables = cb.Comments.Select(commentsContainer.GetDrawableComment).ToArray(); + OnPost?.Invoke(drawables); + + OnCancel!.Invoke(); + } + } +} diff --git a/osu.Game/Overlays/Comments/ReportCommentPopover.cs b/osu.Game/Overlays/Comments/ReportCommentPopover.cs new file mode 100644 index 0000000000..f3b2a2f97c --- /dev/null +++ b/osu.Game/Overlays/Comments/ReportCommentPopover.cs @@ -0,0 +1,111 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.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 Action? Action; + + private readonly Comment? comment; + + private OsuEnumDropdown reasonDropdown = null!; + private OsuTextBox commentsTextBox = null!; + private RoundedButton submitButton = null!; + + public ReportCommentPopover(Comment? comment) + { + 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 221a745189..2065f7a76b 100644 --- a/osu.Game/Overlays/Comments/TotalCommentsCounter.cs +++ b/osu.Game/Overlays/Comments/TotalCommentsCounter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Containers; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; @@ -13,7 +15,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Comments { - public class TotalCommentsCounter : CompositeDrawable + public partial class TotalCommentsCounter : CompositeDrawable { public readonly BindableInt Current = new BindableInt(); @@ -30,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 093cdce66e..dd418a9e58 100644 --- a/osu.Game/Overlays/Comments/VotePill.cs +++ b/osu.Game/Overlays/Comments/VotePill.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Containers; using osu.Framework.Graphics; using osu.Game.Graphics; @@ -22,7 +24,7 @@ using System.Linq; namespace osu.Game.Overlays.Comments { - public class VotePill : LoadingButton, IHasAccentColour + public partial class VotePill : LoadingButton, IHasAccentColour { private const int duration = 200; @@ -130,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 @@ -187,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 23f67a06cb..5047992c8b 100644 --- a/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs +++ b/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Collections.Specialized; using System.Diagnostics; @@ -28,7 +30,7 @@ using osuTK; namespace osu.Game.Overlays.Dashboard { - internal class CurrentlyPlayingDisplay : CompositeDrawable + internal partial class CurrentlyPlayingDisplay : CompositeDrawable { private const float search_textbox_height = 40; private const float padding = 10; @@ -58,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, @@ -151,7 +153,7 @@ namespace osu.Game.Overlays.Dashboard panel.Origin = Anchor.TopCentre; }); - public class PlayingUserPanel : CompositeDrawable, IFilterable + public partial class PlayingUserPanel : CompositeDrawable, IFilterable { public readonly APIUser User; @@ -201,7 +203,7 @@ namespace osu.Game.Overlays.Dashboard Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, }, - new PurpleTriangleButton + new PurpleRoundedButton { RelativeSizeAxes = Axes.X, Text = "Spectate", diff --git a/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs b/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs index 2c8db14950..5cbeb8f306 100644 --- a/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs +++ b/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; using osu.Framework.Localisation; using osu.Game.Localisation; @@ -8,11 +10,11 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Dashboard { - public class DashboardOverlayHeader : TabControlOverlayHeader + public partial class DashboardOverlayHeader : TabControlOverlayHeader { protected override OverlayTitle CreateTitle() => new DashboardTitle(); - private class DashboardTitle : OverlayTitle + private partial class DashboardTitle : OverlayTitle { public DashboardTitle() { diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs index 765f037d70..e3accfd2ad 100644 --- a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs +++ b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using System.Threading; @@ -17,7 +19,7 @@ using osuTK; namespace osu.Game.Overlays.Dashboard.Friends { - public class FriendDisplay : CompositeDrawable + public partial class FriendDisplay : CompositeDrawable { private List users = new List(); @@ -77,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(), } @@ -127,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) } @@ -143,7 +145,7 @@ namespace osu.Game.Overlays.Dashboard.Friends controlBackground.Colour = colourProvider.Background5; apiFriends.BindTo(api.Friends); - apiFriends.BindCollectionChanged((_, __) => Schedule(() => Users = apiFriends.ToList()), true); + apiFriends.BindCollectionChanged((_, _) => Schedule(() => Users = apiFriends.ToList()), true); } protected override void LoadComplete() diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendOnlineStreamControl.cs b/osu.Game/Overlays/Dashboard/Friends/FriendOnlineStreamControl.cs index 7275cdff7c..9f429c23d8 100644 --- a/osu.Game/Overlays/Dashboard/Friends/FriendOnlineStreamControl.cs +++ b/osu.Game/Overlays/Dashboard/Friends/FriendOnlineStreamControl.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Overlays.Dashboard.Friends { - public class FriendOnlineStreamControl : OverlayStreamControl + public partial class FriendOnlineStreamControl : OverlayStreamControl { protected override OverlayStreamItem CreateStreamItem(FriendStream value) => new FriendsOnlineStatusItem(value); diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendStream.cs b/osu.Game/Overlays/Dashboard/Friends/FriendStream.cs index 4abece9a8d..3bb42ec953 100644 --- a/osu.Game/Overlays/Dashboard/Friends/FriendStream.cs +++ b/osu.Game/Overlays/Dashboard/Friends/FriendStream.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Overlays.Dashboard.Friends { public class FriendStream diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendsOnlineStatusItem.cs b/osu.Game/Overlays/Dashboard/Friends/FriendsOnlineStatusItem.cs index 11dcb93e6f..785eef38ad 100644 --- a/osu.Game/Overlays/Dashboard/Friends/FriendsOnlineStatusItem.cs +++ b/osu.Game/Overlays/Dashboard/Friends/FriendsOnlineStatusItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Extensions; using osu.Framework.Localisation; @@ -9,7 +11,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Dashboard.Friends { - public class FriendsOnlineStatusItem : OverlayStreamItem + public partial class FriendsOnlineStatusItem : OverlayStreamItem { public FriendsOnlineStatusItem(FriendStream value) : base(value) diff --git a/osu.Game/Overlays/Dashboard/Friends/OnlineStatus.cs b/osu.Game/Overlays/Dashboard/Friends/OnlineStatus.cs index 853c94d8ae..21bc5b8203 100644 --- a/osu.Game/Overlays/Dashboard/Friends/OnlineStatus.cs +++ b/osu.Game/Overlays/Dashboard/Friends/OnlineStatus.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; diff --git a/osu.Game/Overlays/Dashboard/Friends/UserListToolbar.cs b/osu.Game/Overlays/Dashboard/Friends/UserListToolbar.cs index fb4b938183..db8510325c 100644 --- a/osu.Game/Overlays/Dashboard/Friends/UserListToolbar.cs +++ b/osu.Game/Overlays/Dashboard/Friends/UserListToolbar.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osuTK; @@ -8,7 +10,7 @@ using osu.Framework.Bindables; namespace osu.Game.Overlays.Dashboard.Friends { - public class UserListToolbar : CompositeDrawable + public partial class UserListToolbar : CompositeDrawable { public Bindable SortCriteria => sortControl.Current; diff --git a/osu.Game/Overlays/Dashboard/Friends/UserSortTabControl.cs b/osu.Game/Overlays/Dashboard/Friends/UserSortTabControl.cs index 7fee5f4668..886ed08af2 100644 --- a/osu.Game/Overlays/Dashboard/Friends/UserSortTabControl.cs +++ b/osu.Game/Overlays/Dashboard/Friends/UserSortTabControl.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Dashboard.Friends { - public class UserSortTabControl : OverlaySortTabControl + public partial class UserSortTabControl : OverlaySortTabControl { } diff --git a/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapListing.cs b/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapListing.cs index c781aa0cfb..0282ba8785 100644 --- a/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapListing.cs +++ b/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapListing.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osuTK; namespace osu.Game.Overlays.Dashboard.Home { - public class DashboardBeatmapListing : CompositeDrawable + public partial class DashboardBeatmapListing : CompositeDrawable { private readonly List newBeatmaps; private readonly List popularBeatmaps; diff --git a/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapPanel.cs b/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapPanel.cs index 9276e6ce80..792d6cc785 100644 --- a/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapPanel.cs +++ b/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -16,7 +18,7 @@ using osuTK; namespace osu.Game.Overlays.Dashboard.Home { - public abstract class DashboardBeatmapPanel : OsuClickableContainer + public abstract partial class DashboardBeatmapPanel : OsuClickableContainer { [Resolved] protected OverlayColourProvider ColourProvider { get; private set; } diff --git a/osu.Game/Overlays/Dashboard/Home/DashboardNewBeatmapPanel.cs b/osu.Game/Overlays/Dashboard/Home/DashboardNewBeatmapPanel.cs index 249b355be3..fef33bdf5a 100644 --- a/osu.Game/Overlays/Dashboard/Home/DashboardNewBeatmapPanel.cs +++ b/osu.Game/Overlays/Dashboard/Home/DashboardNewBeatmapPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics; using osu.Game.Graphics; @@ -8,7 +10,7 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Overlays.Dashboard.Home { - public class DashboardNewBeatmapPanel : DashboardBeatmapPanel + public partial class DashboardNewBeatmapPanel : DashboardBeatmapPanel { public DashboardNewBeatmapPanel(APIBeatmapSet beatmapSet) : base(beatmapSet) diff --git a/osu.Game/Overlays/Dashboard/Home/DashboardPopularBeatmapPanel.cs b/osu.Game/Overlays/Dashboard/Home/DashboardPopularBeatmapPanel.cs index 4e50cce890..54d95c994b 100644 --- a/osu.Game/Overlays/Dashboard/Home/DashboardPopularBeatmapPanel.cs +++ b/osu.Game/Overlays/Dashboard/Home/DashboardPopularBeatmapPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -11,7 +13,7 @@ using osuTK; namespace osu.Game.Overlays.Dashboard.Home { - public class DashboardPopularBeatmapPanel : DashboardBeatmapPanel + public partial class DashboardPopularBeatmapPanel : DashboardBeatmapPanel { public DashboardPopularBeatmapPanel(APIBeatmapSet beatmapSet) : base(beatmapSet) diff --git a/osu.Game/Overlays/Dashboard/Home/DrawableBeatmapList.cs b/osu.Game/Overlays/Dashboard/Home/DrawableBeatmapList.cs index 382bc00b1d..af36f71dd2 100644 --- a/osu.Game/Overlays/Dashboard/Home/DrawableBeatmapList.cs +++ b/osu.Game/Overlays/Dashboard/Home/DrawableBeatmapList.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -14,7 +16,7 @@ using osuTK; namespace osu.Game.Overlays.Dashboard.Home { - public abstract class DrawableBeatmapList : CompositeDrawable + public abstract partial class DrawableBeatmapList : CompositeDrawable { private readonly List beatmapSets; diff --git a/osu.Game/Overlays/Dashboard/Home/DrawableNewBeatmapList.cs b/osu.Game/Overlays/Dashboard/Home/DrawableNewBeatmapList.cs index 331fff0aea..8a60d8568c 100644 --- a/osu.Game/Overlays/Dashboard/Home/DrawableNewBeatmapList.cs +++ b/osu.Game/Overlays/Dashboard/Home/DrawableNewBeatmapList.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Localisation; using osu.Game.Online.API.Requests.Responses; @@ -8,7 +10,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Dashboard.Home { - public class DrawableNewBeatmapList : DrawableBeatmapList + public partial class DrawableNewBeatmapList : DrawableBeatmapList { public DrawableNewBeatmapList(List beatmapSets) : base(beatmapSets) diff --git a/osu.Game/Overlays/Dashboard/Home/DrawablePopularBeatmapList.cs b/osu.Game/Overlays/Dashboard/Home/DrawablePopularBeatmapList.cs index 154813dea1..aab99d0ed3 100644 --- a/osu.Game/Overlays/Dashboard/Home/DrawablePopularBeatmapList.cs +++ b/osu.Game/Overlays/Dashboard/Home/DrawablePopularBeatmapList.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Localisation; using osu.Game.Online.API.Requests.Responses; @@ -8,7 +10,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Dashboard.Home { - public class DrawablePopularBeatmapList : DrawableBeatmapList + public partial class DrawablePopularBeatmapList : DrawableBeatmapList { public DrawablePopularBeatmapList(List beatmapSets) : base(beatmapSets) diff --git a/osu.Game/Overlays/Dashboard/Home/HomePanel.cs b/osu.Game/Overlays/Dashboard/Home/HomePanel.cs index ce053cd4ec..8023c093aa 100644 --- a/osu.Game/Overlays/Dashboard/Home/HomePanel.cs +++ b/osu.Game/Overlays/Dashboard/Home/HomePanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Dashboard.Home { - public class HomePanel : Container + public partial class HomePanel : Container { protected override Container Content => content; diff --git a/osu.Game/Overlays/Dashboard/Home/News/FeaturedNewsItemPanel.cs b/osu.Game/Overlays/Dashboard/Home/News/FeaturedNewsItemPanel.cs index 0d166eb858..dabe65964a 100644 --- a/osu.Game/Overlays/Dashboard/Home/News/FeaturedNewsItemPanel.cs +++ b/osu.Game/Overlays/Dashboard/Home/News/FeaturedNewsItemPanel.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -18,7 +21,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Dashboard.Home.News { - public class FeaturedNewsItemPanel : HomePanel + public partial class FeaturedNewsItemPanel : HomePanel { private readonly APINewsPost post; @@ -102,7 +105,7 @@ namespace osu.Game.Overlays.Dashboard.Home.News }; } - private class ClickableNewsBackground : OsuHoverContainer + private partial class ClickableNewsBackground : OsuHoverContainer { private readonly APINewsPost post; @@ -117,22 +120,17 @@ namespace osu.Game.Overlays.Dashboard.Home.News [BackgroundDependencyLoader] private void load(GameHost host) { - NewsPostBackground bg; - - Child = new DelayedLoadWrapper(bg = new NewsPostBackground(post.FirstImage) + Child = new DelayedLoadUnloadWrapper(() => new NewsPostBackground(post.FirstImage) { RelativeSizeAxes = Axes.Both, FillMode = FillMode.Fill, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Alpha = 0 }) { RelativeSizeAxes = Axes.Both }; - bg.OnLoadComplete += d => d.FadeIn(250, Easing.In); - TooltipText = "view in browser"; Action = () => host.OpenUrlExternally("https://osu.ppy.sh/home/news/" + post.Slug); @@ -140,7 +138,7 @@ namespace osu.Game.Overlays.Dashboard.Home.News } } - private class Date : CompositeDrawable, IHasCustomTooltip + private partial class Date : CompositeDrawable, IHasCustomTooltip { private readonly DateTimeOffset date; @@ -170,7 +168,7 @@ namespace osu.Game.Overlays.Dashboard.Home.News Origin = Anchor.TopRight, Font = OsuFont.GetFont(weight: FontWeight.Bold), // using Bold since there is no 800 weight alternative Colour = colourProvider.Light1, - Text = $"{date:dd}" + Text = date.ToLocalisableString(@"dd") }, new TextFlowContainer(f => { @@ -181,7 +179,7 @@ namespace osu.Game.Overlays.Dashboard.Home.News Anchor = Anchor.TopRight, Origin = Anchor.TopRight, AutoSizeAxes = Axes.Both, - Text = $"{date:MMM yyyy}" + Text = date.ToLocalisableString(@"MMM yyyy") } } }; diff --git a/osu.Game/Overlays/Dashboard/Home/News/NewsGroupItem.cs b/osu.Game/Overlays/Dashboard/Home/News/NewsGroupItem.cs index 77cfbc90b0..9b27d1a193 100644 --- a/osu.Game/Overlays/Dashboard/Home/News/NewsGroupItem.cs +++ b/osu.Game/Overlays/Dashboard/Home/News/NewsGroupItem.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -12,7 +15,7 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Overlays.Dashboard.Home.News { - public class NewsGroupItem : CompositeDrawable + public partial class NewsGroupItem : CompositeDrawable { private readonly APINewsPost post; @@ -67,7 +70,7 @@ namespace osu.Game.Overlays.Dashboard.Home.News }; } - private class Date : CompositeDrawable, IHasCustomTooltip + private partial class Date : CompositeDrawable, IHasCustomTooltip { private readonly DateTimeOffset date; @@ -96,12 +99,12 @@ namespace osu.Game.Overlays.Dashboard.Home.News Margin = new MarginPadding { Vertical = 5 } }; - textFlow.AddText($"{date:dd}", t => + textFlow.AddText(date.ToLocalisableString(@"dd"), t => { t.Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold); }); - textFlow.AddText($"{date: MMM}", t => + textFlow.AddText(date.ToLocalisableString(@" MMM"), t => { t.Font = OsuFont.GetFont(size: 14, weight: FontWeight.Regular); }); diff --git a/osu.Game/Overlays/Dashboard/Home/News/NewsItemGroupPanel.cs b/osu.Game/Overlays/Dashboard/Home/News/NewsItemGroupPanel.cs index c1d5a87ef5..fa59f38690 100644 --- a/osu.Game/Overlays/Dashboard/Home/News/NewsItemGroupPanel.cs +++ b/osu.Game/Overlays/Dashboard/Home/News/NewsItemGroupPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -10,7 +12,7 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Overlays.Dashboard.Home.News { - public class NewsItemGroupPanel : HomePanel + public partial class NewsItemGroupPanel : HomePanel { private readonly List posts; diff --git a/osu.Game/Overlays/Dashboard/Home/News/NewsTitleLink.cs b/osu.Game/Overlays/Dashboard/Home/News/NewsTitleLink.cs index d6a3a69fe0..1960e0372e 100644 --- a/osu.Game/Overlays/Dashboard/Home/News/NewsTitleLink.cs +++ b/osu.Game/Overlays/Dashboard/Home/News/NewsTitleLink.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -11,7 +13,7 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Overlays.Dashboard.Home.News { - public class NewsTitleLink : OsuHoverContainer + public partial class NewsTitleLink : OsuHoverContainer { private readonly APINewsPost post; diff --git a/osu.Game/Overlays/Dashboard/Home/News/ShowMoreNewsPanel.cs b/osu.Game/Overlays/Dashboard/Home/News/ShowMoreNewsPanel.cs index f6e966957e..3e61dd1938 100644 --- a/osu.Game/Overlays/Dashboard/Home/News/ShowMoreNewsPanel.cs +++ b/osu.Game/Overlays/Dashboard/Home/News/ShowMoreNewsPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Dashboard.Home.News { - public class ShowMoreNewsPanel : OsuHoverContainer + public partial class ShowMoreNewsPanel : OsuHoverContainer { protected override IEnumerable EffectTargets => new[] { text }; diff --git a/osu.Game/Overlays/DashboardOverlay.cs b/osu.Game/Overlays/DashboardOverlay.cs index 79d972bdcc..527ac1689b 100644 --- a/osu.Game/Overlays/DashboardOverlay.cs +++ b/osu.Game/Overlays/DashboardOverlay.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Game.Overlays.Dashboard; using osu.Game.Overlays.Dashboard.Friends; namespace osu.Game.Overlays { - public class DashboardOverlay : TabbableOnlineOverlay + public partial class DashboardOverlay : TabbableOnlineOverlay { public DashboardOverlay() : base(OverlayColourScheme.Purple) diff --git a/osu.Game/Overlays/Dialog/ConfirmDialog.cs b/osu.Game/Overlays/Dialog/ConfirmDialog.cs index 58ce84e13a..f1caac8b5d 100644 --- a/osu.Game/Overlays/Dialog/ConfirmDialog.cs +++ b/osu.Game/Overlays/Dialog/ConfirmDialog.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Dialog @@ -10,7 +13,7 @@ namespace osu.Game.Overlays.Dialog /// /// A dialog which confirms a user action. /// - public class ConfirmDialog : PopupDialog + public partial class ConfirmDialog : PopupDialog { /// /// Construct a new confirmation dialog. @@ -18,7 +21,7 @@ namespace osu.Game.Overlays.Dialog /// The description of the action to be displayed to the user. /// An action to perform on confirmation. /// An optional action to perform on cancel. - public ConfirmDialog(string message, Action onConfirm, Action onCancel = null) + public ConfirmDialog(LocalisableString message, Action onConfirm, Action onCancel = null) { HeaderText = message; BodyText = "Last chance to turn back"; diff --git a/osu.Game/Overlays/Dialog/DangerousActionDialog.cs b/osu.Game/Overlays/Dialog/DangerousActionDialog.cs new file mode 100644 index 0000000000..c86570386f --- /dev/null +++ b/osu.Game/Overlays/Dialog/DangerousActionDialog.cs @@ -0,0 +1,46 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics.Sprites; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Dialog +{ + /// + /// A dialog which provides confirmation for actions which result in permanent consequences. + /// Differs from in that the confirmation button is a "dangerous" one + /// (requires the confirm button to be held). + /// + /// + /// The default implementation comes with text for a generic deletion operation. + /// This can be further customised by specifying custom . + /// + public abstract partial class DangerousActionDialog : PopupDialog + { + /// + /// The action which performs the deletion. + /// + protected Action? DangerousAction { get; set; } + + protected DangerousActionDialog() + { + HeaderText = DeleteConfirmationDialogStrings.HeaderText; + + Icon = FontAwesome.Regular.TrashAlt; + + Buttons = new PopupDialogButton[] + { + new PopupDialogDangerousButton + { + Text = DeleteConfirmationDialogStrings.Confirm, + Action = () => DangerousAction?.Invoke() + }, + new PopupDialogCancelButton + { + Text = DeleteConfirmationDialogStrings.Cancel + } + }; + } + } +} diff --git a/osu.Game/Overlays/Dialog/PopupDialog.cs b/osu.Game/Overlays/Dialog/PopupDialog.cs index 5959fe656c..f5a7e9e43d 100644 --- a/osu.Game/Overlays/Dialog/PopupDialog.cs +++ b/osu.Game/Overlays/Dialog/PopupDialog.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Extensions.Color4Extensions; @@ -19,7 +21,7 @@ using osuTK.Input; namespace osu.Game.Overlays.Dialog { - public abstract class PopupDialog : VisibilityContainer + public abstract partial class PopupDialog : VisibilityContainer { public const float ENTER_DURATION = 500; public const float EXIT_DURATION = 200; @@ -196,6 +198,7 @@ namespace osu.Game.Overlays.Dialog TextAnchor = Anchor.TopCentre, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(5), }, }, }, diff --git a/osu.Game/Overlays/Dialog/PopupDialogButton.cs b/osu.Game/Overlays/Dialog/PopupDialogButton.cs index 76ee438d6d..91a19add21 100644 --- a/osu.Game/Overlays/Dialog/PopupDialogButton.cs +++ b/osu.Game/Overlays/Dialog/PopupDialogButton.cs @@ -1,14 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Extensions.Color4Extensions; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Dialog { - public class PopupDialogButton : DialogButton + public partial class PopupDialogButton : DialogButton { - public PopupDialogButton() + public PopupDialogButton(HoverSampleSet sampleSet = HoverSampleSet.Button) + : base(sampleSet) { Height = 50; BackgroundColour = Color4Extensions.FromHex(@"150e14"); diff --git a/osu.Game/Overlays/Dialog/PopupDialogCancelButton.cs b/osu.Game/Overlays/Dialog/PopupDialogCancelButton.cs index 754003c1c1..f4289c66f1 100644 --- a/osu.Game/Overlays/Dialog/PopupDialogCancelButton.cs +++ b/osu.Game/Overlays/Dialog/PopupDialogCancelButton.cs @@ -1,17 +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 osu.Framework.Allocation; using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Dialog { - public class PopupDialogCancelButton : PopupDialogButton + public partial class PopupDialogCancelButton : PopupDialogButton { [BackgroundDependencyLoader] private void load(OsuColour colours) { ButtonColour = colours.Blue; } + + public PopupDialogCancelButton() + : base(HoverSampleSet.DialogCancel) + { + } } } diff --git a/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs b/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs index 6239c5e409..19d7ea7a87 100644 --- a/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs +++ b/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs @@ -1,16 +1,22 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Game.Audio.Effects; using osu.Game.Graphics; using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.Dialog { - public class PopupDialogDangerousButton : PopupDialogButton + public partial class PopupDialogDangerousButton : PopupDialogButton { private Box progressBox; private DangerousConfirmContainer confirmContainer; @@ -40,23 +46,96 @@ namespace osu.Game.Overlays.Dialog confirmContainer.Progress.BindValueChanged(progress => progressBox.Width = (float)progress.NewValue, true); } - private class DangerousConfirmContainer : HoldToConfirmContainer + private partial class DangerousConfirmContainer : HoldToConfirmContainer { public DangerousConfirmContainer() : base(isDangerousAction: true) { } + private Sample tickSample; + private Sample confirmSample; + private double lastTickPlaybackTime; + private AudioFilter lowPassFilter = null!; + private bool mouseDown; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + tickSample = audio.Samples.Get(@"UI/dialog-dangerous-tick"); + confirmSample = audio.Samples.Get(@"UI/dialog-dangerous-select"); + + AddInternal(lowPassFilter = new AudioFilter(audio.SampleMixer)); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Progress.BindValueChanged(progressChanged); + } + + protected override void AbortConfirm() + { + lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF); + base.AbortConfirm(); + } + + protected override void Confirm() + { + lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF); + confirmSample?.Play(); + base.Confirm(); + } + protected override bool OnMouseDown(MouseDownEvent e) { BeginConfirm(); + mouseDown = true; return true; } protected override void OnMouseUp(MouseUpEvent e) { if (!e.HasAnyButtonPressed) + { AbortConfirm(); + mouseDown = false; + } + } + + protected override bool OnHover(HoverEvent e) + { + if (mouseDown) + BeginConfirm(); + + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + + if (!mouseDown) return; + + AbortConfirm(); + } + + private void progressChanged(ValueChangedEvent progress) + { + if (progress.NewValue < progress.OldValue) return; + + if (Clock.CurrentTime - lastTickPlaybackTime < 30) return; + + lowPassFilter.CutoffTo((int)(progress.NewValue * AudioFilter.MAX_LOWPASS_CUTOFF * 0.5)); + + var channel = tickSample.GetChannel(); + + channel.Frequency.Value = 1 + progress.NewValue * 0.5f; + channel.Volume.Value = 0.5f + progress.NewValue / 2f; + + channel.Play(); + + lastTickPlaybackTime = Clock.CurrentTime; } } } diff --git a/osu.Game/Overlays/Dialog/PopupDialogOkButton.cs b/osu.Game/Overlays/Dialog/PopupDialogOkButton.cs index 6c2bf6f1e6..eb4a0f0709 100644 --- a/osu.Game/Overlays/Dialog/PopupDialogOkButton.cs +++ b/osu.Game/Overlays/Dialog/PopupDialogOkButton.cs @@ -1,17 +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 osu.Framework.Allocation; using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Dialog { - public class PopupDialogOkButton : PopupDialogButton + public partial class PopupDialogOkButton : PopupDialogButton { [BackgroundDependencyLoader] private void load(OsuColour colours) { ButtonColour = colours.Pink; } + + public PopupDialogOkButton() + : base(HoverSampleSet.DialogOk) + { + } } } diff --git a/osu.Game/Overlays/DialogOverlay.cs b/osu.Game/Overlays/DialogOverlay.cs index 5a69562e82..098a5d0a33 100644 --- a/osu.Game/Overlays/DialogOverlay.cs +++ b/osu.Game/Overlays/DialogOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Overlays.Dialog; @@ -14,7 +16,7 @@ using osu.Game.Audio.Effects; namespace osu.Game.Overlays { - public class DialogOverlay : OsuFocusedOverlayContainer, IDialogOverlay + public partial class DialogOverlay : OsuFocusedOverlayContainer, IDialogOverlay { private readonly Container dialogContainer; @@ -47,43 +49,54 @@ namespace osu.Game.Overlays public void Push(PopupDialog dialog) { - if (dialog == CurrentDialog || dialog.State.Value != Visibility.Visible) return; - - var lastDialog = CurrentDialog; + if (dialog == CurrentDialog || dialog.State.Value == Visibility.Hidden) return; // Immediately update the externally accessible property as this may be used for checks even before // a DialogOverlay instance has finished loading. + var lastDialog = CurrentDialog; CurrentDialog = dialog; - Scheduler.Add(() => + Schedule(() => { // if any existing dialog is being displayed, dismiss it before showing a new one. lastDialog?.Hide(); - dialog.State.ValueChanged += state => onDialogOnStateChanged(dialog, state.NewValue); - dialogContainer.Add(dialog); + // if the new dialog is hidden before added to the dialogContainer, bypass any further operations. + if (dialog.State.Value == Visibility.Hidden) + { + dismiss(); + return; + } + + dialogContainer.Add(dialog); Show(); - }, false); + + dialog.State.BindValueChanged(state => + { + if (state.NewValue != Visibility.Hidden) return; + + // Trigger the demise of the dialog as soon as it hides. + dialog.Delay(PopupDialog.EXIT_DURATION).Expire(); + + dismiss(); + }); + }); + + void dismiss() + { + if (dialog != CurrentDialog) return; + + // Handle the case where the dialog is the currently displayed dialog. + // In this scenario, the overlay itself should also be hidden. + Hide(); + CurrentDialog = null; + } } public override bool IsPresent => Scheduler.HasPendingTasks || dialogContainer.Children.Count > 0; protected override bool BlockNonPositionalInput => true; - private void onDialogOnStateChanged(VisibilityContainer dialog, Visibility v) - { - if (v != Visibility.Hidden) return; - - // handle the dialog being dismissed. - dialog.Delay(PopupDialog.EXIT_DURATION).Expire(); - - if (dialog == CurrentDialog) - { - Hide(); - CurrentDialog = null; - } - } - protected override void PopIn() { base.PopIn(); @@ -95,7 +108,8 @@ namespace osu.Game.Overlays base.PopOut(); lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 100, Easing.InCubic); - if (CurrentDialog?.State.Value == Visibility.Visible) + // PopOut gets called initially, but we only want to hide dialog when we have been loaded and are present. + if (IsLoaded && CurrentDialog?.State.Value == Visibility.Visible) CurrentDialog.Hide(); } @@ -107,7 +121,11 @@ namespace osu.Game.Overlays switch (e.Action) { case GlobalAction.Select: - CurrentDialog?.Buttons.OfType().FirstOrDefault()?.TriggerClick(); + var clickableButton = + CurrentDialog?.Buttons.OfType().FirstOrDefault() ?? + CurrentDialog?.Buttons.First(); + + clickableButton?.TriggerClick(); return true; } diff --git a/osu.Game/Overlays/FirstRunSetup/FirstRunSetupScreen.cs b/osu.Game/Overlays/FirstRunSetup/FirstRunSetupScreen.cs index b043f05bd8..76921718f2 100644 --- a/osu.Game/Overlays/FirstRunSetup/FirstRunSetupScreen.cs +++ b/osu.Game/Overlays/FirstRunSetup/FirstRunSetupScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Graphics; @@ -13,7 +15,7 @@ using osuTK; namespace osu.Game.Overlays.FirstRunSetup { - public abstract class FirstRunSetupScreen : Screen + public abstract partial class FirstRunSetupScreen : Screen { private const float offset = 100; diff --git a/osu.Game/Overlays/FirstRunSetup/ProgressRoundedButton.cs b/osu.Game/Overlays/FirstRunSetup/ProgressRoundedButton.cs index ee2db1f3d4..a1e61e66f8 100644 --- a/osu.Game/Overlays/FirstRunSetup/ProgressRoundedButton.cs +++ b/osu.Game/Overlays/FirstRunSetup/ProgressRoundedButton.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. -#nullable enable using System; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -14,7 +13,7 @@ using osuTK; namespace osu.Game.Overlays.FirstRunSetup { - public class ProgressRoundedButton : RoundedButton + public partial class ProgressRoundedButton : RoundedButton { public new Action? Action; @@ -82,7 +81,7 @@ namespace osu.Game.Overlays.FirstRunSetup loading.Hide(); tick.FadeIn(500, Easing.OutQuint); - Background.FadeColour(colours.Green, 500, Easing.OutQuint); + this.TransformTo(nameof(BackgroundColour), colours.Green, 500, Easing.OutQuint); progressBar.FillColour = colours.Green; this.TransformBindableTo(progressBar.Current, 1, 500, Easing.OutQuint); diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs b/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs index ddcee7c040..75bc8fd3a8 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.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 enable - using System; using System.Linq; using osu.Framework.Allocation; @@ -22,7 +20,7 @@ using Realms; namespace osu.Game.Overlays.FirstRunSetup { [LocalisableDescription(typeof(FirstRunSetupBeatmapScreenStrings), nameof(FirstRunSetupBeatmapScreenStrings.Header))] - public class ScreenBeatmaps : FirstRunSetupScreen + public partial class ScreenBeatmaps : FirstRunSetupScreen { private ProgressRoundedButton downloadBundledButton = null!; private ProgressRoundedButton downloadTutorialButton = null!; @@ -125,7 +123,7 @@ namespace osu.Game.Overlays.FirstRunSetup beatmapSubscription?.Dispose(); } - private void beatmapsChanged(IRealmCollection sender, ChangeSet? changes, Exception error) + private void beatmapsChanged(IRealmCollection sender, ChangeSet? changes, Exception error) => Schedule(() => { currentlyLoadedBeatmaps.Text = FirstRunSetupBeatmapScreenStrings.CurrentlyLoadedBeatmaps(sender.Count); @@ -141,7 +139,7 @@ namespace osu.Game.Overlays.FirstRunSetup currentlyLoadedBeatmaps.ScaleTo(1.1f) .ScaleTo(1, 1500, Easing.OutQuint); } - } + }); private void downloadTutorial() { diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenBehaviour.cs b/osu.Game/Overlays/FirstRunSetup/ScreenBehaviour.cs index 1a88e6a842..95af8ec0f3 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenBehaviour.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenBehaviour.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -17,7 +19,7 @@ using osu.Game.Overlays.Settings.Sections; namespace osu.Game.Overlays.FirstRunSetup { [LocalisableDescription(typeof(FirstRunSetupOverlayStrings), nameof(FirstRunSetupOverlayStrings.Behaviour))] - public class ScreenBehaviour : FirstRunSetupScreen + public partial class ScreenBehaviour : FirstRunSetupScreen { private SearchContainer searchContainer; diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs b/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs index 62b517d982..23f3b3e1af 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenImportFromStable.cs @@ -1,14 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; @@ -16,19 +15,23 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; +using osu.Framework.Logging; +using osu.Framework.Screens; using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Localisation; +using osu.Game.Online.Chat; using osu.Game.Overlays.Settings; +using osu.Game.Overlays.Settings.Sections.Maintenance; using osu.Game.Screens.Edit.Setup; using osuTK; namespace osu.Game.Overlays.FirstRunSetup { [LocalisableDescription(typeof(FirstRunOverlayImportFromStableScreenStrings), nameof(FirstRunOverlayImportFromStableScreenStrings.Header))] - public class ScreenImportFromStable : FirstRunSetupScreen + public partial class ScreenImportFromStable : FirstRunSetupScreen { private static readonly Vector2 button_size = new Vector2(400, 50); @@ -41,6 +44,8 @@ namespace osu.Game.Overlays.FirstRunSetup private StableLocatorLabelledTextBox stableLocatorTextBox = null!; + private LinkFlowContainer copyInformation = null!; + private IEnumerable contentCheckboxes => Content.Children.OfType(); [BackgroundDependencyLoader(permitNulls: true)] @@ -48,7 +53,7 @@ namespace osu.Game.Overlays.FirstRunSetup { Content.Children = new Drawable[] { - new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) + new LinkFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) { Colour = OverlayColourProvider.Content1, Text = FirstRunOverlayImportFromStableScreenStrings.Description, @@ -64,6 +69,12 @@ namespace osu.Game.Overlays.FirstRunSetup new ImportCheckbox(CommonStrings.Scores, StableContent.Scores), new ImportCheckbox(CommonStrings.Skins, StableContent.Skins), new ImportCheckbox(CommonStrings.Collections, StableContent.Collections), + copyInformation = new LinkFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) + { + Colour = OverlayColourProvider.Content1, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + }, importButton = new ProgressRoundedButton { Size = button_size, @@ -85,6 +96,9 @@ namespace osu.Game.Overlays.FirstRunSetup stableLocatorTextBox.Current.BindValueChanged(_ => updateStablePath(), true); } + [Resolved(canBeNull: true)] + private OsuGame? game { get; set; } + private void updateStablePath() { var storage = legacyImportManager.GetCurrentStableStorage(); @@ -107,6 +121,29 @@ namespace osu.Game.Overlays.FirstRunSetup toggleInteraction(true); stableLocatorTextBox.Current.Value = storage.GetFullPath(string.Empty); importButton.Enabled.Value = true; + + bool available = legacyImportManager.CheckSongsFolderHardLinkAvailability(); + Logger.Log($"Hard link support for beatmaps is {available}"); + + if (available) + { + copyInformation.Text = + "Data migration will use \"hard links\". No extra disk space will be used, and you can delete either data folder at any point without affecting the other installation. "; + + copyInformation.AddLink("Learn more about how \"hard links\" work", LinkAction.OpenWiki, @"Client/Release_stream/Lazer/File_storage#via-hard-links"); + } + else if (!RuntimeInfo.IsDesktop) + copyInformation.Text = "Lightweight linking of files is not supported on your operating system yet, so a copy of all files will be made during import."; + else + { + copyInformation.Text = RuntimeInfo.OS == RuntimeInfo.Platform.Windows + ? "A second copy of all files will be made during import. To avoid this, please make sure the lazer data folder is on the same drive as your previous osu! install (and the file system is NTFS). " + : "A second copy of all files will be made during import. To avoid this, please make sure the lazer data folder is on the same drive as your previous osu! install (and the file system supports hard links). "; + copyInformation.AddLink(GeneralSettingsStrings.ChangeFolderLocation, () => + { + game?.PerformFromScreen(menu => menu.Push(new MigrationSelectScreen())); + }); + } } private void runImport() @@ -141,7 +178,19 @@ namespace osu.Game.Overlays.FirstRunSetup c.Current.Disabled = !allow; } - private class ImportCheckbox : SettingsCheckbox + public override void OnSuspending(ScreenTransitionEvent e) + { + stableLocatorTextBox.HidePopover(); + base.OnSuspending(e); + } + + public override bool OnExiting(ScreenExitEvent e) + { + stableLocatorTextBox.HidePopover(); + return base.OnExiting(e); + } + + private partial class ImportCheckbox : SettingsCheckbox { public readonly StableContent StableContent; @@ -183,14 +232,14 @@ namespace osu.Game.Overlays.FirstRunSetup } } - internal class StableLocatorLabelledTextBox : LabelledTextBoxWithPopover, ICanAcceptFiles + internal partial class StableLocatorLabelledTextBox : LabelledTextBoxWithPopover, ICanAcceptFiles { [Resolved] private LegacyImportManager legacyImportManager { get; set; } = null!; public IEnumerable HandledExtensions { get; } = new[] { string.Empty }; - private readonly Bindable currentDirectory = new Bindable(); + private readonly Bindable currentDirectory = new Bindable(); [Resolved(canBeNull: true)] // Can't really be null but required to handle potential of disposal before DI completes. private OsuGameBase? game { get; set; } @@ -208,7 +257,7 @@ namespace osu.Game.Overlays.FirstRunSetup currentDirectory.Value = new DirectoryInfo(fullPath); } - private void onDirectorySelected(ValueChangedEvent directory) + private void onDirectorySelected(ValueChangedEvent directory) { if (directory.NewValue == null) { @@ -237,7 +286,7 @@ namespace osu.Game.Overlays.FirstRunSetup return Task.CompletedTask; } - Task ICanAcceptFiles.Import(params ImportTask[] tasks) => throw new NotImplementedException(); + Task ICanAcceptFiles.Import(ImportTask[] tasks, ImportParameters parameters) => throw new NotImplementedException(); protected override void Dispose(bool isDisposing) { @@ -247,9 +296,9 @@ namespace osu.Game.Overlays.FirstRunSetup public override Popover GetPopover() => new DirectoryChooserPopover(currentDirectory); - private class DirectoryChooserPopover : OsuPopover + private partial class DirectoryChooserPopover : OsuPopover { - public DirectoryChooserPopover(Bindable currentDirectory) + public DirectoryChooserPopover(Bindable currentDirectory) { Child = new Container { diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs b/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs index f09a26a527..e3cd2ae36c 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenUIScale.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -11,7 +13,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Textures; using osu.Framework.Localisation; -using osu.Framework.Screens; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics; @@ -30,7 +31,7 @@ using osuTK; namespace osu.Game.Overlays.FirstRunSetup { [LocalisableDescription(typeof(GraphicsSettingsStrings), nameof(GraphicsSettingsStrings.UIScaling))] - public class ScreenUIScale : FirstRunSetupScreen + public partial class ScreenUIScale : FirstRunSetupScreen { [BackgroundDependencyLoader] private void load(OsuConfigManager config) @@ -56,7 +57,7 @@ namespace osu.Game.Overlays.FirstRunSetup Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, RelativeSizeAxes = Axes.None, - Size = new Vector2(screen_width, screen_width / 16f * 9 / 2), + Size = new Vector2(screen_width, screen_width / 16f * 9), Children = new Drawable[] { new GridContainer @@ -66,7 +67,6 @@ namespace osu.Game.Overlays.FirstRunSetup { new Drawable[] { - new SampleScreenContainer(new PinnedMainMenu()), new SampleScreenContainer(new NestedSongSelect()), }, // TODO: add more screens here in the future (gameplay / results) @@ -78,7 +78,7 @@ namespace osu.Game.Overlays.FirstRunSetup }; } - private class InverseScalingDrawSizePreservingFillContainer : ScalingContainer.ScalingDrawSizePreservingFillContainer + private partial class InverseScalingDrawSizePreservingFillContainer : ScalingContainer.ScalingDrawSizePreservingFillContainer { private Vector2 initialSize; @@ -100,30 +100,19 @@ namespace osu.Game.Overlays.FirstRunSetup } } - private class NestedSongSelect : PlaySongSelect + private partial class NestedSongSelect : PlaySongSelect { protected override bool ControlGlobalMusic => false; public override bool? AllowTrackAdjustments => false; } - private class PinnedMainMenu : MainMenu - { - public override void OnEntering(ScreenTransitionEvent e) - { - base.OnEntering(e); - - Buttons.ReturnToTopOnIdle = false; - Buttons.State = ButtonSystemState.TopLevel; - } - } - - private class UIScaleSlider : OsuSliderBar + private partial class UIScaleSlider : RoundedSliderBar { public override LocalisableString TooltipText => base.TooltipText + "x"; } - private class SampleScreenContainer : CompositeDrawable + private partial class SampleScreenContainer : CompositeDrawable { private readonly OsuScreen screen; @@ -159,7 +148,6 @@ namespace osu.Game.Overlays.FirstRunSetup private void load(AudioManager audio, TextureStore textures, RulesetStore rulesets) { Beatmap.Value = new DummyWorkingBeatmap(audio, textures); - Beatmap.Value.LoadTrack(); Ruleset.Value = rulesets.AvailableRulesets.First(); @@ -232,7 +220,7 @@ namespace osu.Game.Overlays.FirstRunSetup return parentDependencies.Get(type, info); } - public void Inject(T instance) where T : class + public void Inject(T instance) where T : class, IDependencyInjectionCandidate { parentDependencies.Inject(instance); } diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs b/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs index 420d630857..b8d802ad4b 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs @@ -1,30 +1,214 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Configuration; +using osu.Framework.Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Framework.Threading; +using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; using osu.Game.Localisation; +using osu.Game.Overlays.Settings; +using osuTK; namespace osu.Game.Overlays.FirstRunSetup { [LocalisableDescription(typeof(FirstRunSetupOverlayStrings), nameof(FirstRunSetupOverlayStrings.WelcomeTitle))] - public class ScreenWelcome : FirstRunSetupScreen + public partial class ScreenWelcome : FirstRunSetupScreen { [BackgroundDependencyLoader] - private void load() + private void load(FrameworkConfigManager frameworkConfig) { Content.Children = new Drawable[] { - new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] + { + // Avoid height changes when changing language. + new Dimension(GridSizeMode.AutoSize, minSize: 100), + }, + Content = new[] + { + new Drawable[] + { + new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: CONTENT_FONT_SIZE)) + { + Text = FirstRunSetupOverlayStrings.WelcomeDescription, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + }, + }, + } + }, + new SettingsCheckbox + { + LabelText = GeneralSettingsStrings.PreferOriginalMetadataLanguage, + Current = frameworkConfig.GetBindable(FrameworkSetting.ShowUnicode) + }, + new LanguageSelectionFlow { - Text = FirstRunSetupOverlayStrings.WelcomeDescription, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y - }, + } }; } + + private partial class LanguageSelectionFlow : FillFlowContainer + { + private Bindable frameworkLocale = null!; + private IBindable localisationParameters = null!; + + private ScheduledDelegate? updateSelectedDelegate; + + [BackgroundDependencyLoader] + private void load(FrameworkConfigManager frameworkConfig, LocalisationManager localisation) + { + Direction = FillDirection.Full; + Spacing = new Vector2(5); + + ChildrenEnumerable = Enum.GetValues() + .Select(l => new LanguageButton(l) + { + Action = () => frameworkLocale.Value = l.ToCultureCode() + }); + + frameworkLocale = frameworkConfig.GetBindable(FrameworkSetting.Locale); + frameworkLocale.BindValueChanged(_ => onLanguageChange()); + + localisationParameters = localisation.CurrentParameters.GetBoundCopy(); + localisationParameters.BindValueChanged(_ => onLanguageChange(), true); + } + + private void onLanguageChange() + { + var language = LanguageExtensions.GetLanguageFor(frameworkLocale.Value, localisationParameters.Value); + + // Changing language may cause a short period of blocking the UI thread while the new glyphs are loaded. + // Scheduling ensures the button animation plays smoothly after any blocking operation completes. + // Note that a delay is required (the alternative would be a double-schedule; delay feels better). + updateSelectedDelegate?.Cancel(); + updateSelectedDelegate = Scheduler.AddDelayed(() => updateSelectedStates(language), 50); + } + + private void updateSelectedStates(Language language) + { + foreach (var c in Children.OfType()) + c.Selected = c.Language == language; + } + + private partial class LanguageButton : OsuClickableContainer + { + public readonly Language Language; + + private Box backgroundBox = null!; + + private OsuSpriteText text = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + private bool selected; + + public bool Selected + { + get => selected; + set + { + if (selected == value) + return; + + selected = value; + + if (IsLoaded) + updateState(); + } + } + + public LanguageButton(Language language) + { + Language = language; + + Size = new Vector2(160, 50); + Masking = true; + CornerRadius = 10; + } + + [BackgroundDependencyLoader] + private void load() + { + AddRange(new Drawable[] + { + backgroundBox = new Box + { + Alpha = 0, + Colour = colourProvider.Background5, + RelativeSizeAxes = Axes.Both, + }, + text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = colourProvider.Light1, + Text = Language.GetDescription(), + } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateState(); + } + + protected override bool OnHover(HoverEvent e) + { + if (!selected) + updateState(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + if (!selected) + updateState(); + base.OnHoverLost(e); + } + + private void updateState() + { + if (selected) + { + const double selected_duration = 1000; + + backgroundBox.FadeTo(1, selected_duration, Easing.OutQuint); + backgroundBox.FadeColour(colourProvider.Background2, selected_duration, Easing.OutQuint); + text.FadeColour(colourProvider.Content1, selected_duration, Easing.OutQuint); + text.ScaleTo(1.2f, selected_duration, Easing.OutQuint); + } + else + { + const double duration = 500; + + backgroundBox.FadeTo(IsHovered ? 1 : 0, duration, Easing.OutQuint); + backgroundBox.FadeColour(colourProvider.Background5, duration, Easing.OutQuint); + text.FadeColour(colourProvider.Light1, duration, Easing.OutQuint); + text.ScaleTo(1, duration, Easing.OutQuint); + } + } + } + } } } diff --git a/osu.Game/Overlays/FirstRunSetupOverlay.cs b/osu.Game/Overlays/FirstRunSetupOverlay.cs index a5bece0832..f2fdaefbb4 100644 --- a/osu.Game/Overlays/FirstRunSetupOverlay.cs +++ b/osu.Game/Overlays/FirstRunSetupOverlay.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 enable - using System; using System.Collections.Generic; using System.Diagnostics; @@ -33,7 +31,7 @@ using osu.Game.Screens.Menu; namespace osu.Game.Overlays { [Cached] - public class FirstRunSetupOverlay : ShearedOverlayContainer + public partial class FirstRunSetupOverlay : ShearedOverlayContainer { [Resolved] private IPerformFromScreenRunner performer { get; set; } = null!; @@ -303,7 +301,7 @@ namespace osu.Game.Overlays if (currentStepIndex < steps.Count) { - var nextScreen = (Screen)Activator.CreateInstance(steps[currentStepIndex.Value]); + var nextScreen = (Screen)Activator.CreateInstance(steps[currentStepIndex.Value])!; loadingShowDelegate = Scheduler.AddDelayed(() => loading.Show(), 200); nextScreen.OnLoadComplete += _ => diff --git a/osu.Game/Overlays/FullscreenOverlay.cs b/osu.Game/Overlays/FullscreenOverlay.cs index 58c41c4a4b..2cc8354e50 100644 --- a/osu.Game/Overlays/FullscreenOverlay.cs +++ b/osu.Game/Overlays/FullscreenOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -15,7 +17,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays { - public abstract class FullscreenOverlay : WaveOverlayContainer, INamedOverlayComponent + public abstract partial class FullscreenOverlay : WaveOverlayContainer, INamedOverlayComponent where T : OverlayHeader { public virtual string IconTexture => Header.Title.IconTexture ?? string.Empty; diff --git a/osu.Game/Overlays/HoldToConfirmOverlay.cs b/osu.Game/Overlays/HoldToConfirmOverlay.cs index cb6275bd7c..ac8b4ad0a8 100644 --- a/osu.Game/Overlays/HoldToConfirmOverlay.cs +++ b/osu.Game/Overlays/HoldToConfirmOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; @@ -15,7 +17,7 @@ namespace osu.Game.Overlays /// An overlay which will display a black screen that dims over a period before confirming an exit action. /// Action is BYO (derived class will need to call and from a user event). /// - public abstract class HoldToConfirmOverlay : HoldToConfirmContainer + public abstract partial class HoldToConfirmOverlay : HoldToConfirmContainer { private Box overlay; diff --git a/osu.Game/Overlays/IDialogOverlay.cs b/osu.Game/Overlays/IDialogOverlay.cs index 1c6a84cd64..6ddb71d5e3 100644 --- a/osu.Game/Overlays/IDialogOverlay.cs +++ b/osu.Game/Overlays/IDialogOverlay.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 enable - using osu.Framework.Allocation; using osu.Game.Overlays.Dialog; diff --git a/osu.Game/Overlays/INamedOverlayComponent.cs b/osu.Game/Overlays/INamedOverlayComponent.cs index ca0aea041e..e9d01a55e3 100644 --- a/osu.Game/Overlays/INamedOverlayComponent.cs +++ b/osu.Game/Overlays/INamedOverlayComponent.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Localisation; namespace osu.Game.Overlays diff --git a/osu.Game/Overlays/INotificationOverlay.cs b/osu.Game/Overlays/INotificationOverlay.cs index 1d8e33ea3a..b9ac466229 100644 --- a/osu.Game/Overlays/INotificationOverlay.cs +++ b/osu.Game/Overlays/INotificationOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Overlays.Notifications; diff --git a/osu.Game/Overlays/IOverlayManager.cs b/osu.Game/Overlays/IOverlayManager.cs index 940ee2d8db..0318b2b3a0 100644 --- a/osu.Game/Overlays/IOverlayManager.cs +++ b/osu.Game/Overlays/IOverlayManager.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; diff --git a/osu.Game/Overlays/Login/LoginForm.cs b/osu.Game/Overlays/Login/LoginForm.cs index 502f0cd22e..af145c418c 100644 --- a/osu.Game/Overlays/Login/LoginForm.cs +++ b/osu.Game/Overlays/Login/LoginForm.cs @@ -19,21 +19,21 @@ using osuTK; namespace osu.Game.Overlays.Login { - public class LoginForm : FillFlowContainer + public partial class LoginForm : FillFlowContainer { - private TextBox username; - private TextBox password; - private ShakeContainer shakeSignIn; + private TextBox username = null!; + private TextBox password = null!; + private ShakeContainer shakeSignIn = null!; - [Resolved(CanBeNull = true)] - private IAPIProvider api { get; set; } + [Resolved] + private IAPIProvider api { get; set; } = null!; - public Action RequestHide; + public Action? RequestHide; private void performLogin() { if (!string.IsNullOrEmpty(username.Text) && !string.IsNullOrEmpty(password.Text)) - api?.Login(username.Text, password.Text); + api.Login(username.Text, password.Text); else shakeSignIn.Shake(); } @@ -47,6 +47,7 @@ namespace osu.Game.Overlays.Login RelativeSizeAxes = Axes.X; ErrorTextFlowContainer errorText; + LinkFlowContainer forgottenPaswordLink; Children = new Drawable[] { @@ -54,7 +55,7 @@ namespace osu.Game.Overlays.Login { PlaceholderText = UsersStrings.LoginUsername.ToLower(), RelativeSizeAxes = Axes.X, - Text = api?.ProvidedUsername ?? string.Empty, + Text = api.ProvidedUsername, TabbableContentContainer = this }, password = new OsuPasswordTextBox @@ -78,6 +79,12 @@ namespace osu.Game.Overlays.Login LabelText = "Stay signed in", Current = config.GetBindable(OsuSetting.SavePassword), }, + forgottenPaswordLink = new LinkFlowContainer + { + Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, new Container { RelativeSizeAxes = Axes.X, @@ -101,15 +108,17 @@ namespace osu.Game.Overlays.Login Text = "Register", Action = () => { - RequestHide(); + RequestHide?.Invoke(); accountCreation.Show(); } } }; - password.OnCommit += (sender, newText) => performLogin(); + forgottenPaswordLink.AddLink(LayoutStrings.PopupLoginLoginForgot, $"{api.WebsiteRootUrl}/home/password-reset"); - if (api?.LastLoginError?.Message is string error) + password.OnCommit += (_, _) => performLogin(); + + if (api.LastLoginError?.Message is string error) errorText.AddErrors(new[] { error }); } diff --git a/osu.Game/Overlays/Login/LoginPanel.cs b/osu.Game/Overlays/Login/LoginPanel.cs index 481abd48ab..44f2f3273a 100644 --- a/osu.Game/Overlays/Login/LoginPanel.cs +++ b/osu.Game/Overlays/Login/LoginPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -11,6 +13,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Users; using osuTK; @@ -19,7 +22,7 @@ using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Overlays.Login { - public class LoginPanel : FillFlowContainer + public partial class LoginPanel : FillFlowContainer { private bool bounding = true; private LoginForm form; @@ -107,7 +110,7 @@ namespace osu.Game.Overlays.Login Origin = Anchor.TopCentre, TextAnchor = Anchor.TopCentre, AutoSizeAxes = Axes.Both, - Text = state.NewValue == APIState.Failing ? "Connection is failing, will attempt to reconnect... " : "Attempting to connect... ", + Text = state.NewValue == APIState.Failing ? ToolbarStrings.AttemptingToReconnect : ToolbarStrings.Connecting, Margin = new MarginPadding { Top = 10, Bottom = 10 }, }, }; diff --git a/osu.Game/Overlays/Login/UserAction.cs b/osu.Game/Overlays/Login/UserAction.cs index d216670a28..7a18e38109 100644 --- a/osu.Game/Overlays/Login/UserAction.cs +++ b/osu.Game/Overlays/Login/UserAction.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; @@ -21,4 +23,4 @@ namespace osu.Game.Overlays.Login [Description(@"Sign out")] SignOut, } -} \ No newline at end of file +} diff --git a/osu.Game/Overlays/Login/UserDropdown.cs b/osu.Game/Overlays/Login/UserDropdown.cs index 5c3a41aec9..0bdfa82517 100644 --- a/osu.Game/Overlays/Login/UserDropdown.cs +++ b/osu.Game/Overlays/Login/UserDropdown.cs @@ -1,20 +1,22 @@ // 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.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; +using osu.Game.Users.Drawables; using osuTK; using osuTK.Graphics; namespace osu.Game.Overlays.Login { - public class UserDropdown : OsuEnumDropdown + public partial class UserDropdown : OsuEnumDropdown { protected override DropdownHeader CreateHeader() => new UserDropdownHeader(); @@ -29,7 +31,7 @@ namespace osu.Game.Overlays.Login } } - protected class UserDropdownMenu : OsuDropdownMenu + protected partial class UserDropdownMenu : OsuDropdownMenu { public UserDropdownMenu() { @@ -56,7 +58,7 @@ namespace osu.Game.Overlays.Login protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new DrawableUserDropdownMenuItem(item); - private class DrawableUserDropdownMenuItem : DrawableOsuDropdownMenuItem + private partial class DrawableUserDropdownMenuItem : DrawableOsuDropdownMenuItem { public DrawableUserDropdownMenuItem(MenuItem item) : base(item) @@ -72,11 +74,11 @@ namespace osu.Game.Overlays.Login } } - private class UserDropdownHeader : OsuDropdownHeader + private partial class UserDropdownHeader : OsuDropdownHeader { public const float LABEL_LEFT_MARGIN = 20; - private readonly SpriteIcon statusIcon; + private readonly StatusIcon statusIcon; public Color4 StatusColour { @@ -99,11 +101,10 @@ namespace osu.Game.Overlays.Login Icon.Size = new Vector2(14); Icon.Margin = new MarginPadding(0); - Foreground.Add(statusIcon = new SpriteIcon + Foreground.Add(statusIcon = new StatusIcon { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Icon = FontAwesome.Regular.Circle, Size = new Vector2(14), }); diff --git a/osu.Game/Overlays/LoginOverlay.cs b/osu.Game/Overlays/LoginOverlay.cs index 9b2d7ca1ee..536811dfcf 100644 --- a/osu.Game/Overlays/LoginOverlay.cs +++ b/osu.Game/Overlays/LoginOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -13,7 +15,7 @@ using osu.Game.Overlays.Login; namespace osu.Game.Overlays { - public class LoginOverlay : OsuFocusedOverlayContainer + public partial class LoginOverlay : OsuFocusedOverlayContainer { private LoginPanel panel; diff --git a/osu.Game/Overlays/MedalOverlay.cs b/osu.Game/Overlays/MedalOverlay.cs index e15625a4b3..bd895fe6bf 100644 --- a/osu.Game/Overlays/MedalOverlay.cs +++ b/osu.Game/Overlays/MedalOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK; using osuTK.Graphics; using osu.Framework.Extensions.Color4Extensions; @@ -25,7 +27,7 @@ using osu.Framework.Utils; namespace osu.Game.Overlays { - public class MedalOverlay : FocusedOverlayContainer + public partial class MedalOverlay : FocusedOverlayContainer { public const float DISC_SIZE = 400; @@ -264,7 +266,7 @@ namespace osu.Game.Overlays Expire(); } - private class BackgroundStrip : Container + private partial class BackgroundStrip : Container { public BackgroundStrip(float start, float end) { @@ -284,7 +286,7 @@ namespace osu.Game.Overlays } } - private class MedalParticle : CircularContainer + private partial class MedalParticle : CircularContainer { private readonly float direction; diff --git a/osu.Game/Overlays/MedalSplash/DrawableMedal.cs b/osu.Game/Overlays/MedalSplash/DrawableMedal.cs index 3cf7befb45..a25147b69f 100644 --- a/osu.Game/Overlays/MedalSplash/DrawableMedal.cs +++ b/osu.Game/Overlays/MedalSplash/DrawableMedal.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework; using osuTK; @@ -17,7 +19,7 @@ using osu.Game.Users; namespace osu.Game.Overlays.MedalSplash { [LongRunningLoad] - public class DrawableMedal : Container, IStateful + public partial class DrawableMedal : Container, IStateful { private const float scale_when_unlocked = 0.76f; private const float scale_when_full = 0.6f; @@ -110,7 +112,7 @@ namespace osu.Game.Overlays.MedalSplash s.Font = s.Font.With(size: 16); }); - medalContainer.OnLoadComplete += d => + medalContainer.OnLoadComplete += _ => { unlocked.Position = new Vector2(0f, medalContainer.DrawSize.Y / 2 + 10); infoFlow.Position = new Vector2(0f, unlocked.Position.Y + 90); diff --git a/osu.Game/Overlays/Mods/AddPresetButton.cs b/osu.Game/Overlays/Mods/AddPresetButton.cs new file mode 100644 index 0000000000..731079d1d9 --- /dev/null +++ b/osu.Game/Overlays/Mods/AddPresetButton.cs @@ -0,0 +1,67 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Mods; +using osuTK; + +namespace osu.Game.Overlays.Mods +{ + public partial class AddPresetButton : ShearedToggleButton, IHasPopover + { + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private Bindable> selectedMods { get; set; } = null!; + + public AddPresetButton() + : base(1) + { + RelativeSizeAxes = Axes.X; + Height = ModSelectPanel.HEIGHT; + + // shear will be applied at a higher level in `ModPresetColumn`. + Content.Shear = Vector2.Zero; + Padding = new MarginPadding(); + + Text = "+"; + TextSize = 30; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + selectedMods.BindValueChanged(mods => Enabled.Value = mods.NewValue.Any(), true); + Enabled.BindValueChanged(enabled => + { + if (!enabled.NewValue) + Active.Value = false; + }); + } + + protected override void UpdateActiveState() + { + DarkerColour = Active.Value ? colours.Orange1 : ColourProvider.Background3; + LighterColour = Active.Value ? colours.Orange0 : ColourProvider.Background1; + TextColour = Active.Value ? ColourProvider.Background6 : ColourProvider.Content1; + + if (Active.Value) + this.ShowPopover(); + else + this.HidePopover(); + } + + public Popover GetPopover() => new AddPresetPopover(this); + } +} diff --git a/osu.Game/Overlays/Mods/AddPresetPopover.cs b/osu.Game/Overlays/Mods/AddPresetPopover.cs new file mode 100644 index 0000000000..33d72ff383 --- /dev/null +++ b/osu.Game/Overlays/Mods/AddPresetPopover.cs @@ -0,0 +1,120 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +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; +using osu.Game.Localisation; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osuTK; + +namespace osu.Game.Overlays.Mods +{ + internal partial class AddPresetPopover : OsuPopover + { + private readonly AddPresetButton button; + + private readonly LabelledTextBox nameTextBox; + private readonly LabelledTextBox descriptionTextBox; + private readonly ShearedButton createButton; + + [Resolved] + private Bindable ruleset { get; set; } = null!; + + [Resolved] + private Bindable> selectedMods { get; set; } = null!; + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + public AddPresetPopover(AddPresetButton addPresetButton) + { + button = addPresetButton; + + Child = new FillFlowContainer + { + Width = 300, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(7), + Children = new Drawable[] + { + nameTextBox = new LabelledTextBox + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Label = CommonStrings.Name, + TabbableContentContainer = this + }, + descriptionTextBox = new LabelledTextBox + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Label = CommonStrings.Description, + TabbableContentContainer = this + }, + createButton = new ShearedButton + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = ModSelectOverlayStrings.AddPreset, + Action = tryCreatePreset + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider, OsuColour colours) + { + Body.BorderThickness = 3; + Body.BorderColour = colours.Orange1; + + createButton.DarkerColour = colours.Orange1; + createButton.LighterColour = colours.Orange0; + createButton.TextColour = colourProvider.Background6; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ScheduleAfterChildren(() => GetContainingInputManager().ChangeFocus(nameTextBox)); + } + + private void tryCreatePreset() + { + if (string.IsNullOrWhiteSpace(nameTextBox.Current.Value)) + { + Body.Shake(); + return; + } + + realm.Write(r => r.Add(new ModPreset + { + Name = nameTextBox.Current.Value, + Description = descriptionTextBox.Current.Value, + Mods = selectedMods.Value.ToArray(), + Ruleset = r.Find(ruleset.Value.ShortName) + })); + + this.HidePopover(); + } + + protected override void UpdateState(ValueChangedEvent state) + { + base.UpdateState(state); + if (state.NewValue == Visibility.Hidden) + button.Active.Value = false; + } + } +} diff --git a/osu.Game/Overlays/Mods/DeleteModPresetDialog.cs b/osu.Game/Overlays/Mods/DeleteModPresetDialog.cs new file mode 100644 index 0000000000..9788764453 --- /dev/null +++ b/osu.Game/Overlays/Mods/DeleteModPresetDialog.cs @@ -0,0 +1,18 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Database; +using osu.Game.Overlays.Dialog; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Overlays.Mods +{ + public partial class DeleteModPresetDialog : DangerousActionDialog + { + public DeleteModPresetDialog(Live modPreset) + { + BodyText = modPreset.PerformRead(preset => preset.Name); + DangerousAction = () => modPreset.PerformWrite(preset => preset.DeletePending = true); + } + } +} diff --git a/osu.Game/Overlays/Mods/DeselectAllModsButton.cs b/osu.Game/Overlays/Mods/DeselectAllModsButton.cs index 8288d34c95..3e5a3b12d1 100644 --- a/osu.Game/Overlays/Mods/DeselectAllModsButton.cs +++ b/osu.Game/Overlays/Mods/DeselectAllModsButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Bindables; @@ -13,7 +15,7 @@ using osu.Game.Rulesets.Mods; namespace osu.Game.Overlays.Mods { - public class DeselectAllModsButton : ShearedButton, IKeyBindingHandler + public partial class DeselectAllModsButton : ShearedButton, IKeyBindingHandler { private readonly Bindable> selectedMods = new Bindable>(); diff --git a/osu.Game/Overlays/Mods/DifficultyMultiplierDisplay.cs b/osu.Game/Overlays/Mods/DifficultyMultiplierDisplay.cs index 4ccec0dd87..ee4f932326 100644 --- a/osu.Game/Overlays/Mods/DifficultyMultiplierDisplay.cs +++ b/osu.Game/Overlays/Mods/DifficultyMultiplierDisplay.cs @@ -1,188 +1,43 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions.LocalisationExtensions; +#nullable disable + using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osu.Game.Rulesets.Mods; using osuTK; using osu.Game.Localisation; namespace osu.Game.Overlays.Mods { - public class DifficultyMultiplierDisplay : CompositeDrawable, IHasCurrentValue + public sealed partial class DifficultyMultiplierDisplay : ModsEffectDisplay { - public const float HEIGHT = 42; + protected override LocalisableString Label => DifficultyMultiplierDisplayStrings.DifficultyMultiplier; - public Bindable Current - { - get => current.Current; - set => current.Current = value; - } - - private readonly BindableNumberWithCurrent current = new BindableNumberWithCurrent(1) - { - Precision = 0.01 - }; - - private readonly Box underlayBackground; - private readonly Box contentBackground; - private readonly FillFlowContainer multiplierFlow; - private readonly MultiplierCounter multiplierCounter; - - [Resolved] - private OsuColour colours { get; set; } - - [Resolved] - private OverlayColourProvider colourProvider { get; set; } - - private const float multiplier_value_area_width = 56; - private const float transition_duration = 200; + protected override string CounterFormat => @"N2"; public DifficultyMultiplierDisplay() { - Height = HEIGHT; - AutoSizeAxes = Axes.X; - - InternalChild = new InputBlockingContainer + Current.Default = 1d; + Current.Value = 1d; + Add(new SpriteIcon { - RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X, - Masking = true, - CornerRadius = ModPanel.CORNER_RADIUS, - Shear = new Vector2(ShearedOverlayContainer.SHEAR, 0), - Children = new Drawable[] - { - underlayBackground = new Box - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - RelativeSizeAxes = Axes.Y, - Width = multiplier_value_area_width + ModPanel.CORNER_RADIUS - }, - new GridContainer - { - RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X, - ColumnDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.Absolute, multiplier_value_area_width) - }, - Content = new[] - { - new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X, - Masking = true, - CornerRadius = ModPanel.CORNER_RADIUS, - Children = new Drawable[] - { - contentBackground = new Box - { - RelativeSizeAxes = Axes.Both - }, - new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Margin = new MarginPadding { Horizontal = 18 }, - Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), - Text = DifficultyMultiplierDisplayStrings.DifficultyMultiplier, - Font = OsuFont.Default.With(size: 17, weight: FontWeight.SemiBold) - } - } - }, - multiplierFlow = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), - Direction = FillDirection.Horizontal, - Spacing = new Vector2(2, 0), - Children = new Drawable[] - { - multiplierCounter = new MultiplierCounter - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Current = { BindTarget = Current } - }, - new SpriteIcon - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Icon = FontAwesome.Solid.Times, - Size = new Vector2(7), - Margin = new MarginPadding { Top = 1 } - } - } - } - } - } - } - } - }; - } - - [BackgroundDependencyLoader] - private void load() - { - contentBackground.Colour = colourProvider.Background4; + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = FontAwesome.Solid.Times, + Size = new Vector2(7), + Margin = new MarginPadding { Top = 1 } + }); } protected override void LoadComplete() { base.LoadComplete(); - current.BindValueChanged(_ => updateState(), true); - // required to prevent the counter initially rolling up from 0 to 1 // due to `Current.Value` having a nonstandard default value of 1. - multiplierCounter.SetCountWithoutRolling(Current.Value); - } - - private void updateState() - { - if (Current.IsDefault) - { - underlayBackground.FadeColour(colourProvider.Background3, transition_duration, Easing.OutQuint); - multiplierFlow.FadeColour(Colour4.White, transition_duration, Easing.OutQuint); - } - else - { - var backgroundColour = Current.Value < 1 - ? colours.ForModType(ModType.DifficultyReduction) - : colours.ForModType(ModType.DifficultyIncrease); - - underlayBackground.FadeColour(backgroundColour, transition_duration, Easing.OutQuint); - multiplierFlow.FadeColour(colourProvider.Background5, transition_duration, Easing.OutQuint); - } - } - - private class MultiplierCounter : RollingCounter - { - protected override double RollingDuration => 500; - - protected override LocalisableString FormatCount(double count) => count.ToLocalisableString(@"N2"); - - protected override OsuSpriteText CreateSpriteText() => new OsuSpriteText - { - Font = OsuFont.Default.With(size: 17, weight: FontWeight.SemiBold) - }; + Counter.SetCountWithoutRolling(Current.Value); } } } diff --git a/osu.Game/Overlays/Mods/IncompatibilityDisplayingModPanel.cs b/osu.Game/Overlays/Mods/IncompatibilityDisplayingModPanel.cs index 34c4458a21..93279b6e1c 100644 --- a/osu.Game/Overlays/Mods/IncompatibilityDisplayingModPanel.cs +++ b/osu.Game/Overlays/Mods/IncompatibilityDisplayingModPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -12,7 +14,7 @@ using osu.Game.Utils; namespace osu.Game.Overlays.Mods { - public class IncompatibilityDisplayingModPanel : ModPanel, IHasCustomTooltip + public partial class IncompatibilityDisplayingModPanel : ModPanel, IHasCustomTooltip { private readonly BindableBool incompatible = new BindableBool(); @@ -44,8 +46,8 @@ namespace osu.Game.Overlays.Mods && !ModUtils.CheckCompatibleSet(selectedMods.Value.Append(Mod)); } - protected override Colour4 BackgroundColour => incompatible.Value ? (Colour4)ColourProvider.Background6 : base.BackgroundColour; - protected override Colour4 ForegroundColour => incompatible.Value ? (Colour4)ColourProvider.Background5 : base.ForegroundColour; + protected override Colour4 BackgroundColour => incompatible.Value ? ColourProvider.Background6 : base.BackgroundColour; + protected override Colour4 ForegroundColour => incompatible.Value ? ColourProvider.Background5 : base.ForegroundColour; protected override void UpdateState() { diff --git a/osu.Game/Overlays/Mods/IncompatibilityDisplayingTooltip.cs b/osu.Game/Overlays/Mods/IncompatibilityDisplayingTooltip.cs index d8117c8f00..1723634774 100644 --- a/osu.Game/Overlays/Mods/IncompatibilityDisplayingTooltip.cs +++ b/osu.Game/Overlays/Mods/IncompatibilityDisplayingTooltip.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -15,7 +17,7 @@ using osuTK; namespace osu.Game.Overlays.Mods { - internal class IncompatibilityDisplayingTooltip : ModButtonTooltip + internal partial class IncompatibilityDisplayingTooltip : ModButtonTooltip { private readonly OsuSpriteText incompatibleText; diff --git a/osu.Game/Overlays/Mods/Input/ClassicModHotkeyHandler.cs b/osu.Game/Overlays/Mods/Input/ClassicModHotkeyHandler.cs new file mode 100644 index 0000000000..4f3c18fc43 --- /dev/null +++ b/osu.Game/Overlays/Mods/Input/ClassicModHotkeyHandler.cs @@ -0,0 +1,98 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Mods; +using osuTK.Input; + +namespace osu.Game.Overlays.Mods.Input +{ + /// + /// Uses bindings from stable 1:1. + /// + public class ClassicModHotkeyHandler : IModHotkeyHandler + { + private static readonly Dictionary mod_type_lookup = new Dictionary + { + [Key.Q] = new[] { typeof(ModEasy) }, + [Key.W] = new[] { typeof(ModNoFail) }, + [Key.E] = new[] { typeof(ModHalfTime) }, + [Key.A] = new[] { typeof(ModHardRock) }, + [Key.S] = new[] { typeof(ModSuddenDeath), typeof(ModPerfect) }, + [Key.D] = new[] { typeof(ModDoubleTime), typeof(ModNightcore) }, + [Key.F] = new[] { typeof(ModHidden) }, + [Key.G] = new[] { typeof(ModFlashlight) }, + [Key.Z] = new[] { typeof(ModRelax) }, + [Key.V] = new[] { typeof(ModAutoplay), typeof(ModCinema) } + }; + + private readonly bool allowIncompatibleSelection; + + public ClassicModHotkeyHandler(bool allowIncompatibleSelection) + { + this.allowIncompatibleSelection = allowIncompatibleSelection; + } + + public bool HandleHotkeyPressed(KeyDownEvent e, IEnumerable availableMods) + { + if (!mod_type_lookup.TryGetValue(e.Key, out var typesToMatch)) + return false; + + var matchingMods = availableMods.Where(modState => matches(modState, typesToMatch) && !modState.Filtered.Value).ToArray(); + + if (matchingMods.Length == 0) + return false; + + if (matchingMods.Length == 1) + { + matchingMods.Single().Active.Toggle(); + return true; + } + + if (allowIncompatibleSelection) + { + // easier path - multiple incompatible mods can be active at a time. + // this is used in the free mod select overlay. + // in this case, just toggle everything. + bool anyActive = matchingMods.Any(mod => mod.Active.Value); + foreach (var mod in matchingMods) + mod.Active.Value = !anyActive; + return true; + } + + // we now know there are multiple possible mods to handle, and only one of them can be active at a time. + // let's make sure of this just in case. + Debug.Assert(matchingMods.Count(modState => modState.Active.Value) <= 1); + int currentSelectedIndex = Array.FindIndex(matchingMods, modState => modState.Active.Value); + + // `FindIndex` will return -1 if it doesn't find the item. + // this is convenient in the forward direction, since if we add 1 then we'll end up at the first item, + // but less so in the backwards direction. + // for convenience, detect this situation and set the index to one index past the last item. + // this makes it so that if we subtract 1 then we'll end up at the last item again. + if (currentSelectedIndex < 0 && e.ShiftPressed) + currentSelectedIndex = matchingMods.Length; + + int indexToSelect = e.ShiftPressed ? currentSelectedIndex - 1 : currentSelectedIndex + 1; + + // `currentSelectedIndex` and `indexToSelect` can both be equal to -1 or `matchingMods.Length`. + // if the former is beyond array range, it means nothing was previously selected and so there's nothing to deselect. + // if the latter is beyond array range, it means that either the previous selection was first and we're going backwards, + // or it was last and we're going forwards. + // in either case there is nothing to select. + if (currentSelectedIndex >= 0 && currentSelectedIndex <= matchingMods.Length - 1) + matchingMods[currentSelectedIndex].Active.Value = false; + if (indexToSelect >= 0 && indexToSelect <= matchingMods.Length - 1) + matchingMods[indexToSelect].Active.Value = true; + + return true; + } + + private static bool matches(ModState modState, Type[] typesToMatch) + => typesToMatch.Any(typeToMatch => typeToMatch.IsInstanceOfType(modState.Mod)); + } +} diff --git a/osu.Game/Overlays/Mods/Input/IModHotkeyHandler.cs b/osu.Game/Overlays/Mods/Input/IModHotkeyHandler.cs new file mode 100644 index 0000000000..d2cc0e84d2 --- /dev/null +++ b/osu.Game/Overlays/Mods/Input/IModHotkeyHandler.cs @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Input.Events; + +namespace osu.Game.Overlays.Mods.Input +{ + /// + /// Encapsulates strategies of handling mod hotkeys on the . + /// + public interface IModHotkeyHandler + { + /// + /// Attempt to handle the supplied as a selection of one of the mods in . + /// + /// The event representing the user's keypress. + /// The list of currently available mods. + /// Whether the supplied event was handled as a mod selection/deselection. + bool HandleHotkeyPressed(KeyDownEvent e, IEnumerable availableMods); + } +} diff --git a/osu.Game/Overlays/Mods/Input/ModSelectHotkeyStyle.cs b/osu.Game/Overlays/Mods/Input/ModSelectHotkeyStyle.cs new file mode 100644 index 0000000000..abb7a804da --- /dev/null +++ b/osu.Game/Overlays/Mods/Input/ModSelectHotkeyStyle.cs @@ -0,0 +1,31 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; +using osu.Game.Rulesets.Mods; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Mods.Input +{ + /// + /// The style of hotkey handling to use on the mod select screen. + /// + public enum ModSelectHotkeyStyle + { + /// + /// Each letter row on the keyboard controls one of the three first s. + /// Individual letters in a row trigger the mods in a sequential fashion. + /// Uses . + /// + [LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.SequentialHotkeyStyle))] + Sequential, + + /// + /// Matches keybindings from stable 1:1. + /// One keybinding can toggle between what used to be s on stable, + /// and some mods in a column may not have any hotkeys at all. + /// + [LocalisableDescription(typeof(UserInterfaceStrings), nameof(UserInterfaceStrings.ClassicHotkeyStyle))] + Classic + } +} diff --git a/osu.Game/Overlays/Mods/Input/NoopModHotkeyHandler.cs b/osu.Game/Overlays/Mods/Input/NoopModHotkeyHandler.cs new file mode 100644 index 0000000000..3f7a6298a1 --- /dev/null +++ b/osu.Game/Overlays/Mods/Input/NoopModHotkeyHandler.cs @@ -0,0 +1,17 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Input.Events; + +namespace osu.Game.Overlays.Mods.Input +{ + /// + /// A no-op implementation of . + /// Used when a column is not handling any hotkeys at all. + /// + public class NoopModHotkeyHandler : IModHotkeyHandler + { + public bool HandleHotkeyPressed(KeyDownEvent e, IEnumerable availableMods) => false; + } +} diff --git a/osu.Game/Overlays/Mods/Input/SequentialModHotkeyHandler.cs b/osu.Game/Overlays/Mods/Input/SequentialModHotkeyHandler.cs new file mode 100644 index 0000000000..dedb556304 --- /dev/null +++ b/osu.Game/Overlays/Mods/Input/SequentialModHotkeyHandler.cs @@ -0,0 +1,59 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Mods; +using osuTK.Input; + +namespace osu.Game.Overlays.Mods.Input +{ + /// + /// This implementation of receives a sequence of s, + /// and maps the sequence of keys onto the items it is provided in . + /// In this case, particular mods are not bound to particular keys, the hotkeys are a byproduct of mod ordering. + /// + public class SequentialModHotkeyHandler : IModHotkeyHandler + { + public static SequentialModHotkeyHandler Create(ModType modType) + { + switch (modType) + { + case ModType.DifficultyReduction: + return new SequentialModHotkeyHandler(new[] { Key.Q, Key.W, Key.E, Key.R, Key.T, Key.Y, Key.U, Key.I, Key.O, Key.P }); + + case ModType.DifficultyIncrease: + return new SequentialModHotkeyHandler(new[] { Key.A, Key.S, Key.D, Key.F, Key.G, Key.H, Key.J, Key.K, Key.L }); + + case ModType.Automation: + return new SequentialModHotkeyHandler(new[] { Key.Z, Key.X, Key.C, Key.V, Key.B, Key.N, Key.M }); + + default: + throw new ArgumentOutOfRangeException(nameof(modType), modType, $"Cannot create {nameof(SequentialModHotkeyHandler)} for provided mod type"); + } + } + + private readonly Key[] toggleKeys; + + private SequentialModHotkeyHandler(Key[] keys) + { + toggleKeys = keys; + } + + public bool HandleHotkeyPressed(KeyDownEvent e, IEnumerable availableMods) + { + int index = Array.IndexOf(toggleKeys, e.Key); + if (index < 0) + return false; + + var modState = availableMods.Where(modState => !modState.Filtered.Value).ElementAtOrDefault(index); + if (modState == null) + return false; + + modState.Active.Toggle(); + return true; + } + } +} diff --git a/osu.Game/Overlays/Mods/ModButtonTooltip.cs b/osu.Game/Overlays/Mods/ModButtonTooltip.cs index 2f50e38a5a..52b27f1e00 100644 --- a/osu.Game/Overlays/Mods/ModButtonTooltip.cs +++ b/osu.Game/Overlays/Mods/ModButtonTooltip.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -13,7 +15,7 @@ using osuTK; namespace osu.Game.Overlays.Mods { - public class ModButtonTooltip : VisibilityContainer, ITooltip + public partial class ModButtonTooltip : VisibilityContainer, ITooltip { private readonly OsuSpriteText descriptionText; private readonly Box background; diff --git a/osu.Game/Overlays/Mods/ModColumn.cs b/osu.Game/Overlays/Mods/ModColumn.cs index 42f9daec4d..5d9f616e5f 100644 --- a/osu.Game/Overlays/Mods/ModColumn.cs +++ b/osu.Game/Overlays/Mods/ModColumn.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 enable - using System; using System.Collections.Generic; using System.Diagnostics; @@ -14,26 +12,21 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; +using osu.Game.Configuration; using osu.Game.Graphics; -using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; +using osu.Game.Overlays.Mods.Input; using osu.Game.Rulesets.Mods; using osuTK; using osuTK.Graphics; -using osuTK.Input; namespace osu.Game.Overlays.Mods { - public class ModColumn : CompositeDrawable + public partial class ModColumn : ModSelectColumn { - public readonly Container TopLevelContent; - public readonly ModType ModType; private IReadOnlyList availableMods = Array.Empty(); @@ -63,146 +56,32 @@ namespace osu.Game.Overlays.Mods } } - /// - /// Determines whether this column should accept user input. - /// - public Bindable Active = new BindableBool(true); - - protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => base.ReceivePositionalInputAtSubTree(screenSpacePos) && Active.Value; - protected virtual ModPanel CreateModPanel(ModState mod) => new ModPanel(mod); - private readonly Key[]? toggleKeys; + private readonly bool allowIncompatibleSelection; - private readonly TextFlowContainer headerText; - private readonly Box headerBackground; - private readonly Container contentContainer; - private readonly Box contentBackground; - private readonly FillFlowContainer panelFlow; private readonly ToggleAllCheckbox? toggleAllCheckbox; - private Colour4 accentColour; + private Bindable hotkeyStyle = null!; + private IModHotkeyHandler hotkeyHandler = null!; private Task? latestLoadTask; - internal bool ItemsLoaded => latestLoadTask == null; + private ICollection? latestLoadedPanels; + internal bool ItemsLoaded => latestLoadTask?.IsCompleted == true && latestLoadedPanels?.All(panel => panel.Parent != null) == true; - private const float header_height = 42; + public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; - public ModColumn(ModType modType, bool allowBulkSelection, Key[]? toggleKeys = null) + public ModColumn(ModType modType, bool allowIncompatibleSelection) { ModType = modType; - this.toggleKeys = toggleKeys; + this.allowIncompatibleSelection = allowIncompatibleSelection; - Width = 320; - RelativeSizeAxes = Axes.Y; - Shear = new Vector2(ShearedOverlayContainer.SHEAR, 0); + HeaderText = ModType.Humanize(LetterCasing.Title); - Container controlContainer; - InternalChildren = new Drawable[] + if (allowIncompatibleSelection) { - TopLevelContent = new Container - { - RelativeSizeAxes = Axes.Both, - CornerRadius = ModPanel.CORNER_RADIUS, - Masking = true, - Children = new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.X, - Height = header_height + ModPanel.CORNER_RADIUS, - Children = new Drawable[] - { - headerBackground = new Box - { - RelativeSizeAxes = Axes.X, - Height = header_height + ModPanel.CORNER_RADIUS - }, - headerText = new OsuTextFlowContainer(t => - { - t.Font = OsuFont.TorusAlternate.With(size: 17); - t.Shadow = false; - t.Colour = Colour4.Black; - }) - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), - Padding = new MarginPadding - { - Horizontal = 17, - Bottom = ModPanel.CORNER_RADIUS - } - } - } - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = header_height }, - Child = contentContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = ModPanel.CORNER_RADIUS, - BorderThickness = 3, - Children = new Drawable[] - { - contentBackground = new Box - { - RelativeSizeAxes = Axes.Both - }, - new GridContainer - { - RelativeSizeAxes = Axes.Both, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension() - }, - Content = new[] - { - new Drawable[] - { - controlContainer = new Container - { - RelativeSizeAxes = Axes.X, - Padding = new MarginPadding { Horizontal = 14 } - } - }, - new Drawable[] - { - new OsuScrollContainer(Direction.Vertical) - { - RelativeSizeAxes = Axes.Both, - ClampExtension = 100, - ScrollbarOverlapsContent = false, - Child = panelFlow = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(0, 7), - Padding = new MarginPadding(7) - } - } - } - } - } - } - } - } - } - } - }; - - createHeaderText(); - - if (allowBulkSelection) - { - controlContainer.Height = 35; - controlContainer.Add(toggleAllCheckbox = new ToggleAllCheckbox(this) + ControlContainer.Height = 35; + ControlContainer.Add(toggleAllCheckbox = new ToggleAllCheckbox(this) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, @@ -210,7 +89,7 @@ namespace osu.Game.Overlays.Mods RelativeSizeAxes = Axes.X, Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0) }); - panelFlow.Padding = new MarginPadding + ItemsFlow.Padding = new MarginPadding { Top = 0, Bottom = 7, @@ -219,32 +98,18 @@ namespace osu.Game.Overlays.Mods } } - private void createHeaderText() - { - IEnumerable headerTextWords = ModType.Humanize(LetterCasing.Title).Split(' '); - - if (headerTextWords.Count() > 1) - { - headerText.AddText($"{headerTextWords.First()} ", t => t.Font = t.Font.With(weight: FontWeight.SemiBold)); - headerTextWords = headerTextWords.Skip(1); - } - - headerText.AddText(string.Join(' ', headerTextWords)); - } - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider, OsuColour colours) + private void load(OsuColour colours, OsuConfigManager configManager) { - headerBackground.Colour = accentColour = colours.ForModType(ModType); + AccentColour = colours.ForModType(ModType); if (toggleAllCheckbox != null) { - toggleAllCheckbox.AccentColour = accentColour; - toggleAllCheckbox.AccentHoverColour = accentColour.Lighten(0.3f); + toggleAllCheckbox.AccentColour = AccentColour; + toggleAllCheckbox.AccentHoverColour = AccentColour.Lighten(0.3f); } - contentContainer.BorderColour = ColourInfo.GradientVertical(colourProvider.Background4, colourProvider.Background3); - contentBackground.Colour = colourProvider.Background4; + hotkeyStyle = configManager.GetBindable(OsuSetting.ModSelectHotkeyStyle); } protected override void LoadComplete() @@ -252,6 +117,7 @@ namespace osu.Game.Overlays.Mods base.LoadComplete(); toggleAllCheckbox?.Current.BindValueChanged(_ => updateToggleAllText(), true); + hotkeyStyle.BindValueChanged(val => hotkeyHandler = createHotkeyHandler(val.NewValue), true); asyncLoadPanels(); } @@ -267,20 +133,14 @@ namespace osu.Game.Overlays.Mods { cancellationTokenSource?.Cancel(); - var panels = availableMods.Select(mod => CreateModPanel(mod).With(panel => panel.Shear = Vector2.Zero)); + var panels = availableMods.Select(mod => CreateModPanel(mod).With(panel => panel.Shear = Vector2.Zero)).ToArray(); + latestLoadedPanels = panels; - Task? loadTask; - - latestLoadTask = loadTask = LoadComponentsAsync(panels, loaded => + latestLoadTask = LoadComponentsAsync(panels, loaded => { - panelFlow.ChildrenEnumerable = loaded; + ItemsFlow.ChildrenEnumerable = loaded; updateState(); }, (cancellationTokenSource = new CancellationTokenSource()).Token); - loadTask.ContinueWith(_ => - { - if (loadTask == latestLoadTask) - latestLoadTask = null; - }); } private void updateState() @@ -359,7 +219,7 @@ namespace osu.Game.Overlays.Mods dequeuedAction(); } - private class ToggleAllCheckbox : OsuCheckbox + private partial class ToggleAllCheckbox : OsuCheckbox { private Color4 accentColour; @@ -425,19 +285,32 @@ namespace osu.Game.Overlays.Mods #region Keyboard selection support + /// + /// Creates an appropriate for this column's and + /// the supplied . + /// + private IModHotkeyHandler createHotkeyHandler(ModSelectHotkeyStyle hotkeyStyle) + { + switch (ModType) + { + case ModType.DifficultyReduction: + case ModType.DifficultyIncrease: + case ModType.Automation: + return hotkeyStyle == ModSelectHotkeyStyle.Sequential + ? SequentialModHotkeyHandler.Create(ModType) + : new ClassicModHotkeyHandler(allowIncompatibleSelection); + + default: + return new NoopModHotkeyHandler(); + } + } + protected override bool OnKeyDown(KeyDownEvent e) { - if (e.ControlPressed || e.AltPressed || e.SuperPressed) return false; - if (toggleKeys == null) return false; + if (e.ControlPressed || e.AltPressed || e.SuperPressed || e.Repeat) + return false; - int index = Array.IndexOf(toggleKeys, e.Key); - if (index < 0) return false; - - var modState = availableMods.ElementAtOrDefault(index); - if (modState == null || modState.Filtered.Value) return false; - - modState.Active.Toggle(); - return true; + return hotkeyHandler.HandleHotkeyPressed(e, availableMods); } #endregion diff --git a/osu.Game/Overlays/Mods/ModPanel.cs b/osu.Game/Overlays/Mods/ModPanel.cs index 358bdd3202..b5fee9d116 100644 --- a/osu.Game/Overlays/Mods/ModPanel.cs +++ b/osu.Game/Overlays/Mods/ModPanel.cs @@ -1,146 +1,42 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - -using System; using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Events; -using osu.Framework.Utils; -using osu.Game.Audio; using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; using osuTK; -using osuTK.Input; namespace osu.Game.Overlays.Mods { - public class ModPanel : OsuClickableContainer + public partial class ModPanel : ModSelectPanel { public Mod Mod => modState.Mod; - public BindableBool Active => modState.Active; + public override BindableBool Active => modState.Active; public BindableBool Filtered => modState.Filtered; + protected override float IdleSwitchWidth => 54; + protected override float ExpandedSwitchWidth => 70; + private readonly ModState modState; - protected readonly Box Background; - protected readonly Container SwitchContainer; - protected readonly Container MainContentContainer; - protected readonly Box TextBackground; - protected readonly FillFlowContainer TextFlow; - - [Resolved] - protected OverlayColourProvider ColourProvider { get; private set; } = null!; - - protected const double TRANSITION_DURATION = 150; - - public const float CORNER_RADIUS = 7; - - protected const float HEIGHT = 42; - protected const float IDLE_SWITCH_WIDTH = 54; - protected const float EXPANDED_SWITCH_WIDTH = 70; - - private Colour4 activeColour; - - private readonly Bindable samplePlaybackDisabled = new BindableBool(); - private Sample? sampleOff; - private Sample? sampleOn; - public ModPanel(ModState modState) { this.modState = modState; - RelativeSizeAxes = Axes.X; - Height = 42; + Title = Mod.Name; + Description = Mod.Description; - // all below properties are applied to `Content` rather than the `ModPanel` in its entirety - // to allow external components to set these properties on the panel without affecting - // its "internal" appearance. - Content.Masking = true; - Content.CornerRadius = CORNER_RADIUS; - Content.BorderThickness = 2; - Shear = new Vector2(ShearedOverlayContainer.SHEAR, 0); - - Children = new Drawable[] + SwitchContainer.Child = new ModSwitchSmall(Mod) { - Background = new Box - { - RelativeSizeAxes = Axes.Both - }, - SwitchContainer = new Container - { - RelativeSizeAxes = Axes.Y, - Child = new ModSwitchSmall(Mod) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Active = { BindTarget = Active }, - Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), - Scale = new Vector2(HEIGHT / ModSwitchSmall.DEFAULT_SIZE) - } - }, - MainContentContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Child = new Container - { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = CORNER_RADIUS, - Children = new Drawable[] - { - TextBackground = new Box - { - RelativeSizeAxes = Axes.Both - }, - TextFlow = new FillFlowContainer - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding - { - Horizontal = 17.5f, - Vertical = 4 - }, - Direction = FillDirection.Vertical, - Children = new[] - { - new OsuSpriteText - { - Text = Mod.Name, - Font = OsuFont.TorusAlternate.With(size: 18, weight: FontWeight.SemiBold), - Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), - Margin = new MarginPadding - { - Left = -18 * ShearedOverlayContainer.SHEAR - } - }, - new OsuSpriteText - { - Text = Mod.Description, - Font = OsuFont.Default.With(size: 12), - RelativeSizeAxes = Axes.X, - Truncate = true, - Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0) - } - } - } - } - } - } + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Active = { BindTarget = Active }, + Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), + Scale = new Vector2(HEIGHT / ModSwitchSmall.DEFAULT_SIZE) }; - - Action = Active.Toggle; } public ModPanel(Mod mod) @@ -148,122 +44,33 @@ namespace osu.Game.Overlays.Mods { } - [BackgroundDependencyLoader(true)] - private void load(AudioManager audio, OsuColour colours, ISamplePlaybackDisabler? samplePlaybackDisabler) + [BackgroundDependencyLoader] + private void load(OsuColour colours) { - sampleOn = audio.Samples.Get(@"UI/check-on"); - sampleOff = audio.Samples.Get(@"UI/check-off"); - - activeColour = colours.ForModType(Mod.Type); - - if (samplePlaybackDisabler != null) - ((IBindable)samplePlaybackDisabled).BindTo(samplePlaybackDisabler.SamplePlaybackDisabled); + AccentColour = colours.ForModType(Mod.Type); } - protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(sampleSet); - protected override void LoadComplete() { base.LoadComplete(); - Active.BindValueChanged(_ => - { - playStateChangeSamples(); - UpdateState(); - }); + Filtered.BindValueChanged(_ => updateFilterState(), true); - - UpdateState(); - FinishTransforms(true); } - private void playStateChangeSamples() + protected override void Select() { - if (samplePlaybackDisabled.Value) - return; - - if (Active.Value) - sampleOn?.Play(); - else - sampleOff?.Play(); + modState.PendingConfiguration = Mod.RequiresConfiguration; + Active.Value = true; } - protected override bool OnHover(HoverEvent e) + protected override void Deselect() { - UpdateState(); - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - UpdateState(); - base.OnHoverLost(e); - } - - private bool mouseDown; - - protected override bool OnMouseDown(MouseDownEvent e) - { - if (e.Button == MouseButton.Left) - mouseDown = true; - - UpdateState(); - return false; - } - - protected override void OnMouseUp(MouseUpEvent e) - { - mouseDown = false; - - UpdateState(); - base.OnMouseUp(e); - } - - protected virtual Colour4 BackgroundColour => Active.Value ? activeColour.Darken(0.3f) : (Colour4)ColourProvider.Background3; - protected virtual Colour4 ForegroundColour => Active.Value ? activeColour : (Colour4)ColourProvider.Background2; - protected virtual Colour4 TextColour => Active.Value ? (Colour4)ColourProvider.Background6 : Colour4.White; - - protected virtual void UpdateState() - { - float targetWidth = Active.Value ? EXPANDED_SWITCH_WIDTH : IDLE_SWITCH_WIDTH; - double transitionDuration = TRANSITION_DURATION; - - Colour4 backgroundColour = BackgroundColour; - Colour4 foregroundColour = ForegroundColour; - Colour4 textColour = TextColour; - - // Hover affects colour of button background - if (IsHovered) - { - backgroundColour = backgroundColour.Lighten(0.1f); - foregroundColour = foregroundColour.Lighten(0.1f); - } - - // Mouse down adds a halfway tween of the movement - if (mouseDown) - { - targetWidth = (float)Interpolation.Lerp(IDLE_SWITCH_WIDTH, EXPANDED_SWITCH_WIDTH, 0.5f); - transitionDuration *= 4; - } - - Content.TransformTo(nameof(BorderColour), ColourInfo.GradientVertical(backgroundColour, foregroundColour), transitionDuration, Easing.OutQuint); - Background.FadeColour(backgroundColour, transitionDuration, Easing.OutQuint); - SwitchContainer.ResizeWidthTo(targetWidth, transitionDuration, Easing.OutQuint); - MainContentContainer.TransformTo(nameof(Padding), new MarginPadding - { - Left = targetWidth, - Right = CORNER_RADIUS - }, transitionDuration, Easing.OutQuint); - TextBackground.FadeColour(foregroundColour, transitionDuration, Easing.OutQuint); - TextFlow.FadeColour(textColour, transitionDuration, Easing.OutQuint); + modState.PendingConfiguration = false; + Active.Value = false; } #region Filtering support - public void ApplyFilter(Func? filter) - { - Filtered.Value = filter != null && !filter.Invoke(Mod); - } - private void updateFilterState() { this.FadeTo(Filtered.Value ? 0 : 1); diff --git a/osu.Game/Overlays/Mods/ModPresetColumn.cs b/osu.Game/Overlays/Mods/ModPresetColumn.cs new file mode 100644 index 0000000000..bf5e576277 --- /dev/null +++ b/osu.Game/Overlays/Mods/ModPresetColumn.cs @@ -0,0 +1,97 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Localisation; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osuTK; +using Realms; + +namespace osu.Game.Overlays.Mods +{ + public partial class ModPresetColumn : ModSelectColumn + { + [Resolved] + private RealmAccess realm { get; set; } = null!; + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AccentColour = colours.Orange1; + HeaderText = ModSelectOverlayStrings.PersonalPresets; + + AddPresetButton addPresetButton; + ItemsFlow.Add(addPresetButton = new AddPresetButton()); + ItemsFlow.SetLayoutPosition(addPresetButton, float.PositiveInfinity); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ruleset.BindValueChanged(_ => rulesetChanged(), true); + } + + private IDisposable? presetSubscription; + + private void rulesetChanged() + { + presetSubscription?.Dispose(); + presetSubscription = realm.RegisterForNotifications(r => + r.All() + .Filter($"{nameof(ModPreset.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $0" + + $" && {nameof(ModPreset.DeletePending)} == false", ruleset.Value.ShortName) + .OrderBy(preset => preset.Name), asyncLoadPanels); + } + + private CancellationTokenSource? cancellationTokenSource; + + private Task? latestLoadTask; + internal bool ItemsLoaded => latestLoadTask?.IsCompleted == true; + + private void asyncLoadPanels(IRealmCollection presets, ChangeSet changes, Exception error) + { + cancellationTokenSource?.Cancel(); + + if (!presets.Any()) + { + removeAndDisposePresetPanels(); + return; + } + + latestLoadTask = LoadComponentsAsync(presets.Select(p => new ModPresetPanel(p.ToLive(realm)) + { + Shear = Vector2.Zero + }), loaded => + { + removeAndDisposePresetPanels(); + ItemsFlow.AddRange(loaded); + }, (cancellationTokenSource = new CancellationTokenSource()).Token); + + void removeAndDisposePresetPanels() + { + foreach (var panel in ItemsFlow.OfType().ToArray()) + panel.RemoveAndDisposeImmediately(); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + presetSubscription?.Dispose(); + } + } +} diff --git a/osu.Game/Overlays/Mods/ModPresetPanel.cs b/osu.Game/Overlays/Mods/ModPresetPanel.cs new file mode 100644 index 0000000000..6e12e34124 --- /dev/null +++ b/osu.Game/Overlays/Mods/ModPresetPanel.cs @@ -0,0 +1,106 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Overlays.Mods +{ + public partial class ModPresetPanel : ModSelectPanel, IHasCustomTooltip, IHasContextMenu + { + public readonly Live Preset; + + public override BindableBool Active { get; } = new BindableBool(); + + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + + [Resolved] + private Bindable> selectedMods { get; set; } = null!; + + private ModSettingChangeTracker? settingChangeTracker; + + public ModPresetPanel(Live preset) + { + Preset = preset; + + Title = preset.Value.Name; + Description = preset.Value.Description; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AccentColour = colours.Orange1; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + selectedMods.BindValueChanged(_ => selectedModsChanged(), true); + } + + protected override void Select() + { + // if the preset is not active at the point of the user click, then set the mods using the preset directly, discarding any previous selections, + // which will also have the side effect of activating the preset (see `updateActiveState()`). + selectedMods.Value = Preset.Value.Mods.ToArray(); + } + + protected override void Deselect() + { + // if the preset is active when the user has clicked it, then it means that the set of active mods is exactly equal to the set of mods in the preset + // (there are no other active mods than what the preset specifies, and the mod settings match exactly). + // therefore it's safe to just clear selected mods, since it will have the effect of toggling the preset off. + selectedMods.Value = Array.Empty(); + } + + private void selectedModsChanged() + { + settingChangeTracker?.Dispose(); + settingChangeTracker = new ModSettingChangeTracker(selectedMods.Value); + settingChangeTracker.SettingChanged = _ => updateActiveState(); + updateActiveState(); + } + + private void updateActiveState() + { + Active.Value = new HashSet(Preset.Value.Mods).SetEquals(selectedMods.Value); + } + + #region IHasCustomTooltip + + public ModPreset TooltipContent => Preset.Value; + public ITooltip GetCustomTooltip() => new ModPresetTooltip(ColourProvider); + + #endregion + + #region IHasContextMenu + + public MenuItem[] ContextMenuItems => new MenuItem[] + { + new OsuMenuItem(CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new DeleteModPresetDialog(Preset))) + }; + + #endregion + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + settingChangeTracker?.Dispose(); + } + } +} diff --git a/osu.Game/Overlays/Mods/ModPresetTooltip.cs b/osu.Game/Overlays/Mods/ModPresetTooltip.cs new file mode 100644 index 0000000000..ff4f00da69 --- /dev/null +++ b/osu.Game/Overlays/Mods/ModPresetTooltip.cs @@ -0,0 +1,115 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +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 +{ + public partial class ModPresetTooltip : VisibilityContainer, ITooltip + { + protected override Container Content { get; } + + private const double transition_duration = 200; + + public ModPresetTooltip(OverlayColourProvider colourProvider) + { + Width = 250; + AutoSizeAxes = Axes.Y; + + Masking = true; + CornerRadius = 7; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6 + }, + Content = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(7), + Spacing = new Vector2(7) + } + }; + } + + private ModPreset? lastPreset; + + public void SetContent(ModPreset preset) + { + if (ReferenceEquals(preset, lastPreset)) + return; + + lastPreset = preset; + Content.ChildrenEnumerable = preset.Mods.Select(mod => new ModPresetRow(mod)); + } + + protected override void PopIn() => this.FadeIn(transition_duration, Easing.OutQuint); + 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/ModSelectColumn.cs b/osu.Game/Overlays/Mods/ModSelectColumn.cs new file mode 100644 index 0000000000..e6d7bcd97d --- /dev/null +++ b/osu.Game/Overlays/Mods/ModSelectColumn.cs @@ -0,0 +1,196 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.Containers; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Mods +{ + public abstract partial class ModSelectColumn : CompositeDrawable, IHasAccentColour + { + public readonly Container TopLevelContent; + + public LocalisableString HeaderText + { + set => createHeaderText(value); + } + + public Color4 AccentColour + { + get => headerBackground.Colour; + set + { + headerBackground.Colour = value; + + var hsv = new Colour4(value.R, value.G, value.B, 1f).ToHSV(); + var trianglesColour = Colour4.FromHSV(hsv.X, hsv.Y + 0.2f, hsv.Z - 0.1f); + triangles.Colour = ColourInfo.GradientVertical(trianglesColour, trianglesColour.MultiplyAlpha(0f)); + } + } + + /// + /// Determines whether this column should accept user input. + /// + public readonly Bindable Active = new BindableBool(true); + + protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => base.ReceivePositionalInputAtSubTree(screenSpacePos) && Active.Value; + + protected readonly Container ControlContainer; + protected readonly FillFlowContainer ItemsFlow; + + private readonly TextFlowContainer headerText; + private readonly Box headerBackground; + private readonly Container contentContainer; + private readonly Box contentBackground; + private readonly TrianglesV2 triangles; + + private const float header_height = 42; + + protected ModSelectColumn() + { + Width = 320; + RelativeSizeAxes = Axes.Y; + Shear = new Vector2(ShearedOverlayContainer.SHEAR, 0); + + InternalChildren = new Drawable[] + { + TopLevelContent = new Container + { + RelativeSizeAxes = Axes.Both, + CornerRadius = ModSelectPanel.CORNER_RADIUS, + Masking = true, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + Height = header_height + ModSelectPanel.CORNER_RADIUS, + Children = new Drawable[] + { + headerBackground = new Box + { + RelativeSizeAxes = Axes.X, + Height = header_height + ModSelectPanel.CORNER_RADIUS + }, + triangles = new TrianglesV2 + { + RelativeSizeAxes = Axes.X, + Height = header_height, + Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), + Velocity = 0.7f, + }, + headerText = new OsuTextFlowContainer(t => + { + t.Font = OsuFont.TorusAlternate.With(size: 17); + t.Shadow = false; + t.Colour = Colour4.Black; + }) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), + Padding = new MarginPadding + { + Horizontal = 17, + Bottom = ModSelectPanel.CORNER_RADIUS + } + } + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = header_height }, + Child = contentContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = ModSelectPanel.CORNER_RADIUS, + BorderThickness = 3, + Children = new Drawable[] + { + contentBackground = new Box + { + RelativeSizeAxes = Axes.Both + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + ControlContainer = new Container + { + RelativeSizeAxes = Axes.X, + Padding = new MarginPadding { Horizontal = 14 } + } + }, + new Drawable[] + { + new OsuScrollContainer(Direction.Vertical) + { + RelativeSizeAxes = Axes.Both, + ClampExtension = 100, + ScrollbarOverlapsContent = false, + Child = ItemsFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0, 7), + Padding = new MarginPadding(7) + } + } + } + } + } + } + } + } + } + } + }; + } + + private void createHeaderText(LocalisableString text) + { + headerText.Clear(); + + int wordIndex = 0; + + ITextPart part = headerText.AddText(text, t => + { + if (wordIndex == 0) + t.Font = t.Font.With(weight: FontWeight.SemiBold); + wordIndex += 1; + }); + + // Reset the index so that if the parts are refreshed (e.g. through changes in localisation) the correct word is re-emboldened. + part.DrawablePartsRecreated += _ => wordIndex = 0; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + contentContainer.BorderColour = ColourInfo.GradientVertical(colourProvider.Background4, colourProvider.Background3); + contentBackground.Colour = colourProvider.Background4; + } + } +} diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index 4bad34d94f..16602db4be 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -1,36 +1,40 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Input.Events; using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Localisation; using osu.Game.Rulesets.Mods; using osu.Game.Utils; using osuTK; -using osuTK.Input; namespace osu.Game.Overlays.Mods { - public abstract class ModSelectOverlay : ShearedOverlayContainer, ISamplePlaybackDisabler + public abstract partial class ModSelectOverlay : ShearedOverlayContainer, ISamplePlaybackDisabler { public const int BUTTON_WIDTH = 200; + protected override string PopInSampleName => ""; + protected override string PopOutSampleName => @"SongSelect/mod-select-overlay-pop-out"; + [Cached] public Bindable> SelectedMods { get; private set; } = new Bindable>(Array.Empty()); @@ -43,7 +47,7 @@ namespace osu.Game.Overlays.Mods /// public Bindable>> AvailableMods { get; } = new Bindable>>(new Dictionary>()); - private Func isValidMod = m => true; + private Func isValidMod = _ => true; /// /// A function determining whether each mod in the column should be displayed. @@ -70,7 +74,12 @@ namespace osu.Game.Overlays.Mods /// protected virtual bool AllowCustomisation => true; - protected virtual ModColumn CreateModColumn(ModType modType, Key[]? toggleKeys = null) => new ModColumn(modType, false, toggleKeys); + /// + /// Whether the column with available mod presets should be shown. + /// + protected virtual bool ShowPresets => false; + + protected virtual ModColumn CreateModColumn(ModType modType) => new ModColumn(modType, false); protected virtual IReadOnlyList ComputeNewModsFromSelection(IReadOnlyList oldSelection, IReadOnlyList newSelection) => newSelection; @@ -78,7 +87,7 @@ namespace osu.Game.Overlays.Mods { if (AllowCustomisation) { - yield return customisationButton = new ShearedToggleButton(BUTTON_WIDTH) + yield return CustomisationButton = new ShearedToggleButton(BUTTON_WIDTH) { Text = ModSelectOverlayStrings.ModCustomisation, Active = { BindTarget = customisationVisible } @@ -98,11 +107,13 @@ namespace osu.Game.Overlays.Mods private ColumnScrollContainer columnScroll = null!; private ColumnFlowContainer columnFlow = null!; private FillFlowContainer footerButtonFlow = null!; - private ShearedButton backButton = null!; private DifficultyMultiplierDisplay? multiplierDisplay; - private ShearedToggleButton? customisationButton; + protected ShearedButton BackButton { get; private set; } = null!; + protected ShearedToggleButton? CustomisationButton { get; private set; } + + private Sample? columnAppearSample; protected ModSelectOverlay(OverlayColourScheme colourScheme = OverlayColourScheme.Green) : base(colourScheme) @@ -110,11 +121,13 @@ namespace osu.Game.Overlays.Mods } [BackgroundDependencyLoader] - private void load(OsuGameBase game, OsuColour colours) + private void load(OsuGameBase game, OsuColour colours, AudioManager audio) { Header.Title = ModSelectOverlayStrings.ModSelectTitle; Header.Description = ModSelectOverlayStrings.ModSelectDescription; + columnAppearSample = audio.Samples.Get(@"SongSelect/mod-column-pop-in"); + AddRange(new Drawable[] { new ClickToReturnContainer @@ -133,40 +146,37 @@ namespace osu.Game.Overlays.Mods MainAreaContent.AddRange(new Drawable[] { - new Container + new OsuContextMenuContainer { - Padding = new MarginPadding - { - Top = (ShowTotalMultiplier ? DifficultyMultiplierDisplay.HEIGHT : 0) + PADDING, - Bottom = PADDING - }, RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.Both, - Children = new Drawable[] + Child = new PopoverContainer { - columnScroll = new ColumnScrollContainer + Padding = new MarginPadding { - RelativeSizeAxes = Axes.Both, - Masking = false, - ClampExtension = 100, - ScrollbarOverlapsContent = false, - Child = columnFlow = new ColumnFlowContainer + Top = (ShowTotalMultiplier ? ModsEffectDisplay.HEIGHT : 0) + PADDING, + Bottom = PADDING + }, + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.Both, + Children = new Drawable[] + { + columnScroll = new ColumnScrollContainer { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Direction = FillDirection.Horizontal, - Shear = new Vector2(SHEAR, 0), - RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X, - Margin = new MarginPadding { Horizontal = 70 }, - Padding = new MarginPadding { Bottom = 10 }, - Children = new[] + RelativeSizeAxes = Axes.Both, + Masking = false, + ClampExtension = 100, + ScrollbarOverlapsContent = false, + Child = columnFlow = new ColumnFlowContainer { - createModColumnContent(ModType.DifficultyReduction, new[] { Key.Q, Key.W, Key.E, Key.R, Key.T, Key.Y, Key.U, Key.I, Key.O, Key.P }), - createModColumnContent(ModType.DifficultyIncrease, new[] { Key.A, Key.S, Key.D, Key.F, Key.G, Key.H, Key.J, Key.K, Key.L }), - createModColumnContent(ModType.Automation, new[] { Key.Z, Key.X, Key.C, Key.V, Key.B, Key.N, Key.M }), - createModColumnContent(ModType.Conversion), - createModColumnContent(ModType.Fun) + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Direction = FillDirection.Horizontal, + Shear = new Vector2(SHEAR, 0), + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Margin = new MarginPadding { Horizontal = 70 }, + Padding = new MarginPadding { Bottom = 10 }, + ChildrenEnumerable = createColumns() } } } @@ -181,7 +191,7 @@ namespace osu.Game.Overlays.Mods Anchor = Anchor.TopRight, Origin = Anchor.TopRight, AutoSizeAxes = Axes.X, - Height = DifficultyMultiplierDisplay.HEIGHT, + Height = ModsEffectDisplay.HEIGHT, Margin = new MarginPadding { Horizontal = 100 }, Child = multiplierDisplay = new DifficultyMultiplierDisplay { @@ -204,7 +214,7 @@ namespace osu.Game.Overlays.Mods Horizontal = 70 }, Spacing = new Vector2(10), - ChildrenEnumerable = CreateFooterButtons().Prepend(backButton = new ShearedButton(BUTTON_WIDTH) + ChildrenEnumerable = CreateFooterButtons().Prepend(BackButton = new ShearedButton(BUTTON_WIDTH) { Text = CommonStrings.Back, Action = Hide, @@ -216,6 +226,8 @@ namespace osu.Game.Overlays.Mods globalAvailableMods.BindTo(game.AvailableMods); } + private ModSettingChangeTracker? modSettingChangeTracker; + protected override void LoadComplete() { // this is called before base call so that the mod state is populated early, and the transition in `PopIn()` can play out properly. @@ -232,9 +244,17 @@ namespace osu.Game.Overlays.Mods SelectedMods.BindValueChanged(val => { + modSettingChangeTracker?.Dispose(); + updateMultiplier(); - updateCustomisation(val); updateFromExternalSelection(); + updateCustomisation(); + + if (AllowCustomisation) + { + modSettingChangeTracker = new ModSettingChangeTracker(val.NewValue); + modSettingChangeTracker.SettingChanged += _ => updateMultiplier(); + } }, true); customisationVisible.BindValueChanged(_ => updateCustomisationVisualState(), true); @@ -253,7 +273,7 @@ namespace osu.Game.Overlays.Mods /// public void SelectAll() { - foreach (var column in columnFlow.Columns) + foreach (var column in columnFlow.Columns.OfType()) column.SelectAll(); } @@ -262,24 +282,36 @@ namespace osu.Game.Overlays.Mods /// public void DeselectAll() { - foreach (var column in columnFlow.Columns) + foreach (var column in columnFlow.Columns.OfType()) column.DeselectAll(); } - private ColumnDimContainer createModColumnContent(ModType modType, Key[]? toggleKeys = null) + private IEnumerable createColumns() { - var column = CreateModColumn(modType, toggleKeys).With(column => + if (ShowPresets) + { + yield return new ColumnDimContainer(new ModPresetColumn + { + Margin = new MarginPadding { Right = 10 } + }); + } + + yield return createModColumnContent(ModType.DifficultyReduction); + yield return createModColumnContent(ModType.DifficultyIncrease); + yield return createModColumnContent(ModType.Automation); + yield return createModColumnContent(ModType.Conversion); + yield return createModColumnContent(ModType.Fun); + } + + private ColumnDimContainer createModColumnContent(ModType modType) + { + var column = CreateModColumn(modType).With(column => { // spacing applied here rather than via `columnFlow.Spacing` to avoid uneven gaps when some of the columns are hidden. column.Margin = new MarginPadding { Right = 10 }; }); - return new ColumnDimContainer(column) - { - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - RequestScroll = col => columnScroll.ScrollIntoView(col, extraScroll: 140), - }; + return new ColumnDimContainer(column); } private void createLocalMods() @@ -301,7 +333,7 @@ namespace osu.Game.Overlays.Mods AvailableMods.Value = newLocalAvailableMods; filterMods(); - foreach (var column in columnFlow.Columns) + foreach (var column in columnFlow.Columns.OfType()) column.AvailableMods = AvailableMods.Value.GetValueOrDefault(column.ModType, Array.Empty()); } @@ -324,25 +356,26 @@ namespace osu.Game.Overlays.Mods multiplierDisplay.Current.Value = multiplier; } - private void updateCustomisation(ValueChangedEvent> valueChangedEvent) + private void updateCustomisation() { - if (customisationButton == null) + if (CustomisationButton == null) return; - bool anyCustomisableMod = false; - bool anyModWithRequiredCustomisationAdded = false; + bool anyCustomisableModActive = false; + bool anyModPendingConfiguration = false; - foreach (var mod in SelectedMods.Value) + foreach (var modState in allAvailableMods) { - anyCustomisableMod |= mod.GetSettingsSourceProperties().Any(); - anyModWithRequiredCustomisationAdded |= valueChangedEvent.OldValue.All(m => m.GetType() != mod.GetType()) && mod.RequiresConfiguration; + anyCustomisableModActive |= modState.Active.Value && modState.Mod.GetSettingsSourceProperties().Any(); + anyModPendingConfiguration |= modState.PendingConfiguration; + modState.PendingConfiguration = false; } - if (anyCustomisableMod) + if (anyCustomisableModActive) { customisationVisible.Disabled = false; - if (anyModWithRequiredCustomisationAdded && !customisationVisible.Value) + if (anyModPendingConfiguration && !customisationVisible.Value) customisationVisible.Value = true; } else @@ -362,7 +395,7 @@ namespace osu.Game.Overlays.Mods foreach (var button in footerButtonFlow) { - if (button != customisationButton) + if (button != CustomisationButton) button.Enabled.Value = !customisationVisible.Value; } @@ -442,7 +475,7 @@ namespace osu.Game.Overlays.Mods { var column = columnFlow[i].Column; - bool allFiltered = column.AvailableMods.All(modState => modState.Filtered.Value); + bool allFiltered = column is ModColumn modColumn && modColumn.AvailableMods.All(modState => modState.Filtered.Value); double delay = allFiltered ? 0 : nonFilteredColumnCount * 30; double duration = allFiltered ? 0 : fade_in_duration; @@ -456,8 +489,31 @@ namespace osu.Game.Overlays.Mods .MoveToY(0, duration, Easing.OutQuint) .FadeIn(duration, Easing.OutQuint); - if (!allFiltered) - nonFilteredColumnCount += 1; + if (allFiltered) + continue; + + int columnNumber = nonFilteredColumnCount; + Scheduler.AddDelayed(() => + { + var channel = columnAppearSample?.GetChannel(); + if (channel == null) return; + + // Still play sound effects for off-screen columns up to a certain point. + if (columnNumber > 5 && !column.Active.Value) return; + + // use X position of the column on screen as a basis for panning the sample + float balance = column.Parent.BoundingBox.Centre.X / RelativeToAbsoluteFactor.X; + + // dip frequency and ramp volume of sample over the first 5 displayed columns + float progress = Math.Min(1, columnNumber / 5f); + + channel.Frequency.Value = 1.3 - (progress * 0.3) + RNG.NextDouble(0.1); + channel.Volume.Value = Math.Max(progress, 0.2); + channel.Balance.Value = -1 + balance * 2; + channel.Play(); + }, delay); + + nonFilteredColumnCount += 1; } } @@ -477,14 +533,19 @@ namespace osu.Game.Overlays.Mods { var column = columnFlow[i].Column; - bool allFiltered = column.AvailableMods.All(modState => modState.Filtered.Value); + bool allFiltered = false; + + if (column is ModColumn modColumn) + { + allFiltered = modColumn.AvailableMods.All(modState => modState.Filtered.Value); + modColumn.FlushPendingSelections(); + } double duration = allFiltered ? 0 : fade_out_duration; float newYPosition = 0; if (!allFiltered) newYPosition = nonFilteredColumnCount % 2 == 0 ? -distance : distance; - column.FlushPendingSelections(); column.TopLevelContent .MoveToY(newYPosition, duration, Easing.OutQuint) .FadeOut(duration, Easing.OutQuint); @@ -527,14 +588,14 @@ namespace osu.Game.Overlays.Mods { if (customisationVisible.Value) { - Debug.Assert(customisationButton != null); - customisationButton.TriggerClick(); + Debug.Assert(CustomisationButton != null); + CustomisationButton.TriggerClick(); if (!immediate) return; } - backButton.TriggerClick(); + BackButton.TriggerClick(); } } @@ -550,7 +611,8 @@ namespace osu.Game.Overlays.Mods /// /// Manages horizontal scrolling of mod columns, along with the "active" states of each column based on visibility. /// - internal class ColumnScrollContainer : OsuScrollContainer + [Cached] + internal partial class ColumnScrollContainer : OsuScrollContainer { public ColumnScrollContainer() : base(Direction.Horizontal) @@ -591,9 +653,9 @@ namespace osu.Game.Overlays.Mods /// /// Manages layout of mod columns. /// - internal class ColumnFlowContainer : FillFlowContainer + internal partial class ColumnFlowContainer : FillFlowContainer { - public IEnumerable Columns => Children.Select(dimWrapper => dimWrapper.Column); + public IEnumerable Columns => Children.Select(dimWrapper => dimWrapper.Column); public override void Add(ColumnDimContainer dimContainer) { @@ -607,9 +669,9 @@ namespace osu.Game.Overlays.Mods /// /// Encapsulates a column and provides dim and input blocking based on an externally managed "active" state. /// - internal class ColumnDimContainer : Container + internal partial class ColumnDimContainer : Container { - public ModColumn Column { get; } + public ModSelectColumn Column { get; } /// /// Tracks whether this column is in an interactive state. Generally only the case when the column is on-screen. @@ -624,12 +686,21 @@ namespace osu.Game.Overlays.Mods [Resolved] private OsuColour colours { get; set; } = null!; - public ColumnDimContainer(ModColumn column) + public ColumnDimContainer(ModSelectColumn column) { + AutoSizeAxes = Axes.X; + RelativeSizeAxes = Axes.Y; + Child = Column = column; column.Active.BindTo(Active); } + [BackgroundDependencyLoader] + private void load(ColumnScrollContainer columnScroll) + { + RequestScroll = col => columnScroll.ScrollIntoView(col, extraScroll: 140); + } + protected override void LoadComplete() { base.LoadComplete(); @@ -638,7 +709,18 @@ namespace osu.Game.Overlays.Mods FinishTransforms(); } - protected override bool RequiresChildrenUpdate => base.RequiresChildrenUpdate || Column.SelectionAnimationRunning; + protected override bool RequiresChildrenUpdate + { + get + { + bool result = base.RequiresChildrenUpdate; + + if (Column is ModColumn modColumn) + result |= !modColumn.ItemsLoaded || modColumn.SelectionAnimationRunning; + + return result; + } + } private void updateState() { @@ -677,7 +759,7 @@ namespace osu.Game.Overlays.Mods /// /// A container which blocks and handles input, managing the "return from customisation" state change. /// - private class ClickToReturnContainer : Container + private partial class ClickToReturnContainer : Container { public BindableBool HandleMouse { get; } = new BindableBool(); @@ -692,11 +774,11 @@ namespace osu.Game.Overlays.Mods switch (e) { - case ClickEvent _: + case ClickEvent: OnClicked?.Invoke(); return true; - case MouseEvent _: + case MouseEvent: return true; } diff --git a/osu.Game/Overlays/Mods/ModSelectPanel.cs b/osu.Game/Overlays/Mods/ModSelectPanel.cs new file mode 100644 index 0000000000..81285833bd --- /dev/null +++ b/osu.Game/Overlays/Mods/ModSelectPanel.cs @@ -0,0 +1,267 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Framework.Utils; +using osu.Game.Audio; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; +using osuTK.Graphics; +using osuTK.Input; + +namespace osu.Game.Overlays.Mods +{ + public abstract partial class ModSelectPanel : OsuClickableContainer, IHasAccentColour + { + public abstract BindableBool Active { get; } + + public Color4 AccentColour { get; set; } + + public LocalisableString Title + { + get => titleText.Text; + set => titleText.Text = value; + } + + public LocalisableString Description + { + get => descriptionText.Text; + set => descriptionText.Text = value; + } + + public const float CORNER_RADIUS = 7; + public const float HEIGHT = 42; + + protected virtual float IdleSwitchWidth => 14; + protected virtual float ExpandedSwitchWidth => 30; + protected virtual Colour4 BackgroundColour => Active.Value ? AccentColour.Darken(0.3f) : ColourProvider.Background3; + protected virtual Colour4 ForegroundColour => Active.Value ? AccentColour : ColourProvider.Background2; + protected virtual Colour4 TextColour => Active.Value ? ColourProvider.Background6 : Colour4.White; + + protected const double TRANSITION_DURATION = 150; + + protected readonly Box Background; + protected readonly Container SwitchContainer; + protected readonly Container MainContentContainer; + protected readonly Box TextBackground; + protected readonly FillFlowContainer TextFlow; + + [Resolved] + protected OverlayColourProvider ColourProvider { get; private set; } = null!; + + private readonly OsuSpriteText titleText; + private readonly OsuSpriteText descriptionText; + + private readonly Bindable samplePlaybackDisabled = new BindableBool(); + private Sample? sampleOff; + private Sample? sampleOn; + + protected ModSelectPanel() + { + RelativeSizeAxes = Axes.X; + Height = HEIGHT; + + // all below properties are applied to `Content` rather than the `ModPanel` in its entirety + // to allow external components to set these properties on the panel without affecting + // its "internal" appearance. + Content.Masking = true; + Content.CornerRadius = CORNER_RADIUS; + Content.BorderThickness = 2; + + Shear = new Vector2(ShearedOverlayContainer.SHEAR, 0); + + Children = new Drawable[] + { + Background = new Box + { + RelativeSizeAxes = Axes.Both + }, + SwitchContainer = new Container + { + RelativeSizeAxes = Axes.Y, + }, + MainContentContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = CORNER_RADIUS, + Children = new Drawable[] + { + TextBackground = new Box + { + RelativeSizeAxes = Axes.Both + }, + TextFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Horizontal = 17.5f, + Vertical = 4 + }, + Direction = FillDirection.Vertical, + Children = new[] + { + titleText = new OsuSpriteText + { + Font = OsuFont.TorusAlternate.With(size: 18, weight: FontWeight.SemiBold), + RelativeSizeAxes = Axes.X, + Truncate = true, + Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), + Margin = new MarginPadding + { + Left = -18 * ShearedOverlayContainer.SHEAR + } + }, + descriptionText = new OsuSpriteText + { + Font = OsuFont.Default.With(size: 12), + RelativeSizeAxes = Axes.X, + Truncate = true, + Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0) + } + } + } + } + } + } + }; + + Action = () => + { + if (!Active.Value) + Select(); + else + Deselect(); + }; + } + + /// + /// Performs all actions necessary to select this . + /// + protected abstract void Select(); + + /// + /// Performs all actions necessary to deselect this . + /// + protected abstract void Deselect(); + + [BackgroundDependencyLoader] + private void load(AudioManager audio, ISamplePlaybackDisabler? samplePlaybackDisabler) + { + sampleOn = audio.Samples.Get(@"UI/check-on"); + sampleOff = audio.Samples.Get(@"UI/check-off"); + + if (samplePlaybackDisabler != null) + ((IBindable)samplePlaybackDisabled).BindTo(samplePlaybackDisabler.SamplePlaybackDisabled); + } + + protected sealed override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(sampleSet); + + protected override void LoadComplete() + { + base.LoadComplete(); + Active.BindValueChanged(_ => + { + playStateChangeSamples(); + UpdateState(); + }); + + UpdateState(); + FinishTransforms(true); + } + + private void playStateChangeSamples() + { + if (samplePlaybackDisabled.Value) + return; + + if (Active.Value) + sampleOn?.Play(); + else + sampleOff?.Play(); + } + + protected override bool OnHover(HoverEvent e) + { + UpdateState(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + UpdateState(); + base.OnHoverLost(e); + } + + private bool mouseDown; + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button == MouseButton.Left) + mouseDown = true; + + UpdateState(); + return false; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + mouseDown = false; + + UpdateState(); + base.OnMouseUp(e); + } + + protected virtual void UpdateState() + { + float targetWidth = Active.Value ? ExpandedSwitchWidth : IdleSwitchWidth; + double transitionDuration = TRANSITION_DURATION; + + Colour4 backgroundColour = BackgroundColour; + Colour4 foregroundColour = ForegroundColour; + Colour4 textColour = TextColour; + + // Hover affects colour of button background + if (IsHovered) + { + backgroundColour = backgroundColour.Lighten(0.1f); + foregroundColour = foregroundColour.Lighten(0.1f); + } + + // Mouse down adds a halfway tween of the movement + if (mouseDown) + { + targetWidth = (float)Interpolation.Lerp(IdleSwitchWidth, ExpandedSwitchWidth, 0.5f); + transitionDuration *= 4; + } + + Content.TransformTo(nameof(BorderColour), ColourInfo.GradientVertical(backgroundColour, foregroundColour), transitionDuration, Easing.OutQuint); + Background.FadeColour(backgroundColour, transitionDuration, Easing.OutQuint); + SwitchContainer.ResizeWidthTo(targetWidth, transitionDuration, Easing.OutQuint); + MainContentContainer.TransformTo(nameof(Padding), new MarginPadding + { + Left = targetWidth, + Right = CORNER_RADIUS + }, transitionDuration, Easing.OutQuint); + TextBackground.FadeColour(foregroundColour, transitionDuration, Easing.OutQuint); + TextFlow.FadeColour(textColour, transitionDuration, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Overlays/Mods/ModSettingsArea.cs b/osu.Game/Overlays/Mods/ModSettingsArea.cs index f44e4bf07f..f11fef1299 100644 --- a/osu.Game/Overlays/Mods/ModSettingsArea.cs +++ b/osu.Game/Overlays/Mods/ModSettingsArea.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -20,7 +22,7 @@ using osuTK; namespace osu.Game.Overlays.Mods { - public class ModSettingsArea : CompositeDrawable + public partial class ModSettingsArea : CompositeDrawable { public Bindable> SelectedMods { get; } = new Bindable>(Array.Empty()); @@ -108,7 +110,7 @@ namespace osu.Game.Overlays.Mods protected override bool OnMouseDown(MouseDownEvent e) => true; protected override bool OnHover(HoverEvent e) => true; - private class ModSettingsColumn : CompositeDrawable + private partial class ModSettingsColumn : CompositeDrawable { public ModSettingsColumn(Mod mod, IEnumerable settingsControls) { diff --git a/osu.Game/Overlays/Mods/ModState.cs b/osu.Game/Overlays/Mods/ModState.cs index 8fdd5db00b..3ee890e876 100644 --- a/osu.Game/Overlays/Mods/ModState.cs +++ b/osu.Game/Overlays/Mods/ModState.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Game.Rulesets.Mods; @@ -22,6 +24,13 @@ namespace osu.Game.Overlays.Mods /// public BindableBool Active { get; } = new BindableBool(); + /// + /// Whether the mod requires further customisation. + /// This flag is read by the to determine if the customisation panel should be opened after a mod change + /// and cleared after reading. + /// + public bool PendingConfiguration { get; set; } + /// /// Whether the mod is currently filtered out due to not matching imposed criteria. /// diff --git a/osu.Game/Overlays/Mods/ModsEffectDisplay.cs b/osu.Game/Overlays/Mods/ModsEffectDisplay.cs new file mode 100644 index 0000000000..3f31736ee1 --- /dev/null +++ b/osu.Game/Overlays/Mods/ModsEffectDisplay.cs @@ -0,0 +1,223 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Mods; +using osuTK; + +namespace osu.Game.Overlays.Mods +{ + /// + /// Base class for displays of mods effects. + /// + public abstract partial class ModsEffectDisplay : Container, IHasCurrentValue + { + public const float HEIGHT = 42; + private const float transition_duration = 200; + + private readonly Box contentBackground; + private readonly Box labelBackground; + private readonly FillFlowContainer content; + + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + /// + /// Text to display in the left area of the display. + /// + protected abstract LocalisableString Label { get; } + + protected virtual float ValueAreaWidth => 56; + + protected virtual string CounterFormat => @"N0"; + + protected override Container Content => content; + + protected readonly RollingCounter Counter; + + protected ModsEffectDisplay() + { + Height = HEIGHT; + AutoSizeAxes = Axes.X; + + InternalChild = new InputBlockingContainer + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Masking = true, + CornerRadius = ModSelectPanel.CORNER_RADIUS, + Shear = new Vector2(ShearedOverlayContainer.SHEAR, 0), + Children = new Drawable[] + { + contentBackground = new Box + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Y, + Width = ValueAreaWidth + ModSelectPanel.CORNER_RADIUS + }, + new GridContainer + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, ValueAreaWidth) + }, + Content = new[] + { + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Masking = true, + CornerRadius = ModSelectPanel.CORNER_RADIUS, + Children = new Drawable[] + { + labelBackground = new Box + { + RelativeSizeAxes = Axes.Both + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding { Horizontal = 18 }, + Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), + Text = Label, + Font = OsuFont.Default.With(size: 17, weight: FontWeight.SemiBold) + } + } + }, + content = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Horizontal, + Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), + Spacing = new Vector2(2, 0), + Child = Counter = new EffectCounter(CounterFormat) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Current = { BindTarget = Current } + } + } + } + } + } + } + }; + } + + [BackgroundDependencyLoader] + private void load() + { + labelBackground.Colour = colourProvider.Background4; + } + + protected override void LoadComplete() + { + Current.BindValueChanged(e => + { + var effect = CalculateEffectForComparison(e.NewValue.CompareTo(Current.Default)); + setColours(effect); + }, true); + } + + /// + /// Fades colours of text and its background according to displayed value. + /// + /// Effect of the value. + private void setColours(ModEffect effect) + { + switch (effect) + { + case ModEffect.NotChanged: + contentBackground.FadeColour(colourProvider.Background3, transition_duration, Easing.OutQuint); + content.FadeColour(Colour4.White, transition_duration, Easing.OutQuint); + break; + + case ModEffect.DifficultyReduction: + contentBackground.FadeColour(colours.ForModType(ModType.DifficultyReduction), transition_duration, Easing.OutQuint); + content.FadeColour(colourProvider.Background5, transition_duration, Easing.OutQuint); + break; + + case ModEffect.DifficultyIncrease: + contentBackground.FadeColour(colours.ForModType(ModType.DifficultyIncrease), transition_duration, Easing.OutQuint); + content.FadeColour(colourProvider.Background5, transition_duration, Easing.OutQuint); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(effect)); + } + } + + /// + /// Converts signed integer into . Negative values are counted as difficulty reduction, positive as increase. + /// + /// Value to convert. Will arrive from comparison between bindable once it changes and it's . + /// Effect of the value. + protected virtual ModEffect CalculateEffectForComparison(int comparison) + { + if (comparison == 0) + return ModEffect.NotChanged; + if (comparison < 0) + return ModEffect.DifficultyReduction; + + return ModEffect.DifficultyIncrease; + } + + protected enum ModEffect + { + NotChanged, + DifficultyReduction, + DifficultyIncrease + } + + private partial class EffectCounter : RollingCounter + { + private readonly string? format; + + public EffectCounter(string? format) + { + this.format = format; + } + + protected override double RollingDuration => 500; + + protected override LocalisableString FormatCount(double count) => count.ToLocalisableString(format); + + protected override OsuSpriteText CreateSpriteText() => new OsuSpriteText + { + Font = OsuFont.Default.With(size: 17, weight: FontWeight.SemiBold) + }; + } + } +} diff --git a/osu.Game/Overlays/Mods/SelectAllModsButton.cs b/osu.Game/Overlays/Mods/SelectAllModsButton.cs index f7078b2fa5..f4b8025227 100644 --- a/osu.Game/Overlays/Mods/SelectAllModsButton.cs +++ b/osu.Game/Overlays/Mods/SelectAllModsButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Bindables; @@ -14,7 +16,7 @@ using osu.Game.Screens.OnlinePlay; namespace osu.Game.Overlays.Mods { - public class SelectAllModsButton : ShearedButton, IKeyBindingHandler + public partial class SelectAllModsButton : ShearedButton, IKeyBindingHandler { private readonly Bindable> selectedMods = new Bindable>(); private readonly Bindable>> availableMods = new Bindable>>(); diff --git a/osu.Game/Overlays/Mods/ShearedOverlayContainer.cs b/osu.Game/Overlays/Mods/ShearedOverlayContainer.cs index 92e88bfaaf..7f7b09a62c 100644 --- a/osu.Game/Overlays/Mods/ShearedOverlayContainer.cs +++ b/osu.Game/Overlays/Mods/ShearedOverlayContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -16,7 +18,7 @@ namespace osu.Game.Overlays.Mods /// A sheared overlay which provides a header and footer and basic animations. /// Exposes , and as valid targets for content. /// - public abstract class ShearedOverlayContainer : OsuFocusedOverlayContainer + public abstract partial class ShearedOverlayContainer : OsuFocusedOverlayContainer { protected const float PADDING = 14; diff --git a/osu.Game/Overlays/Mods/UserModSelectOverlay.cs b/osu.Game/Overlays/Mods/UserModSelectOverlay.cs index 7100446730..49469b99f3 100644 --- a/osu.Game/Overlays/Mods/UserModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/UserModSelectOverlay.cs @@ -3,21 +3,19 @@ using System.Collections.Generic; using System.Linq; -using JetBrains.Annotations; using osu.Game.Rulesets.Mods; using osu.Game.Utils; -using osuTK.Input; namespace osu.Game.Overlays.Mods { - public class UserModSelectOverlay : ModSelectOverlay + public partial class UserModSelectOverlay : ModSelectOverlay { public UserModSelectOverlay(OverlayColourScheme colourScheme = OverlayColourScheme.Green) : base(colourScheme) { } - protected override ModColumn CreateModColumn(ModType modType, Key[] toggleKeys = null) => new UserModColumn(modType, false, toggleKeys); + protected override ModColumn CreateModColumn(ModType modType) => new UserModColumn(modType, false); protected override IReadOnlyList ComputeNewModsFromSelection(IReadOnlyList oldSelection, IReadOnlyList newSelection) { @@ -40,10 +38,10 @@ namespace osu.Game.Overlays.Mods return modsAfterRemoval.ToList(); } - private class UserModColumn : ModColumn + private partial class UserModColumn : ModColumn { - public UserModColumn(ModType modType, bool allowBulkSelection, [CanBeNull] Key[] toggleKeys = null) - : base(modType, allowBulkSelection, toggleKeys) + public UserModColumn(ModType modType, bool allowIncompatibleSelection) + : base(modType, allowIncompatibleSelection) { } diff --git a/osu.Game/Overlays/Music/FilterControl.cs b/osu.Game/Overlays/Music/FilterControl.cs index 46c66b4bae..a61702645a 100644 --- a/osu.Game/Overlays/Music/FilterControl.cs +++ b/osu.Game/Overlays/Music/FilterControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; @@ -11,12 +13,12 @@ using osu.Framework.Allocation; namespace osu.Game.Overlays.Music { - public class FilterControl : Container + public partial class FilterControl : Container { public Action FilterChanged; public readonly FilterTextBox Search; - private readonly CollectionDropdown collectionDropdown; + private readonly NowPlayingCollectionDropdown collectionDropdown; public FilterControl() { @@ -34,7 +36,7 @@ namespace osu.Game.Overlays.Music RelativeSizeAxes = Axes.X, Height = 40, }, - collectionDropdown = new CollectionDropdown { RelativeSizeAxes = Axes.X } + collectionDropdown = new NowPlayingCollectionDropdown { RelativeSizeAxes = Axes.X } }, }, }; @@ -56,7 +58,7 @@ namespace osu.Game.Overlays.Music Collection = collectionDropdown.Current.Value?.Collection }; - public class FilterTextBox : BasicSearchTextBox + public partial class FilterTextBox : BasicSearchTextBox { protected override bool AllowCommit => true; diff --git a/osu.Game/Overlays/Music/FilterCriteria.cs b/osu.Game/Overlays/Music/FilterCriteria.cs index f15edff4d0..ad491be845 100644 --- a/osu.Game/Overlays/Music/FilterCriteria.cs +++ b/osu.Game/Overlays/Music/FilterCriteria.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using osu.Game.Collections; +using osu.Game.Database; namespace osu.Game.Overlays.Music { @@ -17,6 +20,6 @@ namespace osu.Game.Overlays.Music /// The collection to filter beatmaps from. /// [CanBeNull] - public BeatmapCollection Collection; + public Live Collection; } } diff --git a/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs b/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs index 6b33c9200e..827caf0467 100644 --- a/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs +++ b/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.LocalisationExtensions; @@ -19,7 +21,7 @@ namespace osu.Game.Overlays.Music /// /// Handles s related to music playback, and displays s via the global accordingly. /// - public class MusicKeyBindingHandler : Component, IKeyBindingHandler + public partial class MusicKeyBindingHandler : Component, IKeyBindingHandler { [Resolved] private IBindable beatmap { get; set; } @@ -87,7 +89,7 @@ namespace osu.Game.Overlays.Music { } - private class MusicActionToast : Toast + private partial class MusicActionToast : Toast { private readonly GlobalAction action; diff --git a/osu.Game/Overlays/Music/CollectionDropdown.cs b/osu.Game/Overlays/Music/NowPlayingCollectionDropdown.cs similarity index 85% rename from osu.Game/Overlays/Music/CollectionDropdown.cs rename to osu.Game/Overlays/Music/NowPlayingCollectionDropdown.cs index 658eebe67b..ae59fbb35e 100644 --- a/osu.Game/Overlays/Music/CollectionDropdown.cs +++ b/osu.Game/Overlays/Music/NowPlayingCollectionDropdown.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK; using osuTK.Graphics; using osu.Framework.Allocation; @@ -13,9 +15,9 @@ using osu.Game.Graphics; namespace osu.Game.Overlays.Music { /// - /// A for use in the . + /// A for use in the . /// - public class CollectionDropdown : CollectionFilterDropdown + public partial class NowPlayingCollectionDropdown : CollectionDropdown { protected override bool ShowManageCollectionsItem => false; @@ -23,7 +25,7 @@ namespace osu.Game.Overlays.Music protected override CollectionDropdownMenu CreateCollectionMenu() => new CollectionsMenu(); - private class CollectionsMenu : CollectionDropdownMenu + private partial class CollectionsMenu : CollectionDropdownMenu { public CollectionsMenu() { @@ -40,7 +42,7 @@ namespace osu.Game.Overlays.Music } } - private class CollectionsHeader : CollectionDropdownHeader + private partial class CollectionsHeader : CollectionDropdownHeader { [BackgroundDependencyLoader] private void load(OsuColour colours) diff --git a/osu.Game/Overlays/Music/Playlist.cs b/osu.Game/Overlays/Music/Playlist.cs index 24d867141c..ab51ca7e1d 100644 --- a/osu.Game/Overlays/Music/Playlist.cs +++ b/osu.Game/Overlays/Music/Playlist.cs @@ -13,35 +13,49 @@ using osuTK; namespace osu.Game.Overlays.Music { - public class Playlist : OsuRearrangeableListContainer> + public partial class Playlist : OsuRearrangeableListContainer> { - public Action> RequestSelection; + public Action>? RequestSelection; public readonly Bindable> SelectedSet = new Bindable>(); + private FilterCriteria currentCriteria = new FilterCriteria(); + public new MarginPadding Padding { get => base.Padding; set => base.Padding = value; } + protected override void OnItemsChanged() + { + base.OnItemsChanged(); + Filter(currentCriteria); + } + public void Filter(FilterCriteria criteria) { var items = (SearchContainer>>)ListContainer; + string[]? currentCollectionHashes = criteria.Collection?.PerformRead(c => c.BeatmapMD5Hashes.ToArray()); + foreach (var item in items.OfType()) - item.InSelectedCollection = criteria.Collection?.Beatmaps.Any(b => item.Model.ID == b.BeatmapSet?.ID) ?? true; + { + item.InSelectedCollection = currentCollectionHashes == null || item.Model.Value.Beatmaps.Select(b => b.MD5Hash).Any(currentCollectionHashes.Contains); + } items.SearchTerm = criteria.SearchText; + currentCriteria = criteria; } - public Live FirstVisibleSet => Items.FirstOrDefault(i => ((PlaylistItem)ItemMap[i]).MatchingFilter); + public Live? FirstVisibleSet => Items.FirstOrDefault(i => ((PlaylistItem)ItemMap[i]).MatchingFilter); - protected override OsuRearrangeableListItem> CreateOsuDrawable(Live item) => new PlaylistItem(item) - { - SelectedSet = { BindTarget = SelectedSet }, - RequestSelection = set => RequestSelection?.Invoke(set) - }; + protected override OsuRearrangeableListItem> CreateOsuDrawable(Live item) => + new PlaylistItem(item) + { + SelectedSet = { BindTarget = SelectedSet }, + RequestSelection = set => RequestSelection?.Invoke(set) + }; protected override FillFlowContainer>> CreateListFillFlowContainer() => new SearchContainer>> { diff --git a/osu.Game/Overlays/Music/PlaylistItem.cs b/osu.Game/Overlays/Music/PlaylistItem.cs index 82599d3ec9..00c5ce8002 100644 --- a/osu.Game/Overlays/Music/PlaylistItem.cs +++ b/osu.Game/Overlays/Music/PlaylistItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -18,7 +20,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Music { - public class PlaylistItem : OsuRearrangeableListItem>, IFilterable + public partial class PlaylistItem : OsuRearrangeableListItem>, IFilterable { public readonly Bindable> SelectedSet = new Bindable>(); diff --git a/osu.Game/Overlays/Music/PlaylistOverlay.cs b/osu.Game/Overlays/Music/PlaylistOverlay.cs index ce816f84f0..43b9024303 100644 --- a/osu.Game/Overlays/Music/PlaylistOverlay.cs +++ b/osu.Game/Overlays/Music/PlaylistOverlay.cs @@ -1,11 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; @@ -19,12 +22,10 @@ using Realms; namespace osu.Game.Overlays.Music { - public class PlaylistOverlay : VisibilityContainer + public partial class PlaylistOverlay : VisibilityContainer { private const float transition_duration = 600; - private const float playlist_height = 510; - - public IBindableList> BeatmapSets => beatmapSets; + public const float PLAYLIST_HEIGHT = 510; private readonly BindableList> beatmapSets = new BindableList>(); @@ -83,7 +84,7 @@ namespace osu.Game.Overlays.Music }, }; - filter.Search.OnCommit += (sender, newText) => + filter.Search.OnCommit += (_, _) => { list.FirstVisibleSet?.PerformRead(set => { @@ -102,9 +103,7 @@ namespace osu.Game.Overlays.Music { base.LoadComplete(); - // tests might bind externally, in which case we don't want to involve realm. - if (beatmapSets.Count == 0) - beatmapSubscription = realm.RegisterForNotifications(r => r.All().Where(s => !s.DeletePending), beatmapsChanged); + beatmapSubscription = realm.RegisterForNotifications(r => r.All().Where(s => !s.DeletePending), beatmapsChanged); list.Items.BindTo(beatmapSets); beatmap.BindValueChanged(working => list.SelectedSet.Value = working.NewValue.BeatmapSetInfo.ToLive(realm), true); @@ -132,7 +131,7 @@ namespace osu.Game.Overlays.Music filter.Search.HoldFocus = true; Schedule(() => filter.Search.TakeFocus()); - this.ResizeTo(new Vector2(1, playlist_height), transition_duration, Easing.OutQuint); + this.ResizeTo(new Vector2(1, RelativeSizeAxes.HasFlagFast(Axes.Y) ? 1f : PLAYLIST_HEIGHT), transition_duration, Easing.OutQuint); this.FadeIn(transition_duration, Easing.OutQuint); } diff --git a/osu.Game/Overlays/MusicController.cs b/osu.Game/Overlays/MusicController.cs index aa09ff6b97..1ad5a8c08b 100644 --- a/osu.Game/Overlays/MusicController.cs +++ b/osu.Game/Overlays/MusicController.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -12,6 +14,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Audio; using osu.Framework.Graphics.Containers; +using osu.Framework.Logging; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Database; @@ -22,7 +25,7 @@ namespace osu.Game.Overlays /// /// Handles playback of the global music track. /// - public class MusicController : CompositeDrawable + public partial class MusicController : CompositeDrawable { [Resolved] private BeatmapManager beatmaps { get; set; } @@ -55,19 +58,22 @@ namespace osu.Game.Overlays [Resolved] private RealmAccess realm { get; set; } - [BackgroundDependencyLoader] - private void load() + protected override void LoadComplete() { - // Todo: These binds really shouldn't be here, but are unlikely to cause any issues for now. - // They are placed here for now since some tests rely on setting the beatmap _and_ their hierarchies inside their load(), which runs before the MusicController's load(). - beatmap.BindValueChanged(beatmapChanged, true); + base.LoadComplete(); + + beatmap.BindValueChanged(b => changeBeatmap(b.NewValue), true); mods.BindValueChanged(_ => ResetTrackAdjustments(), true); } /// /// Forcefully reload the current 's track from disk. /// - public void ReloadCurrentTrack() => changeTrack(); + public void ReloadCurrentTrack() + { + changeTrack(); + TrackChanged?.Invoke(current, TrackChangeDirection.None); + } /// /// Returns whether the beatmap track is playing. @@ -104,10 +110,12 @@ namespace osu.Game.Overlays if (beatmap.Disabled) return; + Logger.Log($"{nameof(MusicController)} skipping next track to {nameof(EnsurePlayingSomething)}"); NextTrack(); } else if (!IsPlaying) { + Logger.Log($"{nameof(MusicController)} starting playback to {nameof(EnsurePlayingSomething)}"); Play(); } } @@ -128,9 +136,9 @@ namespace osu.Game.Overlays UserPauseRequested = false; if (restart) - CurrentTrack.Restart(); + CurrentTrack.RestartAsync(); else if (!IsPlaying) - CurrentTrack.Start(); + CurrentTrack.StartAsync(); return true; } @@ -147,7 +155,7 @@ namespace osu.Game.Overlays { UserPauseRequested |= requestedByUser; if (CurrentTrack.IsRunning) - CurrentTrack.Stop(); + CurrentTrack.StopAsync(); } /// @@ -245,7 +253,7 @@ namespace osu.Game.Overlays { // if not scheduled, the previously track will be stopped one frame later (see ScheduleAfterChildren logic in GameBase). // we probably want to move this to a central method for switching to a new working beatmap in the future. - Schedule(() => CurrentTrack.Restart()); + Schedule(() => CurrentTrack.RestartAsync()); } private WorkingBeatmap current; @@ -254,8 +262,6 @@ namespace osu.Game.Overlays private IQueryable getBeatmapSets() => realm.Realm.All().Where(s => !s.DeletePending); - private void beatmapChanged(ValueChangedEvent beatmap) => changeBeatmap(beatmap.NewValue); - private void changeBeatmap(WorkingBeatmap newWorking) { // This method can potentially be triggered multiple times as it is eagerly fired in next() / prev() to ensure correct execution order diff --git a/osu.Game/Overlays/News/Displays/ArticleListing.cs b/osu.Game/Overlays/News/Displays/ArticleListing.cs index dc3b17b323..4fc9dde156 100644 --- a/osu.Game/Overlays/News/Displays/ArticleListing.cs +++ b/osu.Game/Overlays/News/Displays/ArticleListing.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -17,7 +19,7 @@ namespace osu.Game.Overlays.News.Displays /// /// Lists articles in a vertical flow for a specified year. /// - public class ArticleListing : CompositeDrawable + public partial class ArticleListing : CompositeDrawable { private readonly Action fetchMorePosts; @@ -41,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 5ce0b9df9c..e0be5cc4a9 100644 --- a/osu.Game/Overlays/News/NewsCard.cs +++ b/osu.Game/Overlays/News/NewsCard.cs @@ -1,10 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -18,7 +21,7 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Overlays.News { - public class NewsCard : OsuHoverContainer + public partial class NewsCard : OsuHoverContainer { protected override IEnumerable EffectTargets => new[] { background }; @@ -46,7 +49,6 @@ namespace osu.Game.Overlays.News Action = () => host.OpenUrlExternally("https://osu.ppy.sh/home/news/" + post.Slug); } - NewsPostBackground bg; AddRange(new Drawable[] { background = new Box @@ -68,14 +70,14 @@ namespace osu.Game.Overlays.News CornerRadius = 6, Children = new Drawable[] { - new DelayedLoadWrapper(bg = new NewsPostBackground(post.FirstImage) + new DelayedLoadUnloadWrapper(() => new NewsPostBackground(post.FirstImage) { RelativeSizeAxes = Axes.Both, FillMode = FillMode.Fill, Anchor = Anchor.Centre, Origin = Anchor.Centre, Alpha = 0 - }) + }, timeBeforeUnload: 5000) { RelativeSizeAxes = Axes.Both }, @@ -113,15 +115,13 @@ namespace osu.Game.Overlays.News IdleColour = colourProvider.Background4; HoverColour = colourProvider.Background3; - bg.OnLoadComplete += d => d.FadeIn(250, Easing.In); - main.AddParagraph(post.Title, t => t.Font = OsuFont.GetFont(size: 20, weight: FontWeight.SemiBold)); main.AddParagraph(post.Preview, t => t.Font = OsuFont.GetFont(size: 12)); // Should use sans-serif font main.AddParagraph("by ", t => t.Font = OsuFont.GetFont(size: 12)); main.AddText(post.Author, t => t.Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold)); } - private class DateContainer : CircularContainer, IHasCustomTooltip + private partial class DateContainer : CircularContainer, IHasCustomTooltip { private readonly DateTimeOffset date; @@ -144,7 +144,7 @@ namespace osu.Game.Overlays.News }, new OsuSpriteText { - Text = date.ToString("d MMM yyyy").ToUpper(), + Text = date.ToLocalisableString(@"d MMM yyyy").ToUpper(), Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold), Margin = new MarginPadding { diff --git a/osu.Game/Overlays/News/NewsHeader.cs b/osu.Game/Overlays/News/NewsHeader.cs index 35e3c7755d..44e2f6a8cb 100644 --- a/osu.Game/Overlays/News/NewsHeader.cs +++ b/osu.Game/Overlays/News/NewsHeader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.News { - public class NewsHeader : BreadcrumbControlOverlayHeader + public partial class NewsHeader : BreadcrumbControlOverlayHeader { public static LocalisableString FrontPageString => NewsStrings.IndexTitleInfo; @@ -60,7 +62,7 @@ namespace osu.Game.Overlays.News protected override OverlayTitle CreateTitle() => new NewsHeaderTitle(); - private class NewsHeaderTitle : OverlayTitle + private partial class NewsHeaderTitle : OverlayTitle { public NewsHeaderTitle() { diff --git a/osu.Game/Overlays/News/NewsPostBackground.cs b/osu.Game/Overlays/News/NewsPostBackground.cs index 386ef7f669..05f8a639fa 100644 --- a/osu.Game/Overlays/News/NewsPostBackground.cs +++ b/osu.Game/Overlays/News/NewsPostBackground.cs @@ -1,14 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; namespace osu.Game.Overlays.News { [LongRunningLoad] - public class NewsPostBackground : Sprite + public partial class NewsPostBackground : Sprite { private readonly string sourceUrl; @@ -23,6 +26,12 @@ namespace osu.Game.Overlays.News Texture = store.Get(createUrl(sourceUrl)); } + protected override void LoadComplete() + { + base.LoadComplete(); + this.FadeInFromZero(500, Easing.OutQuint); + } + private string createUrl(string source) { if (string.IsNullOrEmpty(source)) diff --git a/osu.Game/Overlays/News/Sidebar/MonthSection.cs b/osu.Game/Overlays/News/Sidebar/MonthSection.cs index e2ce25660e..30d29048ba 100644 --- a/osu.Game/Overlays/News/Sidebar/MonthSection.cs +++ b/osu.Game/Overlays/News/Sidebar/MonthSection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; @@ -17,11 +19,12 @@ using osu.Framework.Graphics.Sprites; using System.Diagnostics; using osu.Framework.Audio; using osu.Framework.Audio.Sample; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Platform; namespace osu.Game.Overlays.News.Sidebar { - public class MonthSection : CompositeDrawable + public partial class MonthSection : CompositeDrawable { public int Year { get; private set; } public int Month { get; private set; } @@ -77,7 +80,7 @@ namespace osu.Game.Overlays.News.Sidebar sampleClose = audio.Samples.Get(@"UI/dropdown-close"); } - private class DropdownHeader : OsuClickableContainer + private partial class DropdownHeader : OsuClickableContainer { public readonly BindableBool Expanded = new BindableBool(); @@ -97,7 +100,7 @@ namespace osu.Game.Overlays.News.Sidebar Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), - Text = date.ToString("MMM yyyy") + Text = date.ToLocalisableString(@"MMM yyyy") }, icon = new SpriteIcon { @@ -120,7 +123,7 @@ namespace osu.Game.Overlays.News.Sidebar } } - private class PostButton : OsuHoverContainer + private partial class PostButton : OsuHoverContainer { protected override IEnumerable EffectTargets => new[] { text }; @@ -152,7 +155,7 @@ namespace osu.Game.Overlays.News.Sidebar } } - private class PostsContainer : Container + private partial class PostsContainer : Container { public readonly BindableBool Expanded = new BindableBool(); diff --git a/osu.Game/Overlays/News/Sidebar/NewsSidebar.cs b/osu.Game/Overlays/News/Sidebar/NewsSidebar.cs index 829fc5b3eb..939e5367b7 100644 --- a/osu.Game/Overlays/News/Sidebar/NewsSidebar.cs +++ b/osu.Game/Overlays/News/Sidebar/NewsSidebar.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; @@ -11,7 +13,7 @@ using System.Linq; namespace osu.Game.Overlays.News.Sidebar { - public class NewsSidebar : OverlaySidebar + public partial class NewsSidebar : OverlaySidebar { [Cached] public readonly Bindable Metadata = new Bindable(); diff --git a/osu.Game/Overlays/News/Sidebar/YearsPanel.cs b/osu.Game/Overlays/News/Sidebar/YearsPanel.cs index 58c0f6ac82..524ffbf63d 100644 --- a/osu.Game/Overlays/News/Sidebar/YearsPanel.cs +++ b/osu.Game/Overlays/News/Sidebar/YearsPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -15,7 +17,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.News.Sidebar { - public class YearsPanel : CompositeDrawable + public partial class YearsPanel : CompositeDrawable { private readonly Bindable metadata = new Bindable(); @@ -77,7 +79,7 @@ namespace osu.Game.Overlays.News.Sidebar Show(); } - public class YearButton : OsuHoverContainer + public partial class YearButton : OsuHoverContainer { public int Year { get; } diff --git a/osu.Game/Overlays/NewsOverlay.cs b/osu.Game/Overlays/NewsOverlay.cs index 12e3f81ca1..cb9d940a05 100644 --- a/osu.Game/Overlays/NewsOverlay.cs +++ b/osu.Game/Overlays/NewsOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Threading; using osu.Framework.Bindables; @@ -13,7 +15,7 @@ using osu.Game.Overlays.News.Sidebar; namespace osu.Game.Overlays { - public class NewsOverlay : OnlineOverlay + public partial class NewsOverlay : OnlineOverlay { private readonly Bindable article = new Bindable(); diff --git a/osu.Game/Overlays/NotificationOverlay.cs b/osu.Game/Overlays/NotificationOverlay.cs index f1ed5c4ba6..71a4c58afd 100644 --- a/osu.Game/Overlays/NotificationOverlay.cs +++ b/osu.Game/Overlays/NotificationOverlay.cs @@ -2,25 +2,25 @@ // See the LICENCE file in the repository root for full licence text. using System.Linq; -using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Overlays.Notifications; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics.Containers; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Framework.Threading; -using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Overlays.Notifications; using osu.Game.Resources.Localisation.Web; +using osuTK; using NotificationsStrings = osu.Game.Localisation.NotificationsStrings; namespace osu.Game.Overlays { - public class NotificationOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent, INotificationOverlay + public partial class NotificationOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent, INotificationOverlay { public string IconTexture => "Icons/Hexacons/notification"; public LocalisableString Title => NotificationsStrings.HeaderTitle; @@ -30,10 +30,30 @@ namespace osu.Game.Overlays public const float TRANSITION_LENGTH = 600; - private FlowContainer sections; + private FlowContainer sections = null!; [Resolved] - private AudioManager audio { get; set; } + private AudioManager audio { get; set; } = null!; + + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + { + if (State.Value == Visibility.Visible) + return base.ReceivePositionalInputAt(screenSpacePos); + + if (toastTray.IsDisplayingToasts) + return toastTray.ReceivePositionalInputAt(screenSpacePos); + + return false; + } + + public override bool PropagatePositionalInputSubTree => base.PropagatePositionalInputSubTree || toastTray.IsDisplayingToasts; + + private NotificationOverlayToastTray toastTray = null!; + + private Container mainContent = null!; [BackgroundDependencyLoader] private void load() @@ -44,40 +64,46 @@ namespace osu.Game.Overlays Children = new Drawable[] { - new Box + toastTray = new NotificationOverlayToastTray { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(0.05f), + ForwardNotificationToPermanentStore = addPermanently, + Origin = Anchor.TopRight, }, - new OsuScrollContainer + mainContent = new Container { - Masking = true, RelativeSizeAxes = Axes.Both, - Children = new[] + Children = new Drawable[] { - sections = new FillFlowContainer + new Box { - Direction = FillDirection.Vertical, - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + }, + new OsuScrollContainer + { + Masking = true, + RelativeSizeAxes = Axes.Both, Children = new[] { - new NotificationSection(AccountsStrings.NotificationsTitle, "Clear All") + sections = new FillFlowContainer { - AcceptTypes = new[] { typeof(SimpleNotification) } - }, - new NotificationSection(@"Running Tasks", @"Cancel All") - { - AcceptTypes = new[] { typeof(ProgressNotification) } + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Children = new[] + { + new NotificationSection(AccountsStrings.NotificationsTitle, new[] { typeof(SimpleNotification) }, NotificationsStrings.ClearAll), + new NotificationSection(NotificationsStrings.RunningTasks, new[] { typeof(ProgressNotification) }, NotificationsStrings.CancelAll), + } } } } } - } + }, }; } - private ScheduledDelegate notificationsEnabler; + private ScheduledDelegate? notificationsEnabler; private void updateProcessingMode() { @@ -87,28 +113,35 @@ namespace osu.Game.Overlays if (enabled) // we want a slight delay before toggling notifications on to avoid the user becoming overwhelmed. - notificationsEnabler = Scheduler.AddDelayed(() => processingPosts = true, State.Value == Visibility.Visible ? 0 : 1000); + notificationsEnabler = Scheduler.AddDelayed(() => processingPosts = true, State.Value == Visibility.Visible ? 0 : 250); else + { processingPosts = false; + toastTray.FlushAllToasts(); + } } protected override void LoadComplete() { base.LoadComplete(); - State.ValueChanged += _ => updateProcessingMode(); + State.BindValueChanged(_ => updateProcessingMode()); OverlayActivationMode.BindValueChanged(_ => updateProcessingMode(), true); } public IBindable UnreadCount => unreadCount; + public int ToastCount => toastTray.UnreadCount; + private readonly BindableInt unreadCount = new BindableInt(); private int runningDepth; private readonly Scheduler postScheduler = new Scheduler(); - public override bool IsPresent => base.IsPresent || postScheduler.HasPendingTasks; + public override bool IsPresent => + // Delegate presence as we need to consider the toast tray in addition to the main overlay. + State.Value == Visibility.Visible || mainContent.IsPresent || toastTray.IsPresent || postScheduler.HasPendingTasks; private bool processingPosts = true; @@ -125,18 +158,33 @@ namespace osu.Game.Overlays if (notification is IHasCompletionTarget hasCompletionTarget) hasCompletionTarget.CompletionTarget = Post; - var ourType = notification.GetType(); + playDebouncedSample(notification.PopInSampleName); - var section = sections.Children.FirstOrDefault(s => s.AcceptTypes.Any(accept => accept.IsAssignableFrom(ourType))); - section?.Add(notification, notification.DisplayOnTop ? -runningDepth : runningDepth); - - if (notification.IsImportant) - Show(); + if (State.Value == Visibility.Hidden) + { + notification.IsInToastTray = true; + toastTray.Post(notification); + } + else + addPermanently(notification); updateCounts(); - playDebouncedSample(notification.PopInSampleName); }); + private void addPermanently(Notification notification) + { + notification.IsInToastTray = false; + + var ourType = notification.GetType(); + int depth = notification.DisplayOnTop ? -runningDepth : runningDepth; + + var section = sections.Children.First(s => s.AcceptedNotificationTypes.Any(accept => accept.IsAssignableFrom(ourType))); + + section.Add(notification, depth); + + updateCounts(); + } + protected override void Update() { base.Update(); @@ -150,7 +198,9 @@ namespace osu.Game.Overlays base.PopIn(); this.MoveToX(0, TRANSITION_LENGTH, Easing.OutQuint); - this.FadeTo(1, TRANSITION_LENGTH, Easing.OutQuint); + mainContent.FadeTo(1, TRANSITION_LENGTH, Easing.OutQuint); + + toastTray.FlushAllToasts(); } protected override void PopOut() @@ -160,17 +210,17 @@ namespace osu.Game.Overlays markAllRead(); this.MoveToX(WIDTH, TRANSITION_LENGTH, Easing.OutQuint); - this.FadeTo(0, TRANSITION_LENGTH, Easing.OutQuint); + mainContent.FadeTo(0, TRANSITION_LENGTH, Easing.OutQuint); } - private void notificationClosed() + private void notificationClosed() => Schedule(() => { updateCounts(); // this debounce is currently shared between popin/popout sounds, which means one could potentially not play when the user is expecting it. // popout is constant across all notification types, and should therefore be handled using playback concurrency instead, but seems broken at the moment. playDebouncedSample("UI/overlay-pop-out"); - } + }); private void playDebouncedSample(string sampleName) { @@ -181,16 +231,16 @@ namespace osu.Game.Overlays } } - private void updateCounts() - { - unreadCount.Value = sections.Select(c => c.UnreadCount).Sum(); - } - private void markAllRead() { sections.Children.ForEach(s => s.MarkAllRead()); - + toastTray.MarkAllRead(); updateCounts(); } + + private void updateCounts() + { + unreadCount.Value = sections.Select(c => c.UnreadCount).Sum() + toastTray.UnreadCount; + } } } diff --git a/osu.Game/Overlays/NotificationOverlayToastTray.cs b/osu.Game/Overlays/NotificationOverlayToastTray.cs new file mode 100644 index 0000000000..7a793ee092 --- /dev/null +++ b/osu.Game/Overlays/NotificationOverlayToastTray.cs @@ -0,0 +1,167 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Overlays.Notifications; +using osuTK; + +namespace osu.Game.Overlays +{ + /// + /// A tray which attaches to the left of to show temporary toasts. + /// + public partial class NotificationOverlayToastTray : CompositeDrawable + { + public override bool IsPresent => toastContentBackground.Height > 0 || toastFlow.Count > 0; + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => toastFlow.ReceivePositionalInputAt(screenSpacePos); + + public bool IsDisplayingToasts => toastFlow.Count > 0; + + private FillFlowContainer toastFlow = null!; + private BufferedContainer toastContentBackground = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public Action? ForwardNotificationToPermanentStore { get; set; } + + public int UnreadCount => allDisplayedNotifications.Count(n => !n.WasClosed && !n.Read); + + /// + /// Notifications contained in the toast flow, or in a detached state while they animate during forwarding to the main overlay. + /// + private IEnumerable allDisplayedNotifications => toastFlow.Concat(InternalChildren.OfType()); + + private int runningDepth; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Padding = new MarginPadding(20); + + InternalChildren = new Drawable[] + { + toastContentBackground = (new Box + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Colour = ColourInfo.GradientVertical( + colourProvider.Background6.Opacity(0.7f), + colourProvider.Background6.Opacity(0.5f)), + RelativeSizeAxes = Axes.Both, + Height = 0, + }.WithEffect(new BlurEffect + { + PadExtent = true, + Sigma = new Vector2(20), + }).With(postEffectDrawable => + { + postEffectDrawable.Scale = new Vector2(1.5f, 1); + postEffectDrawable.Position += new Vector2(70, -50); + postEffectDrawable.AutoSizeAxes = Axes.None; + postEffectDrawable.RelativeSizeAxes = Axes.X; + })), + toastFlow = new FillFlowContainer + { + LayoutDuration = 150, + LayoutEasing = Easing.OutQuart, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + }; + } + + public void MarkAllRead() + { + toastFlow.Children.ForEach(n => n.Read = true); + InternalChildren.OfType().ForEach(n => n.Read = true); + } + + public void FlushAllToasts() + { + foreach (var notification in toastFlow.ToArray()) + forwardNotification(notification); + } + + public void Post(Notification notification) + { + ++runningDepth; + + notification.ForwardToOverlay = () => forwardNotification(notification); + + int depth = notification.DisplayOnTop ? -runningDepth : runningDepth; + + toastFlow.Insert(depth, notification); + + scheduleDismissal(); + + void scheduleDismissal() => Scheduler.AddDelayed(() => + { + // Notification dismissed by user. + if (notification.WasClosed) + return; + + // Notification forwarded away. + if (notification.Parent != toastFlow) + return; + + // Notification hovered; delay dismissal. + if (notification.IsHovered || notification.IsDragged) + { + scheduleDismissal(); + return; + } + + // All looks good, forward away! + forwardNotification(notification); + }, notification.IsImportant ? 12000 : 2500); + } + + private void forwardNotification(Notification notification) + { + if (!notification.IsInToastTray) + return; + + Debug.Assert(notification.Parent == toastFlow); + + // Temporarily remove from flow so we can animate the position off to the right. + toastFlow.Remove(notification, false); + AddInternal(notification); + + notification.MoveToOffset(new Vector2(400, 0), NotificationOverlay.TRANSITION_LENGTH, Easing.OutQuint); + notification.FadeOut(NotificationOverlay.TRANSITION_LENGTH, Easing.OutQuint).OnComplete(_ => + { + RemoveInternal(notification, false); + ForwardNotificationToPermanentStore?.Invoke(notification); + + notification.FadeIn(300, Easing.OutQuint); + }); + } + + protected override void Update() + { + base.Update(); + + float height = toastFlow.Count > 0 ? toastFlow.DrawHeight + 120 : 0; + float alpha = toastFlow.Count > 0 ? MathHelper.Clamp(toastFlow.DrawHeight / 41, 0, 1) * toastFlow.Children.Max(n => n.Alpha) : 0; + + toastContentBackground.Height = (float)Interpolation.DampContinuously(toastContentBackground.Height, height, 10, Clock.ElapsedFrameTime); + toastContentBackground.Alpha = (float)Interpolation.DampContinuously(toastContentBackground.Alpha, alpha, 10, Clock.ElapsedFrameTime); + } + } +} diff --git a/osu.Game/Overlays/Notifications/IHasCompletionTarget.cs b/osu.Game/Overlays/Notifications/IHasCompletionTarget.cs index 603f53cb53..e4aeb4d6ce 100644 --- a/osu.Game/Overlays/Notifications/IHasCompletionTarget.cs +++ b/osu.Game/Overlays/Notifications/IHasCompletionTarget.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; namespace osu.Game.Overlays.Notifications diff --git a/osu.Game/Overlays/Notifications/Notification.cs b/osu.Game/Overlays/Notifications/Notification.cs index ec6e9e09b3..77d3317b1f 100644 --- a/osu.Game/Overlays/Notifications/Notification.cs +++ b/osu.Game/Overlays/Notifications/Notification.cs @@ -2,29 +2,34 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osuTK; using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Overlays.Notifications { - public abstract class Notification : Container + public abstract partial class Notification : Container { /// - /// User requested close. + /// Notification was closed, either by user or otherwise. + /// Importantly, this event may be fired from a non-update thread. /// - public event Action Closed; + public event Action? Closed; public abstract LocalisableString Text { get; set; } @@ -36,7 +41,9 @@ namespace osu.Game.Overlays.Notifications /// /// Run on user activating the notification. Return true to close. /// - public Func Activated; + public Func? Activated; + + public Action? ForwardToOverlay { get; set; } /// /// Should we show at the top of our section on display? @@ -46,32 +53,81 @@ namespace osu.Game.Overlays.Notifications public virtual string PopInSampleName => "UI/notification-pop-in"; protected NotificationLight Light; - private readonly CloseButton closeButton; + protected Container IconContent; + + public bool WasClosed { get; private set; } + private readonly Container content; protected override Container Content => content; - protected Container NotificationContent; + protected Container MainContent; + + private readonly DragContainer dragContainer; public virtual bool Read { get; set; } + protected virtual bool AllowFlingDismiss => true; + + public new bool IsDragged => dragContainer.IsDragged; + + protected virtual IconUsage CloseButtonIcon => FontAwesome.Solid.Check; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public override bool PropagatePositionalInputSubTree => base.PropagatePositionalInputSubTree && !WasClosed; + + private bool isInToastTray; + + /// + /// Whether this notification is in the . + /// + public bool IsInToastTray + { + get => isInToastTray; + set + { + isInToastTray = value; + + if (!isInToastTray) + { + dragContainer.ResetPosition(); + if (!Read) + Light.FadeIn(100); + } + } + } + + private readonly Box initialFlash; + + private Box background = null!; + protected Notification() { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - AddRangeInternal(new Drawable[] + InternalChildren = new Drawable[] { Light = new NotificationLight { + Alpha = 0, Margin = new MarginPadding { Right = 5 }, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreRight, }, - NotificationContent = new Container + dragContainer = new DragContainer(this) { - CornerRadius = 8, + // Use margin instead of FillFlow spacing to fix extra padding appearing when notification shrinks + // in height. + Padding = new MarginPadding { Vertical = 3f }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }.WithChild(MainContent = new Container + { + CornerRadius = 6, Masking = true, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, @@ -79,69 +135,106 @@ namespace osu.Game.Overlays.Notifications AutoSizeEasing = Easing.OutQuint, Children = new Drawable[] { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.White, - }, - new Container + new GridContainer { RelativeSizeAxes = Axes.X, - Padding = new MarginPadding(5), AutoSizeAxes = Axes.Y, - Children = new Drawable[] + RowDimensions = new[] { - IconContent = new Container - { - Size = new Vector2(40), - Masking = true, - CornerRadius = 5, - }, - content = new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding - { - Left = 45, - Right = 30 - }, - } - } - }, - closeButton = new CloseButton - { - Alpha = 0, - Action = Close, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Margin = new MarginPadding - { - Right = 5 + new Dimension(GridSizeMode.AutoSize, minSize: 60) }, - } + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + IconContent = new Container + { + Width = 40, + RelativeSizeAxes = Axes.Y, + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(10), + Children = new Drawable[] + { + content = new Container + { + Masking = true, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + } + }, + new CloseButton(CloseButtonIcon) + { + Action = () => Close(true), + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + } + } + }, + }, + initialFlash = new Box + { + Colour = Color4.White.Opacity(0.8f), + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + }, } - } + }) + }; + } + + [BackgroundDependencyLoader] + private void load() + { + MainContent.Add(background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background3, + Depth = float.MaxValue }); } protected override bool OnHover(HoverEvent e) { - closeButton.FadeIn(75); + background.FadeColour(colourProvider.Background2, 200, Easing.OutQuint); return base.OnHover(e); } protected override void OnHoverLost(HoverLostEvent e) { - closeButton.FadeOut(75); + background.FadeColour(colourProvider.Background3, 200, Easing.OutQuint); base.OnHoverLost(e); } + protected override bool OnMouseDown(MouseDownEvent e) + { + // right click doesn't trigger OnClick so we need to handle here until that changes. + if (e.Button != MouseButton.Left) + { + Close(true); + return true; + } + + return base.OnMouseDown(e); + } + protected override bool OnClick(ClickEvent e) { - if (Activated?.Invoke() ?? true) - Close(); + // Clicking with anything but left button should dismiss but not perform the activation action. + if (e.Button == MouseButton.Left && Activated?.Invoke() == false) + return true; + Close(false); return true; } @@ -150,67 +243,203 @@ namespace osu.Game.Overlays.Notifications base.LoadComplete(); this.FadeInFromZero(200); - NotificationContent.MoveToX(DrawSize.X); - NotificationContent.MoveToX(0, 500, Easing.OutQuint); + + MainContent.MoveToX(DrawSize.X); + MainContent.MoveToX(0, 500, Easing.OutQuint); + + initialFlash.FadeOutFromOne(2000, Easing.OutQuart); } - public bool WasClosed; - - public virtual void Close() + public virtual void Close(bool runFlingAnimation) { if (WasClosed) return; WasClosed = true; Closed?.Invoke(); - this.FadeOut(100); - Expire(); + + Schedule(() => + { + if (runFlingAnimation && dragContainer.FlingLeft()) + this.FadeOut(600, Easing.In); + else + this.FadeOut(100); + + Expire(); + }); } - private class CloseButton : OsuClickableContainer + private partial class DragContainer : Container { - private Color4 hoverColour; + private Vector2 velocity; + private Vector2 lastPosition; - public CloseButton() + private readonly Notification notification; + + public DragContainer(Notification notification) { - Colour = OsuColour.Gray(0.2f); - AutoSizeAxes = Axes.Both; + this.notification = notification; + } - Children = new[] + public override RectangleF BoundingBox + { + get { - new SpriteIcon + var childBounding = Children.First().BoundingBox; + + if (X < 0) childBounding *= new Vector2(1, Math.Max(0, 1 + (X / 300))); + if (Y > 0) childBounding *= new Vector2(1, Math.Max(0, 1 - (Y / 200))); + + return childBounding; + } + } + + protected override bool OnDragStart(DragStartEvent e) => notification.IsInToastTray; + + protected override void OnDrag(DragEvent e) + { + if (!notification.IsInToastTray) + return; + + Vector2 change = e.MousePosition - e.MouseDownPosition; + + // Diminish the drag distance as we go further to simulate "rubber band" feeling. + change *= change.Length <= 0 ? 0 : MathF.Pow(change.Length, 0.8f) / change.Length; + + // Only apply Y change if dragging to the left. + if (change.X >= 0) + change.Y = 0; + else + change.Y *= (float)Interpolation.ApplyEasing(Easing.InOutQuart, Math.Min(1, -change.X / 200)); + + this.MoveTo(change); + } + + protected override void OnDragEnd(DragEndEvent e) + { + if (notification.AllowFlingDismiss && (Rotation < -10 || velocity.X < -0.3f)) + notification.Close(true); + else if (X > 30 || velocity.X > 0.3f) + notification.ForwardToOverlay?.Invoke(); + else + ResetPosition(); + + base.OnDragEnd(e); + } + + private bool flinging; + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + Rotation = Math.Min(0, X * 0.1f); + + if (flinging) + { + velocity.Y += (float)Clock.ElapsedFrameTime * 0.005f; + Position += (float)Clock.ElapsedFrameTime * velocity; + } + else if (Clock.ElapsedFrameTime > 0) + { + Vector2 change = (Position - lastPosition) / (float)Clock.ElapsedFrameTime; + + if (velocity.X == 0) + velocity = change; + else + { + velocity = new Vector2( + (float)Interpolation.DampContinuously(velocity.X, change.X, 40, Clock.ElapsedFrameTime), + (float)Interpolation.DampContinuously(velocity.Y, change.Y, 40, Clock.ElapsedFrameTime) + ); + } + + lastPosition = Position; + } + } + + public bool FlingLeft() + { + if (!notification.IsInToastTray) + return false; + + if (flinging) + return true; + + if (velocity.X > -0.3f) + velocity.X = -0.3f - 0.5f * RNG.NextSingle(); + + flinging = true; + ClearTransforms(); + return true; + } + + public void ResetPosition() + { + this.MoveTo(Vector2.Zero, 800, Easing.OutElastic); + this.RotateTo(0, 800, Easing.OutElastic); + } + } + + internal partial class CloseButton : OsuClickableContainer + { + private SpriteIcon icon = null!; + private Box background = null!; + + private readonly IconUsage iconUsage; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public CloseButton(IconUsage iconUsage) + { + this.iconUsage = iconUsage; + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Y; + Width = 28; + + Children = new Drawable[] + { + background = new Box + { + Colour = OsuColour.Gray(0).Opacity(0.15f), + Alpha = 0, + RelativeSizeAxes = Axes.Both, + }, + icon = new SpriteIcon { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Icon = FontAwesome.Solid.TimesCircle, - Size = new Vector2(20), + Icon = iconUsage, + Size = new Vector2(12), + Colour = colourProvider.Foreground1, } }; } - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - hoverColour = colours.Yellow; - } - protected override bool OnHover(HoverEvent e) { - this.FadeColour(hoverColour, 200); + background.FadeIn(200, Easing.OutQuint); + icon.FadeColour(colourProvider.Content1, 200, Easing.OutQuint); return base.OnHover(e); } protected override void OnHoverLost(HoverLostEvent e) { - this.FadeColour(OsuColour.Gray(0.2f), 200); + background.FadeOut(200, Easing.OutQuint); + icon.FadeColour(colourProvider.Foreground1, 200, Easing.OutQuint); base.OnHoverLost(e); } } - public class NotificationLight : Container + public partial class NotificationLight : Container { private bool pulsate; - private Container pulsateLayer; + private Container pulsateLayer = null!; public bool Pulsate { diff --git a/osu.Game/Overlays/Notifications/NotificationSection.cs b/osu.Game/Overlays/Notifications/NotificationSection.cs index a4851ab365..de4c72e473 100644 --- a/osu.Game/Overlays/Notifications/NotificationSection.cs +++ b/osu.Game/Overlays/Notifications/NotificationSection.cs @@ -17,11 +17,11 @@ using osuTK; namespace osu.Game.Overlays.Notifications { - public class NotificationSection : AlwaysUpdateFillFlowContainer + public partial class NotificationSection : AlwaysUpdateFillFlowContainer { - private OsuSpriteText countDrawable; + private OsuSpriteText countDrawable = null!; - private FlowContainer notifications; + private FlowContainer notifications = null!; public int DisplayedCount => notifications.Count(n => !n.WasClosed); public int UnreadCount => notifications.Count(n => !n.WasClosed && !n.Read); @@ -31,15 +31,17 @@ namespace osu.Game.Overlays.Notifications notifications.Insert((int)position, notification); } - public IEnumerable AcceptTypes; + public IEnumerable AcceptedNotificationTypes { get; } - private readonly string clearButtonText; + private readonly LocalisableString clearButtonText; private readonly LocalisableString titleText; - public NotificationSection(LocalisableString title, string clearButtonText) + public NotificationSection(LocalisableString title, IEnumerable acceptedNotificationTypes, LocalisableString clearButtonText) { - this.clearButtonText = clearButtonText.ToUpperInvariant(); + AcceptedNotificationTypes = acceptedNotificationTypes.ToArray(); + + this.clearButtonText = clearButtonText.ToUpper(); titleText = title; } @@ -104,14 +106,13 @@ namespace osu.Game.Overlays.Notifications RelativeSizeAxes = Axes.X, LayoutDuration = 150, LayoutEasing = Easing.OutQuart, - Spacing = new Vector2(3), } }); } private void clearAll() { - notifications.Children.ForEach(c => c.Close()); + notifications.Children.ForEach(c => c.Close(true)); } protected override void Update() @@ -134,7 +135,7 @@ namespace osu.Game.Overlays.Notifications return count; } - private class ClearAllButton : OsuClickableContainer + private partial class ClearAllButton : OsuClickableContainer { private readonly OsuSpriteText text; @@ -157,11 +158,11 @@ namespace osu.Game.Overlays.Notifications public void MarkAllRead() { - notifications?.Children.ForEach(n => n.Read = true); + notifications.Children.ForEach(n => n.Read = true); } } - public class AlwaysUpdateFillFlowContainer : FillFlowContainer + public partial class AlwaysUpdateFillFlowContainer : FillFlowContainer where T : Drawable { // this is required to ensure correct layout and scheduling on children. diff --git a/osu.Game/Overlays/Notifications/ProgressCompletionNotification.cs b/osu.Game/Overlays/Notifications/ProgressCompletionNotification.cs index 754f9bd600..46972d4b5e 100644 --- a/osu.Game/Overlays/Notifications/ProgressCompletionNotification.cs +++ b/osu.Game/Overlays/Notifications/ProgressCompletionNotification.cs @@ -8,7 +8,7 @@ using osu.Framework.Graphics.Sprites; namespace osu.Game.Overlays.Notifications { - public class ProgressCompletionNotification : SimpleNotification + public partial class ProgressCompletionNotification : SimpleNotification { public ProgressCompletionNotification() { @@ -18,7 +18,7 @@ namespace osu.Game.Overlays.Notifications [BackgroundDependencyLoader] private void load(OsuColour colours) { - IconBackground.Colour = ColourInfo.GradientVertical(colours.GreenDark, colours.GreenLight); + IconContent.Colour = ColourInfo.GradientVertical(colours.GreenDark, colours.GreenLight); } } } diff --git a/osu.Game/Overlays/Notifications/ProgressNotification.cs b/osu.Game/Overlays/Notifications/ProgressNotification.cs index 4735fcb7c1..e6662e2179 100644 --- a/osu.Game/Overlays/Notifications/ProgressNotification.cs +++ b/osu.Game/Overlays/Notifications/ProgressNotification.cs @@ -19,10 +19,24 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Notifications { - public class ProgressNotification : Notification, IHasCompletionTarget + public partial class ProgressNotification : Notification, IHasCompletionTarget { private const float loading_spinner_size = 22; + public Func? CancelRequested { get; set; } + + protected override bool AllowFlingDismiss => false; + + /// + /// The function to post completion notifications back to. + /// + public Action? CompletionTarget { get; set; } + + /// + /// An action to complete when the completion notification is clicked. Return true to close. + /// + public Func? CompletionClickAction { get; set; } + private LocalisableString text; public override LocalisableString Text @@ -35,7 +49,7 @@ namespace osu.Game.Overlays.Notifications } } - public string CompletionText { get; set; } = "Task has completed!"; + public LocalisableString CompletionText { get; set; } = "Task has completed!"; private float progress; @@ -45,18 +59,21 @@ namespace osu.Game.Overlays.Notifications set { progress = value; - Scheduler.AddOnce(updateProgress, progress); + Scheduler.AddOnce(p => progressBar.Progress = p, progress); } } - private void updateProgress(float progress) => progressBar.Progress = progress; + protected override IconUsage CloseButtonIcon => FontAwesome.Solid.Times; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; protected override void LoadComplete() { base.LoadComplete(); // we may have received changes before we were displayed. - updateState(); + Scheduler.AddOnce(updateState); } private readonly CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); @@ -72,8 +89,8 @@ namespace osu.Game.Overlays.Notifications state = value; - if (IsLoaded) - Schedule(updateState); + Scheduler.AddOnce(updateState); + attemptPostCompletion(); } } @@ -88,7 +105,7 @@ namespace osu.Game.Overlays.Notifications Light.Pulsate = false; progressBar.Active = false; - iconBackground.FadeColour(ColourInfo.GradientVertical(colourQueued, colourQueued.Lighten(0.5f)), colour_fade_duration); + IconContent.FadeColour(ColourInfo.GradientVertical(colourQueued, colourQueued.Lighten(0.5f)), colour_fade_duration); loadingSpinner.Show(); break; @@ -97,14 +114,14 @@ namespace osu.Game.Overlays.Notifications Light.Pulsate = true; progressBar.Active = true; - iconBackground.FadeColour(ColourInfo.GradientVertical(colourActive, colourActive.Lighten(0.5f)), colour_fade_duration); + IconContent.FadeColour(ColourInfo.GradientVertical(colourActive, colourActive.Lighten(0.5f)), colour_fade_duration); loadingSpinner.Show(); break; case ProgressNotificationState.Cancelled: cancellationTokenSource.Cancel(); - iconBackground.FadeColour(ColourInfo.GradientVertical(Color4.Gray, Color4.Gray.Lighten(0.5f)), colour_fade_duration); + IconContent.FadeColour(ColourInfo.GradientVertical(Color4.Gray, Color4.Gray.Lighten(0.5f)), colour_fade_duration); loadingSpinner.Hide(); var icon = new SpriteIcon @@ -126,12 +143,34 @@ namespace osu.Game.Overlays.Notifications case ProgressNotificationState.Completed: loadingSpinner.Hide(); - NotificationContent.MoveToY(-DrawSize.Y / 2, 200, Easing.OutQuint); - this.FadeOut(200).Finally(d => Completed()); + attemptPostCompletion(); break; } } + private int completionSent; + + /// + /// Attempt to post a completion notification. + /// + private void attemptPostCompletion() + { + if (state != ProgressNotificationState.Completed) return; + + // This notification may not have been posted yet (and thus may not have a target to post the completion to). + // Completion posting will be re-attempted in a scheduled invocation. + if (CompletionTarget == null) + return; + + // Thread-safe barrier, as this may be called by a web request and also scheduled to the update thread at the same time. + if (Interlocked.Exchange(ref completionSent, 1) == 1) + return; + + CompletionTarget.Invoke(CreateCompletionNotification()); + + Close(false); + } + private ProgressNotificationState state; protected virtual Notification CreateCompletionNotification() => new ProgressCompletionNotification @@ -140,34 +179,28 @@ namespace osu.Game.Overlays.Notifications Text = CompletionText }; - protected virtual void Completed() - { - CompletionTarget?.Invoke(CreateCompletionNotification()); - base.Close(); - } - public override bool DisplayOnTop => false; + public override bool IsImportant => false; + private readonly ProgressBar progressBar; private Color4 colourQueued; private Color4 colourActive; private Color4 colourCancelled; - private Box iconBackground; - private LoadingSpinner loadingSpinner; + private LoadingSpinner loadingSpinner = null!; private readonly TextFlowContainer textDrawable; public ProgressNotification() { - Content.Add(textDrawable = new OsuTextFlowContainer + Content.Add(textDrawable = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 14, weight: FontWeight.Medium)) { - Colour = OsuColour.Gray(128), AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, }); - NotificationContent.Add(progressBar = new ProgressBar + MainContent.Add(progressBar = new ProgressBar { Origin = Anchor.BottomLeft, Anchor = Anchor.BottomLeft, @@ -192,10 +225,11 @@ namespace osu.Game.Overlays.Notifications IconContent.AddRange(new Drawable[] { - iconBackground = new Box + new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4.White, + Colour = colourProvider.Background5, + Depth = float.MaxValue, }, loadingSpinner = new LoadingSpinner { @@ -204,12 +238,13 @@ namespace osu.Game.Overlays.Notifications }); } - public override void Close() + public override void Close(bool runFlingAnimation) { switch (State) { + case ProgressNotificationState.Completed: case ProgressNotificationState.Cancelled: - base.Close(); + base.Close(runFlingAnimation); break; case ProgressNotificationState.Active: @@ -220,19 +255,7 @@ namespace osu.Game.Overlays.Notifications } } - public Func CancelRequested { get; set; } - - /// - /// The function to post completion notifications back to. - /// - public Action CompletionTarget { get; set; } - - /// - /// An action to complete when the completion notification is clicked. Return true to close. - /// - public Func CompletionClickAction; - - private class ProgressBar : Container + private partial class ProgressBar : Container { private readonly Box box; diff --git a/osu.Game/Overlays/Notifications/SimpleErrorNotification.cs b/osu.Game/Overlays/Notifications/SimpleErrorNotification.cs index faab4ed472..7d0d07fc1b 100644 --- a/osu.Game/Overlays/Notifications/SimpleErrorNotification.cs +++ b/osu.Game/Overlays/Notifications/SimpleErrorNotification.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Sprites; namespace osu.Game.Overlays.Notifications { - public class SimpleErrorNotification : SimpleNotification + public partial class SimpleErrorNotification : SimpleNotification { public override string PopInSampleName => "UI/error-notification-pop-in"; diff --git a/osu.Game/Overlays/Notifications/SimpleNotification.cs b/osu.Game/Overlays/Notifications/SimpleNotification.cs index b9a1cc6d90..109b31ff71 100644 --- a/osu.Game/Overlays/Notifications/SimpleNotification.cs +++ b/osu.Game/Overlays/Notifications/SimpleNotification.cs @@ -14,7 +14,7 @@ using osuTK; namespace osu.Game.Overlays.Notifications { - public class SimpleNotification : Notification + public partial class SimpleNotification : Notification { private LocalisableString text; @@ -24,7 +24,8 @@ namespace osu.Game.Overlays.Notifications set { text = value; - textDrawable.Text = text; + if (textDrawable != null) + textDrawable.Text = text; } } @@ -36,48 +37,50 @@ namespace osu.Game.Overlays.Notifications set { icon = value; - iconDrawable.Icon = icon; + if (iconDrawable != null) + iconDrawable.Icon = icon; } } - private readonly TextFlowContainer textDrawable; - private readonly SpriteIcon iconDrawable; - - protected Box IconBackground; - - public SimpleNotification() + public ColourInfo IconColour { + get => IconContent.Colour; + set => IconContent.Colour = value; + } + + private TextFlowContainer? textDrawable; + + private SpriteIcon? iconDrawable; + + [BackgroundDependencyLoader] + private void load(OsuColour colours, OverlayColourProvider colourProvider) + { + Light.Colour = colours.Green; + IconContent.AddRange(new Drawable[] { - IconBackground = new Box + new Box { RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(OsuColour.Gray(0.2f), OsuColour.Gray(0.6f)) + Colour = colourProvider.Background5, }, iconDrawable = new SpriteIcon { Anchor = Anchor.Centre, Origin = Anchor.Centre, Icon = icon, - Size = new Vector2(20), + Size = new Vector2(16), } }); - Content.Add(textDrawable = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 14)) + Content.Add(textDrawable = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 14, weight: FontWeight.Medium)) { - Colour = OsuColour.Gray(128), AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, Text = text }); } - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Light.Colour = colours.Green; - } - public override bool Read { get => base.Read; diff --git a/osu.Game/Overlays/NowPlayingOverlay.cs b/osu.Game/Overlays/NowPlayingOverlay.cs index 4617a91885..66fb3571ba 100644 --- a/osu.Game/Overlays/NowPlayingOverlay.cs +++ b/osu.Game/Overlays/NowPlayingOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Threading.Tasks; using osu.Framework.Allocation; @@ -26,7 +28,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays { - public class NowPlayingOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent + public partial class NowPlayingOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent { public string IconTexture => "Icons/Hexacons/music"; public LocalisableString Title => NowPlayingStrings.HeaderTitle; @@ -36,6 +38,7 @@ namespace osu.Game.Overlays private const float transition_length = 800; private const float progress_height = 10; private const float bottom_black_area_height = 55; + private const float margin = 10; private Drawable background; private ProgressBar progressBar; @@ -51,6 +54,7 @@ namespace osu.Game.Overlays private Container dragContainer; private Container playerContainer; + private Container playlistContainer; protected override string PopInSampleName => "UI/now-playing-pop-in"; protected override string PopOutSampleName => "UI/now-playing-pop-out"; @@ -67,7 +71,7 @@ namespace osu.Game.Overlays public NowPlayingOverlay() { Width = 400; - Margin = new MarginPadding(10); + Margin = new MarginPadding(margin); } [BackgroundDependencyLoader] @@ -80,7 +84,6 @@ namespace osu.Game.Overlays Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, Children = new Drawable[] { playerContainer = new Container @@ -97,7 +100,7 @@ namespace osu.Game.Overlays }, Children = new[] { - background = new Background(), + background = Empty(), title = new OsuSpriteText { Origin = Anchor.BottomCentre, @@ -180,8 +183,13 @@ namespace osu.Game.Overlays } }, }, + playlistContainer = new Container + { + RelativeSizeAxes = Axes.X, + Y = player_height + margin, + } } - } + }, }; } @@ -191,11 +199,10 @@ namespace osu.Game.Overlays { LoadComponentAsync(playlist = new PlaylistOverlay { - RelativeSizeAxes = Axes.X, - Y = player_height + 10, + RelativeSizeAxes = Axes.Both, }, _ => { - dragContainer.Add(playlist); + playlistContainer.Add(playlist); playlist.State.BindValueChanged(s => playlistButton.FadeColour(s.NewValue == Visibility.Visible ? colours.Yellow : Color4.White, 200, Easing.OutQuint), true); @@ -240,7 +247,18 @@ namespace osu.Game.Overlays { base.UpdateAfterChildren(); - Height = dragContainer.Height; + playlistContainer.Height = MathF.Min(Parent.DrawHeight - margin * 3 - player_height, PlaylistOverlay.PLAYLIST_HEIGHT); + + float height = player_height; + + if (playlist != null) + { + height += playlist.DrawHeight; + if (playlist.State.Value == Visibility.Visible) + height += margin; + } + + Height = dragContainer.Height = height; } protected override void Update() @@ -338,7 +356,7 @@ namespace osu.Game.Overlays musicController.TrackChanged -= trackChanged; } - private class MusicIconButton : IconButton + private partial class MusicIconButton : IconButton { public MusicIconButton() { @@ -362,7 +380,7 @@ namespace osu.Game.Overlays } } - private class Background : BufferedContainer + private partial class Background : BufferedContainer { private readonly Sprite sprite; private readonly WorkingBeatmap beatmap; @@ -395,13 +413,13 @@ namespace osu.Game.Overlays } [BackgroundDependencyLoader] - private void load(TextureStore textures) + private void load(LargeTextureStore textures) { sprite.Texture = beatmap?.Background ?? textures.Get(@"Backgrounds/bg4"); } } - private class DragContainer : Container + private partial class DragContainer : Container { protected override bool OnDragStart(DragStartEvent e) { @@ -425,7 +443,7 @@ namespace osu.Game.Overlays } } - private class HoverableProgressBar : ProgressBar + private partial class HoverableProgressBar : ProgressBar { public HoverableProgressBar() : base(true) diff --git a/osu.Game/Overlays/OSD/CopyUrlToast.cs b/osu.Game/Overlays/OSD/CopyUrlToast.cs new file mode 100644 index 0000000000..ce5a5f56c4 --- /dev/null +++ b/osu.Game/Overlays/OSD/CopyUrlToast.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Localisation; + +namespace osu.Game.Overlays.OSD +{ + public partial class CopyUrlToast : Toast + { + public CopyUrlToast() + : base(UserInterfaceStrings.GeneralHeader, ToastStrings.UrlCopied, "") + { + } + } +} diff --git a/osu.Game/Overlays/OSD/Toast.cs b/osu.Game/Overlays/OSD/Toast.cs index 12e30d8de2..ff8696c04f 100644 --- a/osu.Game/Overlays/OSD/Toast.cs +++ b/osu.Game/Overlays/OSD/Toast.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -14,7 +16,7 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.OSD { - public abstract class Toast : Container + public abstract partial class Toast : Container { private const int toast_minimum_width = 240; diff --git a/osu.Game/Overlays/OSD/TrackedSettingToast.cs b/osu.Game/Overlays/OSD/TrackedSettingToast.cs index 9939ba024e..1aa6de423e 100644 --- a/osu.Game/Overlays/OSD/TrackedSettingToast.cs +++ b/osu.Game/Overlays/OSD/TrackedSettingToast.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -19,7 +21,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.OSD { - public class TrackedSettingToast : Toast + public partial class TrackedSettingToast : Toast { private const int lights_bottom_margin = 40; @@ -66,7 +68,7 @@ namespace osu.Game.Overlays.OSD if (val) selectedOption = 0; break; - case Enum _: + case Enum: var values = Enum.GetValues(description.RawValue.GetType()); optionCount = values.Length; selectedOption = Convert.ToInt32(description.RawValue); @@ -127,7 +129,7 @@ namespace osu.Game.Overlays.OSD sampleChange = audio.Samples.Get("UI/osd-change"); } - private class OptionLight : Container + private partial class OptionLight : Container { private Color4 glowingColour, idleColour; diff --git a/osu.Game/Overlays/OnScreenDisplay.cs b/osu.Game/Overlays/OnScreenDisplay.cs index 6b3696ced9..4f2dba7b2c 100644 --- a/osu.Game/Overlays/OnScreenDisplay.cs +++ b/osu.Game/Overlays/OnScreenDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using osu.Framework.Configuration; @@ -18,7 +20,7 @@ namespace osu.Game.Overlays /// An on-screen display which automatically tracks and displays toast notifications for . /// Can also display custom content via /// - public class OnScreenDisplay : Container + public partial class OnScreenDisplay : Container { private readonly Container box; @@ -56,7 +58,7 @@ namespace osu.Game.Overlays /// If is already being tracked from the same . public void BeginTracking(object source, ITrackableConfigManager configManager) { - if (configManager == null) throw new ArgumentNullException(nameof(configManager)); + ArgumentNullException.ThrowIfNull(configManager); if (trackedConfigManagers.ContainsKey((source, configManager))) throw new InvalidOperationException($"{nameof(configManager)} is already registered."); @@ -80,7 +82,7 @@ namespace osu.Game.Overlays /// If is not being tracked from the same . public void StopTracking(object source, ITrackableConfigManager configManager) { - if (configManager == null) throw new ArgumentNullException(nameof(configManager)); + ArgumentNullException.ThrowIfNull(configManager); if (!trackedConfigManagers.TryGetValue((source, configManager), out var existing)) return; diff --git a/osu.Game/Overlays/OnlineOverlay.cs b/osu.Game/Overlays/OnlineOverlay.cs index a610511398..4d2c6bc9d0 100644 --- a/osu.Game/Overlays/OnlineOverlay.cs +++ b/osu.Game/Overlays/OnlineOverlay.cs @@ -1,15 +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; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Online; namespace osu.Game.Overlays { - public abstract class OnlineOverlay : FullscreenOverlay + public abstract partial class OnlineOverlay : FullscreenOverlay where T : OverlayHeader { protected override Container Content => content; @@ -18,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) @@ -35,26 +41,48 @@ namespace osu.Game.Overlays { RelativeSizeAxes = Axes.Both, ScrollbarVisible = false, - Child = new FillFlowContainer + Child = new OsuContextMenuContainer { - AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, - Direction = FillDirection.Vertical, - Children = new Drawable[] + AutoSizeAxes = Axes.Y, + Child = new PopoverContainer { - Header.With(h => h.Depth = float.MinValue), - content = new Container + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = new FillFlowContainer { RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + Header.With(h => h.Depth = float.MinValue), + content = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + } } - } + }, } }, - Loading = new LoadingLayer(true) + loadingContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Child = Loading = new LoadingLayer(true), + } }); base.Content.Add(mainContent); } + + 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/OverlayActivation.cs b/osu.Game/Overlays/OverlayActivation.cs index 68d7ee8ea9..354153734e 100644 --- a/osu.Game/Overlays/OverlayActivation.cs +++ b/osu.Game/Overlays/OverlayActivation.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Overlays { public enum OverlayActivation diff --git a/osu.Game/Overlays/OverlayColourProvider.cs b/osu.Game/Overlays/OverlayColourProvider.cs index a4f6527024..d7581960f4 100644 --- a/osu.Game/Overlays/OverlayColourProvider.cs +++ b/osu.Game/Overlays/OverlayColourProvider.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osuTK; using osuTK.Graphics; diff --git a/osu.Game/Overlays/OverlayHeader.cs b/osu.Game/Overlays/OverlayHeader.cs index fed1e57686..93de463204 100644 --- a/osu.Game/Overlays/OverlayHeader.cs +++ b/osu.Game/Overlays/OverlayHeader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays { - public abstract class OverlayHeader : Container + public abstract partial class OverlayHeader : Container { public OverlayTitle Title { get; } @@ -73,19 +75,11 @@ namespace osu.Game.Overlays { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Children = new[] + Child = Title = CreateTitle().With(title => { - Title = CreateTitle().With(title => - { - title.Anchor = Anchor.CentreLeft; - title.Origin = Anchor.CentreLeft; - }), - CreateTitleContent().With(content => - { - content.Anchor = Anchor.CentreRight; - content.Origin = Anchor.CentreRight; - }) - } + title.Anchor = Anchor.CentreLeft; + title.Origin = Anchor.CentreLeft; + }), } } }, @@ -95,7 +89,7 @@ namespace osu.Game.Overlays } }); - ContentSidePadding = 50; + ContentSidePadding = WaveOverlayContainer.HORIZONTAL_PADDING; } [BackgroundDependencyLoader] @@ -110,12 +104,6 @@ namespace osu.Game.Overlays [NotNull] protected virtual Drawable CreateBackground() => Empty(); - /// - /// Creates a on the opposite side of the . Used mostly to create . - /// - [NotNull] - protected virtual Drawable CreateTitleContent() => Empty(); - protected abstract OverlayTitle CreateTitle(); } } diff --git a/osu.Game/Overlays/OverlayHeaderBackground.cs b/osu.Game/Overlays/OverlayHeaderBackground.cs index 2fef593285..a089001385 100644 --- a/osu.Game/Overlays/OverlayHeaderBackground.cs +++ b/osu.Game/Overlays/OverlayHeaderBackground.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -9,17 +11,17 @@ using osu.Framework.Graphics.Textures; namespace osu.Game.Overlays { - public class OverlayHeaderBackground : CompositeDrawable + public partial class OverlayHeaderBackground : CompositeDrawable { public OverlayHeaderBackground(string textureName) { Height = 80; RelativeSizeAxes = Axes.X; Masking = true; - InternalChild = new Background(textureName); + InternalChild = new DelayedLoadWrapper(() => new Background(textureName)); } - private class Background : Sprite + private partial class Background : Sprite { private readonly string textureName; @@ -34,10 +36,16 @@ namespace osu.Game.Overlays } [BackgroundDependencyLoader] - private void load(TextureStore textures) + private void load(LargeTextureStore textures) { Texture = textures.Get(textureName); } + + protected override void LoadComplete() + { + base.LoadComplete(); + this.FadeInFromZero(500, Easing.OutQuint); + } } } } diff --git a/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs b/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs index d7a3b052ae..d7d6bd4a2a 100644 --- a/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs +++ b/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics; @@ -17,7 +19,7 @@ using osu.Framework.Extensions; namespace osu.Game.Overlays { - public class OverlayPanelDisplayStyleControl : OsuTabControl + public partial class OverlayPanelDisplayStyleControl : OsuTabControl { protected override Dropdown CreateDropdown() => null; @@ -49,7 +51,7 @@ namespace osu.Game.Overlays Direction = FillDirection.Horizontal }; - private class PanelDisplayTabItem : TabItem, IHasTooltip + private partial class PanelDisplayTabItem : TabItem, IHasTooltip { public IconUsage Icon { diff --git a/osu.Game/Overlays/OverlayRulesetSelector.cs b/osu.Game/Overlays/OverlayRulesetSelector.cs index 8c44157f78..9205a14d9f 100644 --- a/osu.Game/Overlays/OverlayRulesetSelector.cs +++ b/osu.Game/Overlays/OverlayRulesetSelector.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; @@ -9,8 +11,11 @@ using osuTK; namespace osu.Game.Overlays { - public class OverlayRulesetSelector : RulesetSelector + public partial class OverlayRulesetSelector : RulesetSelector { + // Since this component is used in online overlays and currently web-side doesn't support non-legacy rulesets, let's disable them for now. + protected override bool LegacyOnly => true; + public OverlayRulesetSelector() { AutoSizeAxes = Axes.Both; diff --git a/osu.Game/Overlays/OverlayRulesetTabItem.cs b/osu.Game/Overlays/OverlayRulesetTabItem.cs index 1f11b98881..d5c70a46d0 100644 --- a/osu.Game/Overlays/OverlayRulesetTabItem.cs +++ b/osu.Game/Overlays/OverlayRulesetTabItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; @@ -16,7 +18,7 @@ using osu.Game.Graphics.Containers; namespace osu.Game.Overlays { - public class OverlayRulesetTabItem : TabItem, IHasTooltip + public partial class OverlayRulesetTabItem : TabItem, IHasTooltip { private Color4 accentColour; diff --git a/osu.Game/Overlays/OverlayScrollContainer.cs b/osu.Game/Overlays/OverlayScrollContainer.cs index 7fe188eb04..9752e04f44 100644 --- a/osu.Game/Overlays/OverlayScrollContainer.cs +++ b/osu.Game/Overlays/OverlayScrollContainer.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -19,25 +22,29 @@ using osuTK.Graphics; namespace osu.Game.Overlays { /// - /// which provides . Mostly used in . + /// which provides . Mostly used in . /// - public class OverlayScrollContainer : UserTrackingScrollContainer + public partial class OverlayScrollContainer : UserTrackingScrollContainer { /// - /// Scroll position at which the will be shown. + /// Scroll position at which the will be shown. /// private const int button_scroll_position = 200; - protected readonly ScrollToTopButton Button; + protected ScrollBackButton Button; - public OverlayScrollContainer() + private readonly Bindable lastScrollTarget = new Bindable(); + + [BackgroundDependencyLoader] + private void load() { - AddInternal(Button = new ScrollToTopButton + AddInternal(Button = new ScrollBackButton { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, Margin = new MarginPadding(20), - Action = scrollToTop + Action = scrollBack, + LastScrollTarget = { BindTarget = lastScrollTarget } }); } @@ -51,16 +58,31 @@ namespace osu.Game.Overlays return; } - Button.State = Target > button_scroll_position ? Visibility.Visible : Visibility.Hidden; + Button.State = Target > button_scroll_position || lastScrollTarget.Value != null ? Visibility.Visible : Visibility.Hidden; } - private void scrollToTop() + protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = default) { - ScrollToStart(); - Button.State = Visibility.Hidden; + base.OnUserScroll(value, animated, distanceDecay); + + lastScrollTarget.Value = null; } - public class ScrollToTopButton : OsuHoverContainer + private void scrollBack() + { + if (lastScrollTarget.Value == null) + { + lastScrollTarget.Value = Target; + ScrollToStart(); + } + else + { + ScrollTo(lastScrollTarget.Value.Value); + lastScrollTarget.Value = null; + } + } + + public partial class ScrollBackButton : OsuHoverContainer { private const int fade_duration = 500; @@ -86,8 +108,11 @@ namespace osu.Game.Overlays private readonly Container content; private readonly Box background; + private readonly SpriteIcon spriteIcon; - public ScrollToTopButton() + public Bindable LastScrollTarget = new Bindable(); + + public ScrollBackButton() : base(HoverSampleSet.ScrollToTop) { Size = new Vector2(50); @@ -111,7 +136,7 @@ namespace osu.Game.Overlays { RelativeSizeAxes = Axes.Both }, - new SpriteIcon + spriteIcon = new SpriteIcon { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -132,6 +157,17 @@ namespace osu.Game.Overlays flashColour = colourProvider.Light1; } + protected override void LoadComplete() + { + base.LoadComplete(); + + LastScrollTarget.BindValueChanged(target => + { + spriteIcon.RotateTo(target.NewValue != null ? 180 : 0, fade_duration, Easing.OutQuint); + TooltipText = target.NewValue != null ? CommonStrings.ButtonsBackToPrevious : CommonStrings.ButtonsBackToTop; + }, true); + } + protected override bool OnClick(ClickEvent e) { background.FlashColour(flashColour, 800, Easing.OutQuint); diff --git a/osu.Game/Overlays/OverlaySidebar.cs b/osu.Game/Overlays/OverlaySidebar.cs index 468b5b6eb3..93e5e83ffc 100644 --- a/osu.Game/Overlays/OverlaySidebar.cs +++ b/osu.Game/Overlays/OverlaySidebar.cs @@ -1,16 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; namespace osu.Game.Overlays { - public abstract class OverlaySidebar : CompositeDrawable + public abstract partial class OverlaySidebar : CompositeDrawable { private readonly Box sidebarBackground; private readonly Box scrollbarBackground; @@ -37,7 +40,7 @@ namespace osu.Game.Overlays { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Right = -3 }, // Compensate for scrollbar margin - Child = new OsuScrollContainer + Child = new SidebarScrollContainer { RelativeSizeAxes = Axes.Both, Child = new Container @@ -52,7 +55,7 @@ namespace osu.Game.Overlays Padding = new MarginPadding { Vertical = 20, - Left = 50, + Left = WaveOverlayContainer.HORIZONTAL_PADDING, Right = 30 }, Child = CreateContent() @@ -72,5 +75,30 @@ namespace osu.Game.Overlays [NotNull] protected virtual Drawable CreateContent() => Empty(); + + private partial class SidebarScrollContainer : OsuScrollContainer + { + protected override bool OnScroll(ScrollEvent e) + { + if (e.ScrollDelta.Y > 0 && IsScrolledToStart()) + return false; + + if (e.ScrollDelta.Y < 0 && IsScrolledToEnd()) + return false; + + return base.OnScroll(e); + } + + protected override bool OnDragStart(DragStartEvent e) + { + if (e.Delta.Y > 0 && IsScrolledToStart()) + return false; + + if (e.Delta.Y < 0 && IsScrolledToEnd()) + return false; + + return base.OnDragStart(e); + } + } } } diff --git a/osu.Game/Overlays/OverlaySortTabControl.cs b/osu.Game/Overlays/OverlaySortTabControl.cs index 5f5cfce344..5c51f5e4d0 100644 --- a/osu.Game/Overlays/OverlaySortTabControl.cs +++ b/osu.Game/Overlays/OverlaySortTabControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Containers; using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; @@ -22,8 +24,10 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays { - public class OverlaySortTabControl : CompositeDrawable, IHasCurrentValue + public partial class OverlaySortTabControl : CompositeDrawable, IHasCurrentValue { + public TabControl TabControl { get; } + private readonly BindableWithCurrent current = new BindableWithCurrent(); public Bindable Current @@ -57,7 +61,7 @@ namespace osu.Game.Overlays Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), Text = SortStrings.Default }, - CreateControl().With(c => + TabControl = CreateControl().With(c => { c.Anchor = Anchor.CentreLeft; c.Origin = Anchor.CentreLeft; @@ -70,7 +74,7 @@ namespace osu.Game.Overlays [NotNull] protected virtual SortTabControl CreateControl() => new SortTabControl(); - protected class SortTabControl : OsuTabControl + protected partial class SortTabControl : OsuTabControl { protected override Dropdown CreateDropdown() => null; @@ -89,7 +93,7 @@ namespace osu.Game.Overlays } } - protected class SortTabItem : TabItem + protected partial class SortTabItem : TabItem { public SortTabItem(T value) : base(value) @@ -113,7 +117,7 @@ namespace osu.Game.Overlays } } - protected class TabButton : HeaderButton + public partial class TabButton : HeaderButton { public readonly BindableBool Active = new BindableBool(); diff --git a/osu.Game/Overlays/OverlayStreamControl.cs b/osu.Game/Overlays/OverlayStreamControl.cs index 8b6aca6d5d..84de384fb5 100644 --- a/osu.Game/Overlays/OverlayStreamControl.cs +++ b/osu.Game/Overlays/OverlayStreamControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Input.Events; using System.Collections.Generic; @@ -10,7 +12,7 @@ using JetBrains.Annotations; namespace osu.Game.Overlays { - public abstract class OverlayStreamControl : TabControl + public abstract partial class OverlayStreamControl : TabControl { protected OverlayStreamControl() { diff --git a/osu.Game/Overlays/OverlayStreamItem.cs b/osu.Game/Overlays/OverlayStreamItem.cs index 56502ff70f..45181c13e4 100644 --- a/osu.Game/Overlays/OverlayStreamItem.cs +++ b/osu.Game/Overlays/OverlayStreamItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Input.Events; using osu.Framework.Graphics.UserInterface; @@ -16,7 +18,7 @@ using osu.Framework.Localisation; namespace osu.Game.Overlays { - public abstract class OverlayStreamItem : TabItem + public abstract partial class OverlayStreamItem : TabItem { public readonly Bindable SelectedItem = new Bindable(); @@ -37,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/OverlayTabControl.cs b/osu.Game/Overlays/OverlayTabControl.cs index 578cd703c7..884e31868f 100644 --- a/osu.Game/Overlays/OverlayTabControl.cs +++ b/osu.Game/Overlays/OverlayTabControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; @@ -14,7 +16,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays { - public abstract class OverlayTabControl : OsuTabControl + public abstract partial class OverlayTabControl : OsuTabControl { private readonly Box bar; @@ -56,7 +58,7 @@ namespace osu.Game.Overlays protected override TabItem CreateTabItem(T value) => new OverlayTabItem(value); - protected class OverlayTabItem : TabItem, IHasAccentColour + protected partial class OverlayTabItem : TabItem, IHasAccentColour { protected readonly ExpandingBar Bar; protected readonly OsuSpriteText Text; diff --git a/osu.Game/Overlays/OverlayTitle.cs b/osu.Game/Overlays/OverlayTitle.cs index d92979e8d4..1d207e5f7d 100644 --- a/osu.Game/Overlays/OverlayTitle.cs +++ b/osu.Game/Overlays/OverlayTitle.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -13,7 +15,7 @@ using osuTK; namespace osu.Game.Overlays { - public abstract class OverlayTitle : CompositeDrawable, INamedOverlayComponent + public abstract partial class OverlayTitle : CompositeDrawable, INamedOverlayComponent { public const float ICON_SIZE = 30; @@ -67,7 +69,7 @@ namespace osu.Game.Overlays }; } - private class OverlayTitleIcon : Sprite + private partial class OverlayTitleIcon : Sprite { private readonly string textureName; diff --git a/osu.Game/Overlays/OverlayView.cs b/osu.Game/Overlays/OverlayView.cs index c254cdf290..e919641b9b 100644 --- a/osu.Game/Overlays/OverlayView.cs +++ b/osu.Game/Overlays/OverlayView.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -16,7 +18,7 @@ namespace osu.Game.Overlays /// Automatically performs a data fetch on load. /// /// The type of the API response. - public abstract class OverlayView : CompositeDrawable + public abstract partial class OverlayView : CompositeDrawable where T : class { [Resolved] diff --git a/osu.Game/Overlays/Profile/Header/MedalHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/BadgeHeaderContainer.cs similarity index 70% rename from osu.Game/Overlays/Profile/Header/MedalHeaderContainer.cs rename to osu.Game/Overlays/Profile/Header/BadgeHeaderContainer.cs index 922f3832e4..24be6ce2f5 100644 --- a/osu.Game/Overlays/Profile/Header/MedalHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/BadgeHeaderContainer.cs @@ -4,7 +4,6 @@ using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; @@ -12,22 +11,21 @@ using osu.Framework.Graphics.Shapes; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Profile.Header.Components; using osuTK; -using osuTK.Graphics; namespace osu.Game.Overlays.Profile.Header { - public class MedalHeaderContainer : CompositeDrawable + public partial class BadgeHeaderContainer : CompositeDrawable { - private FillFlowContainer badgeFlowContainer; + private FillFlowContainer badgeFlowContainer = null!; - public readonly Bindable User = new Bindable(); + public readonly Bindable User = new Bindable(); [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) { Alpha = 0; AutoSizeAxes = Axes.Y; - User.ValueChanged += e => updateDisplay(e.NewValue); + User.ValueChanged += e => updateDisplay(e.NewValue?.User); InternalChildren = new Drawable[] { @@ -43,37 +41,30 @@ namespace osu.Game.Overlays.Profile.Header Child = new Box { RelativeSizeAxes = Axes.Both, - Colour = new ColourInfo - { - TopLeft = Color4.Black.Opacity(0.2f), - TopRight = Color4.Black.Opacity(0.2f), - BottomLeft = Color4.Black.Opacity(0), - BottomRight = Color4.Black.Opacity(0) - } - }, + Colour = ColourInfo.GradientVertical(Colour4.Black.Opacity(0.2f), Colour4.Black.Opacity(0)) + } }, badgeFlowContainer = new FillFlowContainer { Direction = FillDirection.Full, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Margin = new MarginPadding { Top = 5 }, Spacing = new Vector2(10, 10), - Padding = new MarginPadding { Horizontal = UserProfileOverlay.CONTENT_X_MARGIN, Vertical = 10 }, + Padding = new MarginPadding { Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING, Top = 10 }, } }; } - private CancellationTokenSource cancellationTokenSource; + private CancellationTokenSource? cancellationTokenSource; - private void updateDisplay(APIUser user) + private void updateDisplay(APIUser? user) { cancellationTokenSource?.Cancel(); cancellationTokenSource = new CancellationTokenSource(); badgeFlowContainer.Clear(); - var badges = user.Badges; + var badges = user?.Badges; if (badges?.Length > 0) { diff --git a/osu.Game/Overlays/Profile/Header/BannerHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/BannerHeaderContainer.cs new file mode 100644 index 0000000000..8e6648dc4b --- /dev/null +++ b/osu.Game/Overlays/Profile/Header/BannerHeaderContainer.cs @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays.Profile.Header.Components; + +namespace osu.Game.Overlays.Profile.Header +{ + public partial class BannerHeaderContainer : CompositeDrawable + { + public readonly Bindable User = new Bindable(); + + [BackgroundDependencyLoader] + private void load() + { + Alpha = 0; + RelativeSizeAxes = Axes.Both; + FillMode = FillMode.Fit; + FillAspectRatio = 1000 / 60f; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + User.BindValueChanged(u => updateDisplay(u.NewValue?.User), true); + } + + private CancellationTokenSource? cancellationTokenSource; + + private void updateDisplay(APIUser? user) + { + cancellationTokenSource?.Cancel(); + cancellationTokenSource = new CancellationTokenSource(); + + ClearInternal(); + + var banner = user?.TournamentBanner; + + if (banner != null) + { + Show(); + + LoadComponentAsync(new DrawableTournamentBanner(banner), AddInternal, cancellationTokenSource.Token); + } + else + { + Hide(); + } + } + + protected override void Dispose(bool isDisposing) + { + cancellationTokenSource?.Cancel(); + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs index a70d57661b..08a816930e 100644 --- a/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs @@ -21,17 +21,17 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Profile.Header { - public class BottomHeaderContainer : CompositeDrawable + public partial class BottomHeaderContainer : CompositeDrawable { - public readonly Bindable User = new Bindable(); + public readonly Bindable User = new Bindable(); - private LinkFlowContainer topLinkContainer; - private LinkFlowContainer bottomLinkContainer; + private LinkFlowContainer topLinkContainer = null!; + private LinkFlowContainer bottomLinkContainer = null!; private Color4 iconColour; [Resolved] - private IAPIProvider api { get; set; } + private IAPIProvider api { get; set; } = null!; public BottomHeaderContainer() { @@ -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[] { @@ -73,10 +73,10 @@ namespace osu.Game.Overlays.Profile.Header } }; - User.BindValueChanged(user => updateDisplay(user.NewValue)); + User.BindValueChanged(user => updateDisplay(user.NewValue?.User)); } - private void updateDisplay(APIUser user) + private void updateDisplay(APIUser? user) { topLinkContainer.Clear(); bottomLinkContainer.Clear(); @@ -162,7 +162,7 @@ namespace osu.Game.Overlays.Profile.Header private void addSpacer(OsuTextFlowContainer textFlow) => textFlow.AddArbitraryDrawable(new Container { Width = 15 }); - private bool tryAddInfo(IconUsage icon, string content, string link = null) + private bool tryAddInfo(IconUsage icon, string content, string? link = null) { if (string.IsNullOrEmpty(content)) return false; diff --git a/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs index 00a866f1f4..d964364510 100644 --- a/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs @@ -3,25 +3,20 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Localisation; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Profile.Header.Components; -using osu.Game.Resources.Localisation.Web; using osuTK; namespace osu.Game.Overlays.Profile.Header { - public class CentreHeaderContainer : CompositeDrawable + public partial class CentreHeaderContainer : CompositeDrawable { - public readonly BindableBool DetailsVisible = new BindableBool(true); - public readonly Bindable User = new Bindable(); + public readonly Bindable User = new Bindable(); - private OverlinedInfoContainer hiddenDetailGlobal; - private OverlinedInfoContainer hiddenDetailCountry; + private LevelBadge levelBadge = null!; public CentreHeaderContainer() { @@ -31,15 +26,12 @@ namespace osu.Game.Overlays.Profile.Header [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) { - Container hiddenDetailContainer; - Container expandedDetailContainer; - InternalChildren = new Drawable[] { new Box { RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background4 + Colour = colourProvider.Background3 }, new FillFlowContainer { @@ -47,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[] { @@ -66,88 +58,47 @@ namespace osu.Game.Overlays.Profile.Header } }, new Container - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - RelativeSizeAxes = Axes.Y, - Padding = new MarginPadding { Vertical = 10 }, - Width = UserProfileOverlay.CONTENT_X_MARGIN, - Child = new ExpandDetailsButton - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - DetailsVisible = { BindTarget = DetailsVisible } - }, - }, - new Container { 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[] { - new LevelBadge + levelBadge = new LevelBadge { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, - Size = new Vector2(40), - User = { BindTarget = User } + Size = new Vector2(40) }, - expandedDetailContainer = new Container + new Container { Anchor = Anchor.CentreRight, 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, User = { BindTarget = User } } }, - hiddenDetailContainer = new FillFlowContainer - { - Direction = FillDirection.Horizontal, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Width = 200, - AutoSizeAxes = Axes.Y, - Alpha = 0, - Spacing = new Vector2(10, 0), - Margin = new MarginPadding { Right = 50 }, - Children = new[] - { - hiddenDetailGlobal = new OverlinedInfoContainer - { - Title = UsersStrings.ShowRankGlobalSimple, - LineColour = colourProvider.Highlight1 - }, - hiddenDetailCountry = new OverlinedInfoContainer - { - Title = UsersStrings.ShowRankCountrySimple, - LineColour = colourProvider.Highlight1 - }, - } - } } } }; - - DetailsVisible.BindValueChanged(visible => - { - hiddenDetailContainer.FadeTo(visible.NewValue ? 0 : 1, 200, Easing.OutQuint); - expandedDetailContainer.FadeTo(visible.NewValue ? 1 : 0, 200, Easing.OutQuint); - }); - - User.BindValueChanged(user => updateDisplay(user.NewValue)); } - private void updateDisplay(APIUser user) + protected override void LoadComplete() { - hiddenDetailGlobal.Content = user?.Statistics?.GlobalRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; - hiddenDetailCountry.Content = user?.Statistics?.CountryRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; + base.LoadComplete(); + + User.BindValueChanged(user => updateDisplay(user.NewValue?.User), true); + } + + private void updateDisplay(APIUser? user) + { + levelBadge.LevelInfo.Value = user?.Statistics?.Level; } } } diff --git a/osu.Game/Overlays/Profile/Header/Components/DrawableBadge.cs b/osu.Game/Overlays/Profile/Header/Components/DrawableBadge.cs index 74f3ed846b..5f100bc882 100644 --- a/osu.Game/Overlays/Profile/Header/Components/DrawableBadge.cs +++ b/osu.Game/Overlays/Profile/Header/Components/DrawableBadge.cs @@ -3,18 +3,18 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Localisation; +using osu.Game.Graphics.Containers; +using osu.Game.Online; using osu.Game.Users; using osuTK; namespace osu.Game.Overlays.Profile.Header.Components { [LongRunningLoad] - public class DrawableBadge : CompositeDrawable, IHasTooltip + public partial class DrawableBadge : OsuClickableContainer { public static readonly Vector2 DRAWABLE_BADGE_SIZE = new Vector2(86, 40); @@ -27,22 +27,25 @@ namespace osu.Game.Overlays.Profile.Header.Components } [BackgroundDependencyLoader] - private void load(LargeTextureStore textures) + private void load(LargeTextureStore textures, ILinkHandler? linkHandler) { - InternalChild = new Sprite + Child = new Sprite { FillMode = FillMode.Fit, RelativeSizeAxes = Axes.Both, Texture = textures.Get(badge.ImageUrl), }; + + if (!string.IsNullOrEmpty(badge.Url)) + Action = () => linkHandler?.HandleLink(badge.Url); } protected override void LoadComplete() { base.LoadComplete(); - InternalChild.FadeInFromZero(200); + this.FadeInFromZero(200); } - public LocalisableString TooltipText => badge.Description; + public override LocalisableString TooltipText => badge.Description; } } diff --git a/osu.Game/Overlays/Profile/Header/Components/DrawableTournamentBanner.cs b/osu.Game/Overlays/Profile/Header/Components/DrawableTournamentBanner.cs new file mode 100644 index 0000000000..26d333ff95 --- /dev/null +++ b/osu.Game/Overlays/Profile/Header/Components/DrawableTournamentBanner.cs @@ -0,0 +1,46 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Localisation; +using osu.Game.Graphics.Containers; +using osu.Game.Online.API; +using osu.Game.Users; + +namespace osu.Game.Overlays.Profile.Header.Components +{ + [LongRunningLoad] + public partial class DrawableTournamentBanner : OsuClickableContainer + { + private readonly TournamentBanner banner; + + public DrawableTournamentBanner(TournamentBanner banner) + { + this.banner = banner; + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(LargeTextureStore textures, OsuGame? game, IAPIProvider api) + { + Child = new Sprite + { + RelativeSizeAxes = Axes.Both, + Texture = textures.Get(banner.Image), + }; + + Action = () => game?.OpenUrlExternally($@"{api.WebsiteRootUrl}/community/tournaments/{banner.TournamentId}"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + this.FadeInFromZero(200); + } + + public override LocalisableString TooltipText => "view in browser"; + } +} diff --git a/osu.Game/Overlays/Profile/Header/Components/ExtendedDetails.cs b/osu.Game/Overlays/Profile/Header/Components/ExtendedDetails.cs new file mode 100644 index 0000000000..50fc52600c --- /dev/null +++ b/osu.Game/Overlays/Profile/Header/Components/ExtendedDetails.cs @@ -0,0 +1,110 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Overlays.Profile.Header.Components +{ + public partial class ExtendedDetails : CompositeDrawable + { + public Bindable User { get; } = new Bindable(); + + private SpriteText rankedScore = null!; + private SpriteText hitAccuracy = null!; + private SpriteText playCount = null!; + private SpriteText totalScore = null!; + private SpriteText totalHits = null!; + private SpriteText maximumCombo = null!; + private SpriteText replaysWatched = null!; + + [BackgroundDependencyLoader] + private void load() + { + var font = OsuFont.Default.With(size: 12); + const float vertical_spacing = 4; + + AutoSizeAxes = Axes.Both; + + // this should really be a grid, but trying to avoid one to avoid the performance hit. + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(20, 0), + Children = new[] + { + new FillFlowContainer + { + Name = @"Labels", + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, vertical_spacing), + Children = new Drawable[] + { + new OsuSpriteText { Font = font, Text = UsersStrings.ShowStatsRankedScore }, + new OsuSpriteText { Font = font, Text = UsersStrings.ShowStatsHitAccuracy }, + new OsuSpriteText { Font = font, Text = UsersStrings.ShowStatsPlayCount }, + new OsuSpriteText { Font = font, Text = UsersStrings.ShowStatsTotalScore }, + new OsuSpriteText { Font = font, Text = UsersStrings.ShowStatsTotalHits }, + new OsuSpriteText { Font = font, Text = UsersStrings.ShowStatsMaximumCombo }, + new OsuSpriteText { Font = font, Text = UsersStrings.ShowStatsReplaysWatchedByOthers }, + } + }, + new FillFlowContainer + { + Name = @"Values", + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, vertical_spacing), + Children = new Drawable[] + { + rankedScore = new OsuSpriteText { Font = font }, + hitAccuracy = new OsuSpriteText { Font = font }, + playCount = new OsuSpriteText { Font = font }, + totalScore = new OsuSpriteText { Font = font }, + totalHits = new OsuSpriteText { Font = font }, + maximumCombo = new OsuSpriteText { Font = font }, + replaysWatched = new OsuSpriteText { Font = font }, + } + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + User.BindValueChanged(user => updateStatistics(user.NewValue?.User.Statistics), true); + } + + private void updateStatistics(UserStatistics? statistics) + { + if (statistics == null) + { + Alpha = 0; + return; + } + + Alpha = 1; + + rankedScore.Text = statistics.RankedScore.ToLocalisableString(@"N0"); + hitAccuracy.Text = statistics.DisplayAccuracy; + playCount.Text = statistics.PlayCount.ToLocalisableString(@"N0"); + totalScore.Text = statistics.TotalScore.ToLocalisableString(@"N0"); + totalHits.Text = statistics.TotalHits.ToLocalisableString(@"N0"); + maximumCombo.Text = statistics.MaxCombo.ToLocalisableString(@"N0"); + replaysWatched.Text = statistics.ReplaysWatched.ToLocalisableString(@"N0"); + } + } +} diff --git a/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs b/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs index 1b21bfc982..844efa5cf0 100644 --- a/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs @@ -5,14 +5,13 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Header.Components { - public class FollowersButton : ProfileHeaderStatisticsButton + public partial class FollowersButton : ProfileHeaderStatisticsButton { - public readonly Bindable User = new Bindable(); + public readonly Bindable User = new Bindable(); public override LocalisableString TooltipText => FriendsStrings.ButtonsDisabled; @@ -22,7 +21,7 @@ namespace osu.Game.Overlays.Profile.Header.Components private void load() { // todo: when friending/unfriending is implemented, the APIAccess.Friends list should be updated accordingly. - User.BindValueChanged(user => SetValue(user.NewValue?.FollowerCount ?? 0), true); + User.BindValueChanged(user => SetValue(user.NewValue?.User.FollowerCount ?? 0), true); } } } diff --git a/osu.Game/Overlays/Profile/Header/Components/GroupBadge.cs b/osu.Game/Overlays/Profile/Header/Components/GroupBadge.cs new file mode 100644 index 0000000000..4e17627e04 --- /dev/null +++ b/osu.Game/Overlays/Profile/Header/Components/GroupBadge.cs @@ -0,0 +1,98 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets; +using osuTK; + +namespace osu.Game.Overlays.Profile.Header.Components +{ + public partial class GroupBadge : Container, IHasTooltip + { + public LocalisableString TooltipText { get; private set; } + + public int TextSize { get; set; } = 12; + + private readonly APIUserGroup group; + + public GroupBadge(APIUserGroup group) + { + this.group = group; + + AutoSizeAxes = Axes.Both; + Masking = true; + CornerRadius = 8; + + TooltipText = group.Name; + + if (group.IsProbationary) + { + Alpha = 0.6f; + } + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider? colourProvider, RulesetStore rulesets) + { + FillFlowContainer innerContainer; + + AddRangeInternal(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider?.Background6 ?? Colour4.Black, + // Normal badges background opacity is 75%, probationary is full opacity as the whole badge gets a bit transparent + // Goal is to match osu-web so this is the most accurate it can be, its a bit scuffed but it is what it is + // Source: https://github.com/ppy/osu-web/blob/master/resources/css/bem/user-group-badge.less#L50 + Alpha = group.IsProbationary ? 1 : 0.75f, + }, + innerContainer = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Padding = new MarginPadding { Vertical = 2, Horizontal = 10 }, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Children = new[] + { + new OsuSpriteText + { + Text = group.ShortName, + Colour = Color4Extensions.FromHex(group.Colour ?? Colour4.White.ToHex()), + Shadow = false, + Font = OsuFont.GetFont(size: TextSize, weight: FontWeight.Bold, italics: true) + } + } + } + }); + + if (group.Playmodes?.Length > 0) + { + innerContainer.AddRange(group.Playmodes.Select(p => + (rulesets.GetRuleset(p)?.CreateInstance().CreateIcon() ?? new SpriteIcon { Icon = FontAwesome.Regular.QuestionCircle }).With(icon => + { + icon.Size = new Vector2(TextSize - 1); + })).ToList() + ); + + var badgeModesList = group.Playmodes.Select(p => rulesets.GetRuleset(p)?.Name).ToList(); + + string modesDisplay = string.Join(", ", badgeModesList); + TooltipText += $" ({modesDisplay})"; + } + } + } +} diff --git a/osu.Game/Overlays/Profile/Header/Components/GroupBadgeFlow.cs b/osu.Game/Overlays/Profile/Header/Components/GroupBadgeFlow.cs new file mode 100644 index 0000000000..33b3de94db --- /dev/null +++ b/osu.Game/Overlays/Profile/Header/Components/GroupBadgeFlow.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; +using osuTK; + +namespace osu.Game.Overlays.Profile.Header.Components +{ + public partial class GroupBadgeFlow : FillFlowContainer + { + public readonly Bindable User = new Bindable(); + + public GroupBadgeFlow() + { + AutoSizeAxes = Axes.Both; + Direction = FillDirection.Horizontal; + Spacing = new Vector2(2); + + User.BindValueChanged(user => + { + Clear(true); + + if (user.NewValue?.Groups != null) + AddRange(user.NewValue.Groups.Select(g => new GroupBadge(g))); + }); + } + } +} diff --git a/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs b/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs index e1302788da..9b4df7672d 100644 --- a/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs +++ b/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; @@ -11,18 +12,23 @@ using osu.Framework.Graphics.Textures; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Resources.Localisation.Web; +using osu.Game.Scoring; +using osu.Game.Users; namespace osu.Game.Overlays.Profile.Header.Components { - public class LevelBadge : CompositeDrawable, IHasTooltip + public partial class LevelBadge : CompositeDrawable, IHasTooltip { - public readonly Bindable User = new Bindable(); + public readonly Bindable LevelInfo = new Bindable(); public LocalisableString TooltipText { get; private set; } - private OsuSpriteText levelText; + private OsuSpriteText levelText = null!; + private Sprite sprite = null!; + + [Resolved] + private OsuColour osuColour { get; set; } = null!; public LevelBadge() { @@ -34,7 +40,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { InternalChildren = new Drawable[] { - new Sprite + sprite = new Sprite { RelativeSizeAxes = Axes.Both, Texture = textures.Get("Profile/levelbadge"), @@ -47,14 +53,45 @@ namespace osu.Game.Overlays.Profile.Header.Components Font = OsuFont.GetFont(size: 20) } }; - - User.BindValueChanged(user => updateLevel(user.NewValue)); } - private void updateLevel(APIUser user) + protected override void LoadComplete() { - levelText.Text = user?.Statistics?.Level.Current.ToString() ?? "0"; - TooltipText = UsersStrings.ShowStatsLevel(user?.Statistics?.Level.Current.ToString()); + base.LoadComplete(); + + LevelInfo.BindValueChanged(level => updateLevel(level.NewValue), true); + } + + private void updateLevel(UserStatistics.LevelInfo? levelInfo) + { + int level = levelInfo?.Current ?? 0; + + levelText.Text = level.ToString(); + TooltipText = UsersStrings.ShowStatsLevel(level.ToString()); + + sprite.Colour = mapLevelToTierColour(level); + } + + private ColourInfo mapLevelToTierColour(int level) + { + var tier = RankingTier.Iron; + + if (level > 0) + { + tier = (RankingTier)(level / 20); + } + + if (level >= 105) + { + tier = RankingTier.Radiant; + } + + if (level >= 110) + { + tier = RankingTier.Lustrous; + } + + return osuColour.ForRankingTier(tier); } } } diff --git a/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs b/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs index d8eb5b65ac..919ccb0dd4 100644 --- a/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs +++ b/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs @@ -17,14 +17,14 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Profile.Header.Components { - public class LevelProgressBar : CompositeDrawable, IHasTooltip + public partial class LevelProgressBar : CompositeDrawable, IHasTooltip { - public readonly Bindable User = new Bindable(); + public readonly Bindable User = new Bindable(); public LocalisableString TooltipText { get; } - private Bar levelProgressBar; - private OsuSpriteText levelProgressText; + private Bar levelProgressBar = null!; + private OsuSpriteText levelProgressText = null!; public LevelProgressBar() { @@ -56,10 +56,10 @@ namespace osu.Game.Overlays.Profile.Header.Components } }; - User.BindValueChanged(user => updateProgress(user.NewValue)); + User.BindValueChanged(user => updateProgress(user.NewValue?.User)); } - private void updateProgress(APIUser user) + private void updateProgress(APIUser? user) { levelProgressBar.Length = user?.Statistics?.Level.Progress / 100f ?? 0; levelProgressText.Text = user?.Statistics?.Level.Progress.ToLocalisableString("0'%'") ?? default; diff --git a/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs b/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs new file mode 100644 index 0000000000..b89973c5e5 --- /dev/null +++ b/osu.Game/Overlays/Profile/Header/Components/MainDetails.cs @@ -0,0 +1,186 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Leaderboards; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Scoring; +using osuTK; + +namespace osu.Game.Overlays.Profile.Header.Components +{ + public partial class MainDetails : CompositeDrawable + { + private readonly Dictionary scoreRankInfos = new Dictionary(); + private ProfileValueDisplay medalInfo = null!; + private ProfileValueDisplay ppInfo = null!; + private ProfileValueDisplay detailGlobalRank = null!; + private ProfileValueDisplay detailCountryRank = null!; + private RankGraph rankGraph = null!; + + public readonly Bindable User = new Bindable(); + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Y; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + AutoSizeDuration = 200, + AutoSizeEasing = Easing.OutQuint, + Masking = true, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 15), + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(20), + Children = new Drawable[] + { + detailGlobalRank = new ProfileValueDisplay(true) + { + Title = UsersStrings.ShowRankGlobalSimple, + }, + detailCountryRank = new ProfileValueDisplay(true) + { + Title = UsersStrings.ShowRankCountrySimple, + }, + } + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = 60, + Children = new Drawable[] + { + rankGraph = new RankGraph + { + RelativeSizeAxes = Axes.Both, + }, + } + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + medalInfo = new ProfileValueDisplay + { + Title = UsersStrings.ShowStatsMedals, + }, + ppInfo = new ProfileValueDisplay + { + Title = "pp", + }, + new TotalPlayTime + { + User = { BindTarget = User } + }, + } + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Children = new[] + { + scoreRankInfos[ScoreRank.XH] = new ScoreRankInfo(ScoreRank.XH), + scoreRankInfos[ScoreRank.X] = new ScoreRankInfo(ScoreRank.X), + scoreRankInfos[ScoreRank.SH] = new ScoreRankInfo(ScoreRank.SH), + scoreRankInfos[ScoreRank.S] = new ScoreRankInfo(ScoreRank.S), + scoreRankInfos[ScoreRank.A] = new ScoreRankInfo(ScoreRank.A), + } + } + } + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + User.BindValueChanged(e => updateDisplay(e.NewValue), true); + } + + private void updateDisplay(UserProfileData? data) + { + var user = data?.User; + + medalInfo.Content = user?.Achievements?.Length.ToString() ?? "0"; + ppInfo.Content = user?.Statistics?.PP?.ToLocalisableString("#,##0") ?? (LocalisableString)"0"; + + foreach (var scoreRankInfo in scoreRankInfos) + scoreRankInfo.Value.RankCount = user?.Statistics?.GradesCount[scoreRankInfo.Key] ?? 0; + + detailGlobalRank.Content = user?.Statistics?.GlobalRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; + detailCountryRank.Content = user?.Statistics?.CountryRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; + + rankGraph.Statistics.Value = user?.Statistics; + } + + private partial class ScoreRankInfo : CompositeDrawable + { + private readonly OsuSpriteText rankCount; + + public int RankCount + { + set => rankCount.Text = value.ToLocalisableString("#,##0"); + } + + public ScoreRankInfo(ScoreRank rank) + { + AutoSizeAxes = Axes.Both; + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + Width = 44, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new DrawableRank(rank) + { + RelativeSizeAxes = Axes.X, + Height = 22, + }, + rankCount = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre + } + } + }; + } + } + } +} diff --git a/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs b/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs index 6d1024ff18..d509ec0f81 100644 --- a/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs @@ -5,14 +5,13 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Header.Components { - public class MappingSubscribersButton : ProfileHeaderStatisticsButton + public partial class MappingSubscribersButton : ProfileHeaderStatisticsButton { - public readonly Bindable User = new Bindable(); + public readonly Bindable User = new Bindable(); public override LocalisableString TooltipText => FollowsStrings.MappingFollowers; @@ -21,7 +20,7 @@ namespace osu.Game.Overlays.Profile.Header.Components [BackgroundDependencyLoader] private void load() { - User.BindValueChanged(user => SetValue(user.NewValue?.MappingFollowerCount ?? 0), true); + User.BindValueChanged(user => SetValue(user.NewValue?.User.MappingFollowerCount ?? 0), true); } } } diff --git a/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs b/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs index e3dc5f818a..5f934e3916 100644 --- a/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs @@ -7,30 +7,29 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Resources.Localisation.Web; using osuTK; namespace osu.Game.Overlays.Profile.Header.Components { - public class MessageUserButton : ProfileHeaderButton + public partial class MessageUserButton : ProfileHeaderButton { - public readonly Bindable User = new Bindable(); + public readonly Bindable User = new Bindable(); public override LocalisableString TooltipText => UsersStrings.CardSendMessage; - [Resolved(CanBeNull = true)] - private ChannelManager channelManager { get; set; } - - [Resolved(CanBeNull = true)] - private UserProfileOverlay userOverlay { get; set; } - - [Resolved(CanBeNull = true)] - private ChatOverlay chatOverlay { get; set; } + [Resolved] + private ChannelManager? channelManager { get; set; } [Resolved] - private IAPIProvider apiProvider { get; set; } + private UserProfileOverlay? userOverlay { get; set; } + + [Resolved] + private ChatOverlay? chatOverlay { get; set; } + + [Resolved] + private IAPIProvider apiProvider { get; set; } = null!; public MessageUserButton() { @@ -49,12 +48,16 @@ namespace osu.Game.Overlays.Profile.Header.Components { if (!Content.IsPresent) return; - channelManager?.OpenPrivateChannel(User.Value); + channelManager?.OpenPrivateChannel(User.Value?.User); userOverlay?.Hide(); chatOverlay?.Show(); }; - User.ValueChanged += e => Content.Alpha = !e.NewValue.PMFriendsOnly && apiProvider.LocalUser.Value.Id != e.NewValue.Id ? 1 : 0; + User.ValueChanged += e => + { + var user = e.NewValue?.User; + Content.Alpha = user != null && !user.PMFriendsOnly && apiProvider.LocalUser.Value.Id != user.Id ? 1 : 0; + }; } } } diff --git a/osu.Game/Overlays/Profile/Header/Components/PreviousUsernames.cs b/osu.Game/Overlays/Profile/Header/Components/PreviousUsernames.cs index 49744e885a..b722fe92e0 100644 --- a/osu.Game/Overlays/Profile/Header/Components/PreviousUsernames.cs +++ b/osu.Game/Overlays/Profile/Header/Components/PreviousUsernames.cs @@ -18,14 +18,14 @@ using osuTK; namespace osu.Game.Overlays.Profile.Header.Components { - public class PreviousUsernames : CompositeDrawable + public partial class PreviousUsernames : CompositeDrawable { private const int duration = 200; private const int margin = 10; private const int width = 310; private const int move_offset = 15; - public readonly Bindable User = new Bindable(); + public readonly Bindable User = new Bindable(); private readonly TextFlowContainer text; private readonly Box background; @@ -107,11 +107,11 @@ namespace osu.Game.Overlays.Profile.Header.Components User.BindValueChanged(onUserChanged, true); } - private void onUserChanged(ValueChangedEvent user) + private void onUserChanged(ValueChangedEvent user) { text.Text = string.Empty; - string[] usernames = user.NewValue?.PreviousUsernames; + string[]? usernames = user.NewValue?.PreviousUsernames; if (usernames?.Any() ?? false) { @@ -145,9 +145,9 @@ namespace osu.Game.Overlays.Profile.Header.Components this.MoveToY(0, duration, Easing.OutQuint); } - private class HoverIconContainer : Container + private partial class HoverIconContainer : Container { - public Action ActivateHover; + public Action? ActivateHover; public HoverIconContainer() { diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs index cea63574cf..414ca4d077 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs @@ -10,7 +10,7 @@ using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.Profile.Header.Components { - public abstract class ProfileHeaderButton : OsuHoverContainer + public abstract partial class ProfileHeaderButton : OsuHoverContainer { private readonly Box background; private readonly Container content; diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs index b098f9f840..32c5ebee2c 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs @@ -11,7 +11,7 @@ using osuTK; namespace osu.Game.Overlays.Profile.Header.Components { - public abstract class ProfileHeaderStatisticsButton : ProfileHeaderButton + public abstract partial class ProfileHeaderStatisticsButton : ProfileHeaderButton { private readonly OsuSpriteText drawableText; diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetSelector.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetSelector.cs index 4d2290266c..9f306ee20b 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetSelector.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetSelector.cs @@ -1,21 +1,39 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; +using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics.UserInterface; -using osu.Game.Online.API.Requests.Responses; +using osu.Game.Extensions; using osu.Game.Rulesets; namespace osu.Game.Overlays.Profile.Header.Components { - public class ProfileRulesetSelector : OverlayRulesetSelector + public partial class ProfileRulesetSelector : OverlayRulesetSelector { - public readonly Bindable User = new Bindable(); + [Resolved] + private UserProfileOverlay? profileOverlay { get; set; } + + public readonly Bindable User = new Bindable(); protected override void LoadComplete() { base.LoadComplete(); - User.BindValueChanged(u => SetDefaultRuleset(Rulesets.GetRuleset(u.NewValue?.PlayMode ?? "osu")), true); + + User.BindValueChanged(user => updateState(user.NewValue), true); + Current.BindValueChanged(ruleset => + { + if (User.Value != null && !ruleset.NewValue.Equals(User.Value.Ruleset)) + profileOverlay?.ShowUser(User.Value.User, ruleset.NewValue); + }); + } + + private void updateState(UserProfileData? user) + { + Current.Value = Items.SingleOrDefault(ruleset => user?.Ruleset.MatchesOnlineID(ruleset) == true); + SetDefaultRuleset(Rulesets.GetRuleset(user?.User.PlayMode ?? @"osu").AsNonNull()); } public void SetDefaultRuleset(RulesetInfo ruleset) diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetTabItem.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetTabItem.cs index 4a44e285bf..9caa7dd1bc 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetTabItem.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetTabItem.cs @@ -12,7 +12,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Profile.Header.Components { - public class ProfileRulesetTabItem : OverlayRulesetTabItem + public partial class ProfileRulesetTabItem : OverlayRulesetTabItem { private bool isDefault; @@ -48,7 +48,7 @@ namespace osu.Game.Overlays.Profile.Header.Components Add(icon = new DefaultRulesetIcon { Alpha = 0 }); } - public class DefaultRulesetIcon : SpriteIcon, IHasTooltip + public partial class DefaultRulesetIcon : SpriteIcon, IHasTooltip { public LocalisableString TooltipText => UsersStrings.ShowEditDefaultPlaymodeIsDefaultTooltip; diff --git a/osu.Game/Overlays/Profile/Header/Components/OverlinedInfoContainer.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileValueDisplay.cs similarity index 65% rename from osu.Game/Overlays/Profile/Header/Components/OverlinedInfoContainer.cs rename to osu.Game/Overlays/Profile/Header/Components/ProfileValueDisplay.cs index 5ef8482b47..4b1a0409a3 100644 --- a/osu.Game/Overlays/Profile/Header/Components/OverlinedInfoContainer.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ProfileValueDisplay.cs @@ -1,19 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osuTK.Graphics; namespace osu.Game.Overlays.Profile.Header.Components { - public class OverlinedInfoContainer : CompositeDrawable + public partial class ProfileValueDisplay : CompositeDrawable { - private readonly Circle line; private readonly OsuSpriteText title; private readonly OsuSpriteText content; @@ -27,12 +25,7 @@ namespace osu.Game.Overlays.Profile.Header.Components set => content.Text = value; } - public Color4 LineColour - { - set => line.Colour = value; - } - - public OverlinedInfoContainer(bool big = false, int minimumWidth = 60) + public ProfileValueDisplay(bool big = false, int minimumWidth = 60) { AutoSizeAxes = Axes.Both; InternalChild = new FillFlowContainer @@ -41,19 +34,13 @@ namespace osu.Game.Overlays.Profile.Header.Components AutoSizeAxes = Axes.Both, Children = new Drawable[] { - line = new Circle - { - RelativeSizeAxes = Axes.X, - Height = 2, - Margin = new MarginPadding { Bottom = 2 } - }, title = new OsuSpriteText { - Font = OsuFont.GetFont(size: big ? 14 : 12, weight: FontWeight.Bold) + Font = OsuFont.GetFont(size: 12) }, content = new OsuSpriteText { - Font = OsuFont.GetFont(size: big ? 40 : 18, weight: FontWeight.Light) + Font = OsuFont.GetFont(size: big ? 30 : 20, weight: FontWeight.Light) }, new Container // Add a minimum size to the FillFlowContainer { @@ -62,5 +49,12 @@ namespace osu.Game.Overlays.Profile.Header.Components } }; } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + title.Colour = colourProvider.Content1; + content.Colour = colourProvider.Content2; + } } } diff --git a/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs b/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs index d195babcbf..4f3f1ac2c3 100644 --- a/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs +++ b/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs @@ -15,11 +15,11 @@ using osu.Game.Users; namespace osu.Game.Overlays.Profile.Header.Components { - public class RankGraph : UserGraph + public partial class RankGraph : UserGraph { private const int ranked_days = 88; - public readonly Bindable Statistics = new Bindable(); + public readonly Bindable Statistics = new Bindable(); private readonly OsuSpriteText placeholder; @@ -40,11 +40,11 @@ namespace osu.Game.Overlays.Profile.Header.Components Statistics.BindValueChanged(statistics => updateStatistics(statistics.NewValue), true); } - private void updateStatistics(UserStatistics statistics) + private void updateStatistics(UserStatistics? statistics) { // checking both IsRanked and RankHistory is required. // see https://github.com/ppy/osu-web/blob/154ceafba0f35a1dd935df53ec98ae2ea5615f9f/resources/assets/lib/profile-page/rank-chart.tsx#L46 - int[] userRanks = statistics?.IsRanked == true ? statistics.RankHistory?.Data : null; + int[]? userRanks = statistics?.IsRanked == true ? statistics.RankHistory?.Data : null; Data = userRanks?.Select((x, index) => new KeyValuePair(index, x)).Where(x => x.Value != 0).ToArray(); } @@ -66,10 +66,12 @@ namespace osu.Game.Overlays.Profile.Header.Components { int days = ranked_days - index + 1; - return new UserGraphTooltipContent( - UsersStrings.ShowRankGlobalSimple, - rank.ToLocalisableString("\\##,##0"), - days == 0 ? "now" : $"{"day".ToQuantity(days)} ago"); + return new UserGraphTooltipContent + { + Name = UsersStrings.ShowRankGlobalSimple, + Count = rank.ToLocalisableString("\\##,##0"), + Time = days == 0 ? "now" : $"{"day".ToQuantity(days)} ago", + }; } } } diff --git a/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs b/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs index f450754c02..92e2017659 100644 --- a/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs +++ b/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs @@ -15,7 +15,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Header.Components { - public class SupporterIcon : OsuClickableContainer + public partial class SupporterIcon : OsuClickableContainer { private readonly Box background; private readonly FillFlowContainer iconContainer; @@ -80,10 +80,10 @@ namespace osu.Game.Overlays.Profile.Header.Components } [Resolved] - private OsuColour colours { get; set; } + private OsuColour colours { get; set; } = null!; - [BackgroundDependencyLoader(true)] - private void load(OsuGame game) + [BackgroundDependencyLoader] + private void load(OsuGame? game) { background.Colour = colours.Pink; diff --git a/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs b/osu.Game/Overlays/Profile/Header/Components/ToggleCoverButton.cs similarity index 62% rename from osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs rename to osu.Game/Overlays/Profile/Header/Components/ToggleCoverButton.cs index 4cfdf5cc86..9171d5de7d 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ToggleCoverButton.cs @@ -5,7 +5,6 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; @@ -15,24 +14,24 @@ using osuTK; namespace osu.Game.Overlays.Profile.Header.Components { - public class ExpandDetailsButton : ProfileHeaderButton + public partial class ToggleCoverButton : ProfileHeaderButton { - public readonly BindableBool DetailsVisible = new BindableBool(); + public readonly BindableBool CoverExpanded = new BindableBool(true); - public override LocalisableString TooltipText => DetailsVisible.Value ? CommonStrings.ButtonsCollapse : CommonStrings.ButtonsExpand; + public override LocalisableString TooltipText => CoverExpanded.Value ? UsersStrings.ShowCoverTo0 : UsersStrings.ShowCoverTo1; - private SpriteIcon icon; - private Sample sampleOpen; - private Sample sampleClose; + private SpriteIcon icon = null!; + private Sample? sampleOpen; + private Sample? sampleClose; protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverClickSounds(); - public ExpandDetailsButton() + public ToggleCoverButton() { Action = () => { - DetailsVisible.Toggle(); - (DetailsVisible.Value ? sampleOpen : sampleClose)?.Play(); + CoverExpanded.Toggle(); + (CoverExpanded.Value ? sampleOpen : sampleClose)?.Play(); }; } @@ -40,19 +39,21 @@ namespace osu.Game.Overlays.Profile.Header.Components private void load(OverlayColourProvider colourProvider, AudioManager audio) { IdleColour = colourProvider.Background2; - HoverColour = colourProvider.Background2.Lighten(0.2f); + HoverColour = colourProvider.Background1; sampleOpen = audio.Samples.Get(@"UI/dropdown-open"); sampleClose = audio.Samples.Get(@"UI/dropdown-close"); + AutoSizeAxes = Axes.None; + Size = new Vector2(30); Child = icon = new SpriteIcon { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(20, 12) + Size = new Vector2(10.5f, 12) }; - DetailsVisible.BindValueChanged(visible => updateState(visible.NewValue), true); + CoverExpanded.BindValueChanged(visible => updateState(visible.NewValue), true); } private void updateState(bool detailsVisible) => icon.Icon = detailsVisible ? FontAwesome.Solid.ChevronUp : FontAwesome.Solid.ChevronDown; diff --git a/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs b/osu.Game/Overlays/Profile/Header/Components/TotalPlayTime.cs similarity index 67% rename from osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs rename to osu.Game/Overlays/Profile/Header/Components/TotalPlayTime.cs index 1ead2cd2f5..08ca59d89b 100644 --- a/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs +++ b/osu.Game/Overlays/Profile/Header/Components/TotalPlayTime.cs @@ -7,20 +7,19 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Localisation; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Header.Components { - public class OverlinedTotalPlayTime : CompositeDrawable, IHasTooltip + public partial class TotalPlayTime : CompositeDrawable, IHasTooltip { - public readonly Bindable User = new Bindable(); + public readonly Bindable User = new Bindable(); public LocalisableString TooltipText { get; set; } - private OverlinedInfoContainer info; + private ProfileValueDisplay info = null!; - public OverlinedTotalPlayTime() + public TotalPlayTime() { AutoSizeAxes = Axes.Both; @@ -28,21 +27,21 @@ namespace osu.Game.Overlays.Profile.Header.Components } [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) + private void load() { - InternalChild = info = new OverlinedInfoContainer + InternalChild = info = new ProfileValueDisplay(minimumWidth: 140) { Title = UsersStrings.ShowStatsPlayTime, - LineColour = colourProvider.Highlight1, }; User.BindValueChanged(updateTime, true); } - private void updateTime(ValueChangedEvent user) + private void updateTime(ValueChangedEvent user) { - TooltipText = (user.NewValue?.Statistics?.PlayTime ?? 0) / 3600 + " hours"; - info.Content = formatTime(user.NewValue?.Statistics?.PlayTime); + int? playTime = user.NewValue?.User.Statistics?.PlayTime; + TooltipText = (playTime ?? 0) / 3600 + " hours"; + info.Content = formatTime(playTime); } private string formatTime(int? secondsNull) diff --git a/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs index 79e553062d..1f35f39b49 100644 --- a/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs @@ -1,68 +1,24 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Localisation; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Online.Leaderboards; using osu.Game.Overlays.Profile.Header.Components; -using osu.Game.Resources.Localisation.Web; -using osu.Game.Scoring; -using osuTK; namespace osu.Game.Overlays.Profile.Header { - public class DetailHeaderContainer : CompositeDrawable + public partial class DetailHeaderContainer : CompositeDrawable { - private readonly Dictionary scoreRankInfos = new Dictionary(); - private OverlinedInfoContainer medalInfo; - private OverlinedInfoContainer ppInfo; - private OverlinedInfoContainer detailGlobalRank; - private OverlinedInfoContainer detailCountryRank; - private FillFlowContainer fillFlow; - private RankGraph rankGraph; - - public readonly Bindable User = new Bindable(); - - private bool expanded = true; - - public bool Expanded - { - set - { - if (expanded == value) return; - - expanded = value; - - if (fillFlow == null) return; - - fillFlow.ClearTransforms(); - - if (expanded) - fillFlow.AutoSizeAxes = Axes.Y; - else - { - fillFlow.AutoSizeAxes = Axes.None; - fillFlow.ResizeHeightTo(0, 200, Easing.OutQuint); - } - } - } + public readonly Bindable User = new Bindable(); [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider, OsuColour colours) + private void load(OverlayColourProvider colourProvider) { AutoSizeAxes = Axes.Y; - User.ValueChanged += e => updateDisplay(e.NewValue); - InternalChildren = new Drawable[] { new Box @@ -70,154 +26,52 @@ namespace osu.Game.Overlays.Profile.Header RelativeSizeAxes = Axes.Both, Colour = colourProvider.Background5, }, - fillFlow = new FillFlowContainer + new Container { RelativeSizeAxes = Axes.X, - AutoSizeAxes = expanded ? Axes.Y : Axes.None, - AutoSizeDuration = 200, - AutoSizeEasing = Easing.OutQuint, - Masking = true, - Padding = new MarginPadding { Horizontal = UserProfileOverlay.CONTENT_X_MARGIN, Vertical = 10 }, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 20), - Children = new Drawable[] + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING, Vertical = 10 }, + Child = new GridContainer { - new Container + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] + new Dimension(GridSizeMode.AutoSize), + }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] { - new FillFlowContainer + new MainDetails + { + RelativeSizeAxes = Axes.X, + User = { BindTarget = User } + }, + new Box + { + RelativeSizeAxes = Axes.Y, + Width = 2, + Colour = colourProvider.Background6, + Margin = new MarginPadding { Horizontal = 15 } + }, + new ExtendedDetails { - AutoSizeAxes = Axes.Both, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10, 0), - Children = new Drawable[] - { - new OverlinedTotalPlayTime - { - User = { BindTarget = User } - }, - medalInfo = new OverlinedInfoContainer - { - Title = UsersStrings.ShowStatsMedals, - LineColour = colours.GreenLight, - }, - ppInfo = new OverlinedInfoContainer - { - Title = "pp", - LineColour = colours.Red, - }, - } - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5), - Children = new[] - { - scoreRankInfos[ScoreRank.XH] = new ScoreRankInfo(ScoreRank.XH), - scoreRankInfos[ScoreRank.X] = new ScoreRankInfo(ScoreRank.X), - scoreRankInfos[ScoreRank.SH] = new ScoreRankInfo(ScoreRank.SH), - scoreRankInfos[ScoreRank.S] = new ScoreRankInfo(ScoreRank.S), - scoreRankInfos[ScoreRank.A] = new ScoreRankInfo(ScoreRank.A), - } + User = { BindTarget = User } } } - }, - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Right = 130 }, - Children = new Drawable[] - { - rankGraph = new RankGraph - { - RelativeSizeAxes = Axes.Both, - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Y, - Width = 130, - Anchor = Anchor.TopRight, - Direction = FillDirection.Vertical, - Padding = new MarginPadding { Horizontal = 10 }, - Spacing = new Vector2(0, 20), - Children = new Drawable[] - { - detailGlobalRank = new OverlinedInfoContainer(true, 110) - { - Title = UsersStrings.ShowRankGlobalSimple, - LineColour = colourProvider.Highlight1, - }, - detailCountryRank = new OverlinedInfoContainer(false, 110) - { - Title = UsersStrings.ShowRankCountrySimple, - LineColour = colourProvider.Highlight1, - }, - } - } - } - }, - } - }, - }; - } - - private void updateDisplay(APIUser user) - { - medalInfo.Content = user?.Achievements?.Length.ToString() ?? "0"; - ppInfo.Content = user?.Statistics?.PP?.ToLocalisableString("#,##0") ?? (LocalisableString)"0"; - - foreach (var scoreRankInfo in scoreRankInfos) - scoreRankInfo.Value.RankCount = user?.Statistics?.GradesCount[scoreRankInfo.Key] ?? 0; - - detailGlobalRank.Content = user?.Statistics?.GlobalRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; - detailCountryRank.Content = user?.Statistics?.CountryRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; - - rankGraph.Statistics.Value = user?.Statistics; - } - - private class ScoreRankInfo : CompositeDrawable - { - private readonly OsuSpriteText rankCount; - - public int RankCount - { - set => rankCount.Text = value.ToLocalisableString("#,##0"); - } - - public ScoreRankInfo(ScoreRank rank) - { - AutoSizeAxes = Axes.Both; - InternalChild = new FillFlowContainer - { - AutoSizeAxes = Axes.Y, - Width = 56, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - new DrawableRank(rank) - { - RelativeSizeAxes = Axes.X, - Height = 30, - }, - rankCount = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre } } - }; - } + } + }; } } } diff --git a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs index 6ba5cb2f7f..d04329430b 100644 --- a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs @@ -3,216 +3,230 @@ 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.Effects; using osu.Framework.Graphics.Shapes; -using osu.Framework.Localisation; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Profile.Header.Components; -using osu.Game.Resources.Localisation.Web; +using osu.Game.Users; using osu.Game.Users.Drawables; using osuTK; namespace osu.Game.Overlays.Profile.Header { - public class TopHeaderContainer : CompositeDrawable + public partial class TopHeaderContainer : CompositeDrawable { - private const float avatar_size = 110; + private const float content_height = 65; + private const float vertical_padding = 10; - public readonly Bindable User = new Bindable(); + public readonly Bindable User = new Bindable(); [Resolved] - private IAPIProvider api { get; set; } + private IAPIProvider api { get; set; } = null!; - private SupporterIcon supporterTag; - private UpdateableAvatar avatar; - private OsuSpriteText usernameText; - private ExternalLinkButton openUserExternally; - private OsuSpriteText titleText; - private UpdateableFlag userFlag; - private OsuSpriteText userCountryText; - private FillFlowContainer userStats; + private UserCoverBackground cover = null!; + private SupporterIcon supporterTag = null!; + private UpdateableAvatar avatar = null!; + private OsuSpriteText usernameText = null!; + private ExternalLinkButton openUserExternally = null!; + private OsuSpriteText titleText = null!; + private UpdateableFlag userFlag = null!; + private OsuSpriteText userCountryText = null!; + private GroupBadgeFlow groupBadgeFlow = null!; + private ToggleCoverButton coverToggle = null!; + + private Bindable coverExpanded = null!; + + private FillFlowContainer flow = null!; [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) + private void load(OverlayColourProvider colourProvider, OsuConfigManager configManager) { - Height = 150; + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + coverExpanded = configManager.GetBindable(OsuSetting.ProfileCoverExpanded); InternalChildren = new Drawable[] { new Box { RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background5, + Colour = colourProvider.Background4, }, new FillFlowContainer { - Direction = FillDirection.Horizontal, - Margin = new MarginPadding { Left = UserProfileOverlay.CONTENT_X_MARGIN }, - Height = avatar_size, - AutoSizeAxes = Axes.X, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, Children = new Drawable[] { - avatar = new UpdateableAvatar(isInteractive: false, showGuestOnNull: false) + cover = new ProfileCoverBackground { - Size = new Vector2(avatar_size), - Masking = true, - CornerRadius = avatar_size * 0.25f, + RelativeSizeAxes = Axes.X, }, new Container { - RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X, - Padding = new MarginPadding { Left = 10 }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, Children = new Drawable[] { - new FillFlowContainer + flow = new FillFlowContainer { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, + Direction = FillDirection.Horizontal, + Padding = new MarginPadding + { + Left = WaveOverlayContainer.HORIZONTAL_PADDING, + Vertical = vertical_padding + }, + Height = content_height + 2 * vertical_padding, + RelativeSizeAxes = Axes.X, Children = new Drawable[] { + avatar = new UpdateableAvatar(isInteractive: false, showGuestOnNull: false) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Masking = true, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Offset = new Vector2(0, 1), + Radius = 3, + Colour = Colour4.Black.Opacity(0.25f), + } + }, new FillFlowContainer { AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, + Direction = FillDirection.Vertical, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, Children = new Drawable[] { - usernameText = new OsuSpriteText + new FillFlowContainer { - Font = OsuFont.GetFont(size: 24, weight: FontWeight.Regular) + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5, 0), + Children = new Drawable[] + { + usernameText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 24, weight: FontWeight.Regular) + }, + supporterTag = new SupporterIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Height = 15, + }, + openUserExternally = new ExternalLinkButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + groupBadgeFlow = new GroupBadgeFlow + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + } }, - openUserExternally = new ExternalLinkButton + titleText = new OsuSpriteText { - Margin = new MarginPadding { Left = 5 }, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 16, weight: FontWeight.Regular), + Margin = new MarginPadding { Bottom = 5 } + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + userFlag = new UpdateableFlag + { + Size = new Vector2(28, 20), + ShowPlaceholderOnUnknown = false, + }, + userCountryText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 14f, weight: FontWeight.Regular), + Margin = new MarginPadding { Left = 5 }, + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + } + } }, } }, - titleText = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 18, weight: FontWeight.Regular) - }, } }, - new FillFlowContainer + coverToggle = new ToggleCoverButton { - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - Direction = FillDirection.Vertical, - AutoSizeAxes = Axes.Both, - Children = new Drawable[] - { - supporterTag = new SupporterIcon - { - Height = 20, - Margin = new MarginPadding { Top = 5 } - }, - new Box - { - RelativeSizeAxes = Axes.X, - Height = 1.5f, - Margin = new MarginPadding { Top = 10 }, - Colour = colourProvider.Light1, - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Top = 5 }, - Direction = FillDirection.Horizontal, - Children = new Drawable[] - { - userFlag = new UpdateableFlag - { - Size = new Vector2(30, 20), - ShowPlaceholderOnNull = false, - }, - userCountryText = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 17.5f, weight: FontWeight.Regular), - Margin = new MarginPadding { Left = 10 }, - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - Colour = colourProvider.Light1, - } - } - }, - } + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Margin = new MarginPadding { Right = 10 }, + CoverExpanded = { BindTarget = coverExpanded } } - } - } - } + }, + }, + }, }, - userStats = new FillFlowContainer - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - AutoSizeAxes = Axes.Y, - Width = 300, - Margin = new MarginPadding { Right = UserProfileOverlay.CONTENT_X_MARGIN }, - Padding = new MarginPadding { Vertical = 15 }, - Spacing = new Vector2(0, 2) - } }; - - User.BindValueChanged(user => updateUser(user.NewValue)); } - private void updateUser(APIUser user) + protected override void LoadComplete() { + base.LoadComplete(); + + User.BindValueChanged(user => updateUser(user.NewValue), true); + coverExpanded.BindValueChanged(_ => updateCoverState(), true); + FinishTransforms(true); + } + + private void updateUser(UserProfileData? data) + { + var user = data?.User; + + cover.User = user; avatar.User = user; usernameText.Text = user?.Username ?? string.Empty; openUserExternally.Link = $@"{api.WebsiteRootUrl}/users/{user?.Id ?? 0}"; - userFlag.Country = user?.Country; - userCountryText.Text = user?.Country?.FullName ?? "Alien"; + userFlag.CountryCode = user?.CountryCode ?? default; + userCountryText.Text = (user?.CountryCode ?? default).GetDescription(); supporterTag.SupportLevel = user?.SupportLevel ?? 0; titleText.Text = user?.Title ?? string.Empty; titleText.Colour = Color4Extensions.FromHex(user?.Colour ?? "fff"); - - userStats.Clear(); - - if (user?.Statistics != null) - { - userStats.Add(new UserStatsLine(UsersStrings.ShowStatsRankedScore, user.Statistics.RankedScore.ToLocalisableString("#,##0"))); - userStats.Add(new UserStatsLine(UsersStrings.ShowStatsHitAccuracy, user.Statistics.DisplayAccuracy)); - userStats.Add(new UserStatsLine(UsersStrings.ShowStatsPlayCount, user.Statistics.PlayCount.ToLocalisableString("#,##0"))); - userStats.Add(new UserStatsLine(UsersStrings.ShowStatsTotalScore, user.Statistics.TotalScore.ToLocalisableString("#,##0"))); - userStats.Add(new UserStatsLine(UsersStrings.ShowStatsTotalHits, user.Statistics.TotalHits.ToLocalisableString("#,##0"))); - userStats.Add(new UserStatsLine(UsersStrings.ShowStatsMaximumCombo, user.Statistics.MaxCombo.ToLocalisableString("#,##0"))); - userStats.Add(new UserStatsLine(UsersStrings.ShowStatsReplaysWatchedByOthers, user.Statistics.ReplaysWatched.ToLocalisableString("#,##0"))); - } + groupBadgeFlow.User.Value = user; } - private class UserStatsLine : Container + private void updateCoverState() { - public UserStatsLine(LocalisableString left, LocalisableString right) + const float transition_duration = 500; + + bool expanded = coverToggle.CoverExpanded.Value; + + cover.ResizeHeightTo(expanded ? 250 : 0, transition_duration, Easing.OutQuint); + avatar.ResizeTo(new Vector2(expanded ? 120 : content_height), transition_duration, Easing.OutQuint); + avatar.TransformTo(nameof(avatar.CornerRadius), expanded ? 40f : 20f, transition_duration, Easing.OutQuint); + flow.TransformTo(nameof(flow.Spacing), new Vector2(expanded ? 20f : 10f), transition_duration, Easing.OutQuint); + } + + private partial class ProfileCoverBackground : UserCoverBackground + { + protected override double LoadDelay => 0; + + public ProfileCoverBackground() { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - Children = new Drawable[] - { - new OsuSpriteText - { - Font = OsuFont.GetFont(size: 15), - Text = left, - }, - new OsuSpriteText - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Font = OsuFont.GetFont(size: 15, weight: FontWeight.Bold), - Text = right, - }, - }; + Masking = true; } } } diff --git a/osu.Game/Overlays/Profile/ProfileHeader.cs b/osu.Game/Overlays/Profile/ProfileHeader.cs index 44e0d9c37f..80d48ae09e 100644 --- a/osu.Game/Overlays/Profile/ProfileHeader.cs +++ b/osu.Game/Overlays/Profile/ProfileHeader.cs @@ -1,62 +1,39 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Diagnostics; using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Profile.Header; +using osu.Game.Overlays.Profile.Header.Components; using osu.Game.Resources.Localisation.Web; -using osu.Game.Users; namespace osu.Game.Overlays.Profile { - public class ProfileHeader : TabControlOverlayHeader + public partial class ProfileHeader : TabControlOverlayHeader { - private UserCoverBackground coverContainer; - - public Bindable User = new Bindable(); + public Bindable User = new Bindable(); private CentreHeaderContainer centreHeaderContainer; private DetailHeaderContainer detailHeaderContainer; public ProfileHeader() { - ContentSidePadding = UserProfileOverlay.CONTENT_X_MARGIN; - - User.ValueChanged += e => updateDisplay(e.NewValue); + ContentSidePadding = WaveOverlayContainer.HORIZONTAL_PADDING; TabControl.AddItem(LayoutStrings.HeaderUsersShow); // todo: pending implementation. // TabControl.AddItem(LayoutStrings.HeaderUsersModding); - centreHeaderContainer.DetailsVisible.BindValueChanged(visible => detailHeaderContainer.Expanded = visible.NewValue, true); + // Haphazardly guaranteed by OverlayHeader constructor (see CreateBackground / CreateContent). + Debug.Assert(centreHeaderContainer != null); + Debug.Assert(detailHeaderContainer != null); } - protected override Drawable CreateBackground() => - new Container - { - RelativeSizeAxes = Axes.X, - Height = 150, - Masking = true, - Children = new Drawable[] - { - coverContainer = new ProfileCoverBackground - { - RelativeSizeAxes = Axes.Both, - }, - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(Color4Extensions.FromHex("222").Opacity(0.8f), Color4Extensions.FromHex("222").Opacity(0.2f)) - }, - } - }; + protected override Drawable CreateBackground() => Empty(); protected override Drawable CreateContent() => new FillFlowContainer { @@ -70,7 +47,11 @@ namespace osu.Game.Overlays.Profile RelativeSizeAxes = Axes.X, User = { BindTarget = User }, }, - centreHeaderContainer = new CentreHeaderContainer + new BannerHeaderContainer + { + User = { BindTarget = User }, + }, + new BadgeHeaderContainer { RelativeSizeAxes = Axes.X, User = { BindTarget = User }, @@ -80,7 +61,7 @@ namespace osu.Game.Overlays.Profile RelativeSizeAxes = Axes.X, User = { BindTarget = User }, }, - new MedalHeaderContainer + centreHeaderContainer = new CentreHeaderContainer { RelativeSizeAxes = Axes.X, User = { BindTarget = User }, @@ -95,9 +76,12 @@ namespace osu.Game.Overlays.Profile protected override OverlayTitle CreateTitle() => new ProfileHeaderTitle(); - private void updateDisplay(APIUser user) => coverContainer.User = user; + protected override Drawable CreateTabControlContent() => new ProfileRulesetSelector + { + User = { BindTarget = User } + }; - private class ProfileHeaderTitle : OverlayTitle + private partial class ProfileHeaderTitle : OverlayTitle { public ProfileHeaderTitle() { @@ -105,10 +89,5 @@ namespace osu.Game.Overlays.Profile IconTexture = "Icons/Hexacons/profile"; } } - - private class ProfileCoverBackground : UserCoverBackground - { - protected override double LoadDelay => 0; - } } } diff --git a/osu.Game/Overlays/Profile/ProfileSection.cs b/osu.Game/Overlays/Profile/ProfileSection.cs index fc6fce0d8e..a8a240ddde 100644 --- a/osu.Game/Overlays/Profile/ProfileSection.cs +++ b/osu.Game/Overlays/Profile/ProfileSection.cs @@ -3,21 +3,19 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; using osu.Game.Graphics; -using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; -using osu.Game.Online.API.Requests.Responses; +using osuTK; namespace osu.Game.Overlays.Profile { - public abstract class ProfileSection : Container + public abstract partial class ProfileSection : Container { public abstract LocalisableString Title { get; } @@ -29,7 +27,9 @@ namespace osu.Game.Overlays.Profile protected override Container Content => content; - public readonly Bindable User = new Bindable(); + public readonly Bindable User = new Bindable(); + + private const float outer_gutter_width = 10; protected ProfileSection() { @@ -38,14 +38,22 @@ namespace osu.Game.Overlays.Profile InternalChildren = new Drawable[] { - background = new Box + new Container { RelativeSizeAxes = Axes.Both, - }, - new SectionTriangles - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, + Masking = true, + CornerRadius = 10, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Offset = new Vector2(0, 1), + Radius = 3, + Colour = Colour4.Black.Opacity(0.25f) + }, + Child = background = new Box + { + RelativeSizeAxes = Axes.Both, + }, }, new FillFlowContainer { @@ -59,8 +67,8 @@ namespace osu.Game.Overlays.Profile AutoSizeAxes = Axes.Both, Margin = new MarginPadding { - Horizontal = UserProfileOverlay.CONTENT_X_MARGIN, - Top = 15, + Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING - outer_gutter_width, + Top = 20, Bottom = 20, }, Children = new Drawable[] @@ -68,7 +76,7 @@ namespace osu.Game.Overlays.Profile new OsuSpriteText { Text = Title, - Font = OsuFont.GetFont(size: 20, weight: FontWeight.Bold), + Font = OsuFont.GetFont(size: 16, weight: FontWeight.Bold), }, underscore = new Box { @@ -89,7 +97,7 @@ namespace osu.Game.Overlays.Profile RelativeSizeAxes = Axes.X, Padding = new MarginPadding { - Horizontal = UserProfileOverlay.CONTENT_X_MARGIN, + Horizontal = WaveOverlayContainer.HORIZONTAL_PADDING - outer_gutter_width, Bottom = 20 } }, @@ -101,43 +109,8 @@ namespace osu.Game.Overlays.Profile [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) { - background.Colour = colourProvider.Background5; + background.Colour = colourProvider.Background4; underscore.Colour = colourProvider.Highlight1; } - - private class SectionTriangles : Container - { - private readonly Triangles triangles; - private readonly Box foreground; - - public SectionTriangles() - { - RelativeSizeAxes = Axes.X; - Height = 100; - Masking = true; - Children = new Drawable[] - { - triangles = new Triangles - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - RelativeSizeAxes = Axes.Both, - TriangleScale = 3, - }, - foreground = new Box - { - RelativeSizeAxes = Axes.Both, - } - }; - } - - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) - { - triangles.ColourLight = colourProvider.Background4; - triangles.ColourDark = colourProvider.Background5.Darken(0.2f); - foreground.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Background5.Opacity(0)); - } - } } } diff --git a/osu.Game/Overlays/Profile/Sections/AboutSection.cs b/osu.Game/Overlays/Profile/Sections/AboutSection.cs index d0d9362fd2..69a8b23412 100644 --- a/osu.Game/Overlays/Profile/Sections/AboutSection.cs +++ b/osu.Game/Overlays/Profile/Sections/AboutSection.cs @@ -6,7 +6,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections { - public class AboutSection : ProfileSection + public partial class AboutSection : ProfileSection { public override LocalisableString Title => UsersStrings.ShowExtraMeTitle; diff --git a/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs b/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs index 13465f3bf8..499c572eac 100644 --- a/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs @@ -12,7 +12,7 @@ namespace osu.Game.Overlays.Profile.Sections /// /// Display artist/title/mapper information, commonly used as the left portion of a profile or score display row. /// - public abstract class BeatmapMetadataContainer : OsuHoverContainer + public abstract partial class BeatmapMetadataContainer : OsuHoverContainer { private readonly IBeatmapInfo beatmapInfo; @@ -23,8 +23,8 @@ namespace osu.Game.Overlays.Profile.Sections AutoSizeAxes = Axes.Both; } - [BackgroundDependencyLoader(true)] - private void load(BeatmapSetOverlay beatmapSetOverlay) + [BackgroundDependencyLoader] + private void load(BeatmapSetOverlay? beatmapSetOverlay) { Action = () => { diff --git a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs index 8224cd5eb5..b237a0ee05 100644 --- a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs @@ -15,14 +15,14 @@ using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Overlays.Profile.Sections.Beatmaps { - public class PaginatedBeatmapContainer : PaginatedProfileSubsection + public partial class PaginatedBeatmapContainer : PaginatedProfileSubsection { private const float panel_padding = 10f; private readonly BeatmapSetType type; protected override int InitialItemsCount => type == BeatmapSetType.Graveyard ? 2 : 6; - public PaginatedBeatmapContainer(BeatmapSetType type, Bindable user, LocalisableString headerText) + public PaginatedBeatmapContainer(BeatmapSetType type, Bindable user, LocalisableString headerText) : base(user, headerText) { this.type = type; @@ -56,15 +56,18 @@ namespace osu.Game.Overlays.Profile.Sections.Beatmaps case BeatmapSetType.Guest: return user.GuestBeatmapsetCount; + case BeatmapSetType.Nominated: + return user.NominatedBeatmapsetCount; + default: return 0; } } - protected override APIRequest> CreateRequest(PaginationParameters pagination) => - new GetUserBeatmapsRequest(User.Value.Id, type, pagination); + protected override APIRequest> CreateRequest(UserProfileData user, PaginationParameters pagination) => + new GetUserBeatmapsRequest(user.User.Id, type, pagination); - protected override Drawable CreateDrawableItem(APIBeatmapSet model) => model.OnlineID > 0 + protected override Drawable? CreateDrawableItem(APIBeatmapSet model) => model.OnlineID > 0 ? new BeatmapCardNormal(model) { Anchor = Anchor.TopCentre, diff --git a/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs b/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs index 6b93c24a78..3b304a79ef 100644 --- a/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs +++ b/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs @@ -8,7 +8,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections { - public class BeatmapsSection : ProfileSection + public partial class BeatmapsSection : ProfileSection { public override LocalisableString Title => UsersStrings.ShowExtraBeatmapsTitle; @@ -23,7 +23,8 @@ namespace osu.Game.Overlays.Profile.Sections new PaginatedBeatmapContainer(BeatmapSetType.Loved, User, UsersStrings.ShowExtraBeatmapsLovedTitle), new PaginatedBeatmapContainer(BeatmapSetType.Guest, User, UsersStrings.ShowExtraBeatmapsGuestTitle), new PaginatedBeatmapContainer(BeatmapSetType.Pending, User, UsersStrings.ShowExtraBeatmapsPendingTitle), - new PaginatedBeatmapContainer(BeatmapSetType.Graveyard, User, UsersStrings.ShowExtraBeatmapsGraveyardTitle) + new PaginatedBeatmapContainer(BeatmapSetType.Graveyard, User, UsersStrings.ShowExtraBeatmapsGraveyardTitle), + new PaginatedBeatmapContainer(BeatmapSetType.Nominated, User, UsersStrings.ShowExtraBeatmapsNominatedTitle), }; } } diff --git a/osu.Game/Overlays/Profile/Sections/CounterPill.cs b/osu.Game/Overlays/Profile/Sections/CounterPill.cs index bd6cb4d09b..74cd4b218b 100644 --- a/osu.Game/Overlays/Profile/Sections/CounterPill.cs +++ b/osu.Game/Overlays/Profile/Sections/CounterPill.cs @@ -12,11 +12,11 @@ using osu.Framework.Extensions.LocalisationExtensions; namespace osu.Game.Overlays.Profile.Sections { - public class CounterPill : CircularContainer + public partial class CounterPill : CircularContainer { public readonly BindableInt Current = new BindableInt(); - private OsuSpriteText counter; + private OsuSpriteText counter = null!; [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) diff --git a/osu.Game/Overlays/Profile/Sections/Historical/ChartProfileSubsection.cs b/osu.Game/Overlays/Profile/Sections/Historical/ChartProfileSubsection.cs index 4edbdc31bd..583006031c 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/ChartProfileSubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/ChartProfileSubsection.cs @@ -11,16 +11,16 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Overlays.Profile.Sections.Historical { - public abstract class ChartProfileSubsection : ProfileSubsection + public abstract partial class ChartProfileSubsection : ProfileSubsection { - private ProfileLineChart chart; + private ProfileLineChart chart = null!; /// /// Text describing the value being plotted on the graph, which will be displayed as a prefix to the value in the history graph tooltip. /// protected abstract LocalisableString GraphCounterName { get; } - protected ChartProfileSubsection(Bindable user, LocalisableString headerText) + protected ChartProfileSubsection(Bindable user, LocalisableString headerText) : base(user, headerText) { } @@ -44,9 +44,9 @@ namespace osu.Game.Overlays.Profile.Sections.Historical User.BindValueChanged(onUserChanged, true); } - private void onUserChanged(ValueChangedEvent e) + private void onUserChanged(ValueChangedEvent e) { - var values = GetValues(e.NewValue); + var values = GetValues(e.NewValue?.User); if (values == null || values.Length <= 1) { @@ -84,6 +84,6 @@ namespace osu.Game.Overlays.Profile.Sections.Historical return filledHistoryEntries.ToArray(); } - protected abstract APIUserHistoryCount[] GetValues(APIUser user); + protected abstract APIUserHistoryCount[]? GetValues(APIUser? user); } } diff --git a/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs b/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs index 3ed4bd9e50..8a05341783 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.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 osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -19,7 +18,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections.Historical { - public class DrawableMostPlayedBeatmap : CompositeDrawable + public partial class DrawableMostPlayedBeatmap : CompositeDrawable { private const int cover_width = 100; private const int corner_radius = 6; @@ -108,17 +107,17 @@ namespace osu.Game.Overlays.Profile.Sections.Historical }); } - private class MostPlayedBeatmapContainer : ProfileItemContainer + private partial class MostPlayedBeatmapContainer : ProfileItemContainer { [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) { - IdleColour = colourProvider.Background4; - HoverColour = colourProvider.Background3; + IdleColour = colourProvider.Background3; + HoverColour = colourProvider.Background2; } } - private class MostPlayedBeatmapMetadataContainer : BeatmapMetadataContainer + private partial class MostPlayedBeatmapMetadataContainer : BeatmapMetadataContainer { public MostPlayedBeatmapMetadataContainer(IBeatmapInfo beatmapInfo) : base(beatmapInfo) @@ -129,8 +128,6 @@ namespace osu.Game.Overlays.Profile.Sections.Historical { var metadata = beatmapInfo.Metadata; - Debug.Assert(metadata != null); - return new Drawable[] { new OsuSpriteText @@ -157,7 +154,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical } } - private class PlayCountText : CompositeDrawable, IHasTooltip + private partial class PlayCountText : CompositeDrawable, IHasTooltip { public LocalisableString TooltipText => UsersStrings.ShowExtraHistoricalMostPlayedCount; diff --git a/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs index 06de0f62dc..9708062202 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs @@ -14,9 +14,9 @@ using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Overlays.Profile.Sections.Historical { - public class PaginatedMostPlayedBeatmapContainer : PaginatedProfileSubsection + public partial class PaginatedMostPlayedBeatmapContainer : PaginatedProfileSubsection { - public PaginatedMostPlayedBeatmapContainer(Bindable user) + public PaginatedMostPlayedBeatmapContainer(Bindable user) : base(user, UsersStrings.ShowExtraHistoricalMostPlayedTitle) { } @@ -29,8 +29,8 @@ namespace osu.Game.Overlays.Profile.Sections.Historical protected override int GetCount(APIUser user) => user.BeatmapPlayCountsCount; - protected override APIRequest> CreateRequest(PaginationParameters pagination) => - new GetUserMostPlayedBeatmapsRequest(User.Value.Id, pagination); + protected override APIRequest> CreateRequest(UserProfileData user, PaginationParameters pagination) => + new GetUserMostPlayedBeatmapsRequest(user.User.Id, pagination); protected override Drawable CreateDrawableItem(APIUserMostPlayedBeatmap mostPlayed) => new DrawableMostPlayedBeatmap(mostPlayed); diff --git a/osu.Game/Overlays/Profile/Sections/Historical/PlayHistorySubsection.cs b/osu.Game/Overlays/Profile/Sections/Historical/PlayHistorySubsection.cs index 51d704a6b0..f472ded182 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/PlayHistorySubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/PlayHistorySubsection.cs @@ -8,15 +8,15 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections.Historical { - public class PlayHistorySubsection : ChartProfileSubsection + public partial class PlayHistorySubsection : ChartProfileSubsection { protected override LocalisableString GraphCounterName => UsersStrings.ShowExtraHistoricalMonthlyPlaycountsCountLabel; - public PlayHistorySubsection(Bindable user) + public PlayHistorySubsection(Bindable user) : base(user, UsersStrings.ShowExtraHistoricalMonthlyPlaycountsTitle) { } - protected override APIUserHistoryCount[] GetValues(APIUser user) => user?.MonthlyPlayCounts; + protected override APIUserHistoryCount[]? GetValues(APIUser? user) => user?.MonthlyPlayCounts; } } diff --git a/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs b/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs index c049bff7b2..5711bfc046 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs @@ -3,7 +3,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics; -using JetBrains.Annotations; using System; using System.Linq; using osu.Game.Graphics.Sprites; @@ -18,11 +17,10 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Overlays.Profile.Sections.Historical { - public class ProfileLineChart : CompositeDrawable + public partial class ProfileLineChart : CompositeDrawable { - private APIUserHistoryCount[] values; + private APIUserHistoryCount[] values = Array.Empty(); - [NotNull] public APIUserHistoryCount[] Values { get => values; @@ -240,7 +238,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical return Math.Max((long)(tickMultiplier * tickBase), 1); } - private class TickText : OsuSpriteText + private partial class TickText : OsuSpriteText { [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) @@ -249,7 +247,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical } } - private class TickLine : Box + private partial class TickLine : Box { [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) diff --git a/osu.Game/Overlays/Profile/Sections/Historical/ReplaysSubsection.cs b/osu.Game/Overlays/Profile/Sections/Historical/ReplaysSubsection.cs index 22312afdc9..225eaa8c7e 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/ReplaysSubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/ReplaysSubsection.cs @@ -8,15 +8,15 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections.Historical { - public class ReplaysSubsection : ChartProfileSubsection + public partial class ReplaysSubsection : ChartProfileSubsection { protected override LocalisableString GraphCounterName => UsersStrings.ShowExtraHistoricalReplaysWatchedCountsCountLabel; - public ReplaysSubsection(Bindable user) + public ReplaysSubsection(Bindable user) : base(user, UsersStrings.ShowExtraHistoricalReplaysWatchedCountsTitle) { } - protected override APIUserHistoryCount[] GetValues(APIUser user) => user?.ReplaysWatchedCounts; + protected override APIUserHistoryCount[]? GetValues(APIUser? user) => user?.ReplaysWatchedCounts; } } diff --git a/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs b/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs index f80167f9d3..0259231001 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs @@ -4,19 +4,17 @@ using System; using System.Collections.Generic; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Localisation; using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Overlays.Profile.Sections.Historical { - public class UserHistoryGraph : UserGraph + public partial class UserHistoryGraph : UserGraph { private readonly LocalisableString tooltipCounterName; - [CanBeNull] - public APIUserHistoryCount[] Values + public APIUserHistoryCount[]? Values { set => Data = value?.Select(v => new KeyValuePair(v.Date, v.Count)).ToArray(); } @@ -29,9 +27,11 @@ namespace osu.Game.Overlays.Profile.Sections.Historical protected override float GetDataPointHeight(long playCount) => playCount; protected override UserGraphTooltipContent GetTooltipContent(DateTime date, long playCount) => - new UserGraphTooltipContent( - tooltipCounterName, - playCount.ToLocalisableString("N0"), - date.ToLocalisableString("MMMM yyyy")); + new UserGraphTooltipContent + { + Name = tooltipCounterName, + Count = playCount.ToLocalisableString("N0"), + Time = date.ToLocalisableString("MMMM yyyy") + }; } } diff --git a/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs b/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs index 203844b6b5..19f7a32d4d 100644 --- a/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs +++ b/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs @@ -10,7 +10,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections { - public class HistoricalSection : ProfileSection + public partial class HistoricalSection : ProfileSection { public override LocalisableString Title => UsersStrings.ShowExtraHistoricalTitle; diff --git a/osu.Game/Overlays/Profile/Sections/Kudosu/DrawableKudosuHistoryItem.cs b/osu.Game/Overlays/Profile/Sections/Kudosu/DrawableKudosuHistoryItem.cs index ce05beffd1..161d5b6f64 100644 --- a/osu.Game/Overlays/Profile/Sections/Kudosu/DrawableKudosuHistoryItem.cs +++ b/osu.Game/Overlays/Profile/Sections/Kudosu/DrawableKudosuHistoryItem.cs @@ -13,13 +13,10 @@ using osuTK; namespace osu.Game.Overlays.Profile.Sections.Kudosu { - public class DrawableKudosuHistoryItem : CompositeDrawable + public partial class DrawableKudosuHistoryItem : CompositeDrawable { private const int height = 25; - [Resolved] - private OsuColour colours { get; set; } - private readonly APIKudosuHistory historyItem; private readonly LinkFlowContainer linkFlowContainer; private readonly DrawableDate date; @@ -48,9 +45,9 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu } [BackgroundDependencyLoader] - private void load() + private void load(OverlayColourProvider colourProvider) { - date.Colour = colours.GreySeaFoamLighter; + date.Colour = colourProvider.Foreground1; var formattedSource = MessageFormatter.FormatText(getString(historyItem)); linkFlowContainer.AddLinks(formattedSource.Text, formattedSource.Links); } diff --git a/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs b/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs index 79a06ecdd4..1a44262ef8 100644 --- a/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs +++ b/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs @@ -2,27 +2,24 @@ // 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; -using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; namespace osu.Game.Overlays.Profile.Sections.Kudosu { - public class KudosuInfo : Container + public partial class KudosuInfo : Container { - private readonly Bindable user = new Bindable(); + private readonly Bindable user = new Bindable(); - public KudosuInfo(Bindable user) + public KudosuInfo(Bindable user) { this.user.BindTo(user); CountSection total; @@ -32,56 +29,44 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu CornerRadius = 3; Child = total = new CountTotal(); - this.user.ValueChanged += u => total.Count = u.NewValue?.Kudosu.Total ?? 0; + this.user.ValueChanged += u => total.Count = u.NewValue?.User.Kudosu.Total ?? 0; } protected override bool OnClick(ClickEvent e) => true; - private class CountTotal : CountSection + private partial class CountTotal : CountSection { public CountTotal() : base(UsersStrings.ShowExtraKudosuTotal) { DescriptionText.AddText("Based on how much of a contribution the user has made to beatmap moderation. See "); - DescriptionText.AddLink("this page", "https://osu.ppy.sh/wiki/Kudosu"); + DescriptionText.AddLink("this page", LinkAction.OpenWiki, @"Modding/Kudosu"); DescriptionText.AddText(" for more information."); } } - private class CountSection : Container + private partial class CountSection : Container { 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/Profile/Sections/Kudosu/PaginatedKudosuHistoryContainer.cs b/osu.Game/Overlays/Profile/Sections/Kudosu/PaginatedKudosuHistoryContainer.cs index 9af854e6b9..79d31c6ed1 100644 --- a/osu.Game/Overlays/Profile/Sections/Kudosu/PaginatedKudosuHistoryContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Kudosu/PaginatedKudosuHistoryContainer.cs @@ -8,19 +8,18 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API; using System.Collections.Generic; using osu.Game.Resources.Localisation.Web; -using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Overlays.Profile.Sections.Kudosu { - public class PaginatedKudosuHistoryContainer : PaginatedProfileSubsection + public partial class PaginatedKudosuHistoryContainer : PaginatedProfileSubsection { - public PaginatedKudosuHistoryContainer(Bindable user) + public PaginatedKudosuHistoryContainer(Bindable user) : base(user, missingText: UsersStrings.ShowExtraKudosuEntryEmpty) { } - protected override APIRequest> CreateRequest(PaginationParameters pagination) - => new GetUserKudosuHistoryRequest(User.Value.Id, pagination); + protected override APIRequest> CreateRequest(UserProfileData user, PaginationParameters pagination) + => new GetUserKudosuHistoryRequest(user.User.Id, pagination); protected override Drawable CreateDrawableItem(APIKudosuHistory item) => new DrawableKudosuHistoryItem(item); } diff --git a/osu.Game/Overlays/Profile/Sections/KudosuSection.cs b/osu.Game/Overlays/Profile/Sections/KudosuSection.cs index 5b749c78a8..482a853c44 100644 --- a/osu.Game/Overlays/Profile/Sections/KudosuSection.cs +++ b/osu.Game/Overlays/Profile/Sections/KudosuSection.cs @@ -8,7 +8,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections { - public class KudosuSection : ProfileSection + public partial class KudosuSection : ProfileSection { public override LocalisableString Title => UsersStrings.ShowExtraKudosuTitle; diff --git a/osu.Game/Overlays/Profile/Sections/MedalsSection.cs b/osu.Game/Overlays/Profile/Sections/MedalsSection.cs index cacdd44b61..42f241d662 100644 --- a/osu.Game/Overlays/Profile/Sections/MedalsSection.cs +++ b/osu.Game/Overlays/Profile/Sections/MedalsSection.cs @@ -6,7 +6,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections { - public class MedalsSection : ProfileSection + public partial class MedalsSection : ProfileSection { public override LocalisableString Title => UsersStrings.ShowExtraMedalsTitle; diff --git a/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs b/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs index 33bd155d71..1c94048758 100644 --- a/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs @@ -20,7 +20,7 @@ using osuTK; namespace osu.Game.Overlays.Profile.Sections { - public abstract class PaginatedProfileSubsection : ProfileSubsection + public abstract partial class PaginatedProfileSubsection : ProfileSubsection { /// /// The number of items displayed per page. @@ -33,20 +33,20 @@ namespace osu.Game.Overlays.Profile.Sections protected virtual int InitialItemsCount => 5; [Resolved] - private IAPIProvider api { get; set; } + private IAPIProvider api { get; set; } = null!; protected PaginationParameters? CurrentPage { get; private set; } - protected ReverseChildIDFillFlowContainer ItemsContainer { get; private set; } + protected ReverseChildIDFillFlowContainer ItemsContainer { get; private set; } = null!; - private APIRequest> retrievalRequest; - private CancellationTokenSource loadCancellation; + private APIRequest>? retrievalRequest; + private CancellationTokenSource? loadCancellation; - private ShowMoreButton moreButton; - private OsuSpriteText missing; + private ShowMoreButton moreButton = null!; + private OsuSpriteText missing = null!; private readonly LocalisableString? missingText; - protected PaginatedProfileSubsection(Bindable user, LocalisableString? headerText = null, LocalisableString? missingText = null) + protected PaginatedProfileSubsection(Bindable user, LocalisableString? headerText = null, LocalisableString? missingText = null) : base(user, headerText, CounterVisibilityState.AlwaysVisible) { this.missingText = missingText; @@ -92,7 +92,7 @@ namespace osu.Game.Overlays.Profile.Sections User.BindValueChanged(onUserChanged, true); } - private void onUserChanged(ValueChangedEvent e) + private void onUserChanged(ValueChangedEvent e) { loadCancellation?.Cancel(); retrievalRequest?.Cancel(); @@ -100,26 +100,29 @@ namespace osu.Game.Overlays.Profile.Sections CurrentPage = null; ItemsContainer.Clear(); - if (e.NewValue != null) + if (e.NewValue?.User != null) { showMore(); - SetCount(GetCount(e.NewValue)); + SetCount(GetCount(e.NewValue.User)); } } private void showMore() { + if (User.Value == null) + return; + loadCancellation = new CancellationTokenSource(); CurrentPage = CurrentPage?.TakeNext(ItemsPerPage) ?? new PaginationParameters(InitialItemsCount); - retrievalRequest = CreateRequest(CurrentPage.Value); - retrievalRequest.Success += UpdateItems; + retrievalRequest = CreateRequest(User.Value, CurrentPage.Value); + retrievalRequest.Success += items => UpdateItems(items, loadCancellation); api.Queue(retrievalRequest); } - protected virtual void UpdateItems(List items) => Schedule(() => + protected virtual void UpdateItems(List items, CancellationTokenSource cancellationTokenSource) => Schedule(() => { OnItemsReceived(items); @@ -134,7 +137,7 @@ namespace osu.Game.Overlays.Profile.Sections return; } - LoadComponentsAsync(items.Select(CreateDrawableItem).Where(d => d != null), drawables => + LoadComponentsAsync(items.Select(CreateDrawableItem).Where(d => d != null).Cast(), drawables => { missing.Hide(); @@ -142,7 +145,7 @@ namespace osu.Game.Overlays.Profile.Sections moreButton.IsLoading = false; ItemsContainer.AddRange(drawables); - }, loadCancellation.Token); + }, cancellationTokenSource.Token); }); protected virtual int GetCount(APIUser user) => 0; @@ -151,9 +154,9 @@ namespace osu.Game.Overlays.Profile.Sections { } - protected abstract APIRequest> CreateRequest(PaginationParameters pagination); + protected abstract APIRequest> CreateRequest(UserProfileData user, PaginationParameters pagination); - protected abstract Drawable CreateDrawableItem(TModel model); + protected abstract Drawable? CreateDrawableItem(TModel model); protected override void Dispose(bool isDisposing) { diff --git a/osu.Game/Overlays/Profile/Sections/ProfileItemContainer.cs b/osu.Game/Overlays/Profile/Sections/ProfileItemContainer.cs index afa6bd9f79..5e1b650bd3 100644 --- a/osu.Game/Overlays/Profile/Sections/ProfileItemContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/ProfileItemContainer.cs @@ -10,7 +10,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Profile.Sections { - public class ProfileItemContainer : Container + public partial class ProfileItemContainer : Container { private const int hover_duration = 200; @@ -65,8 +65,8 @@ namespace osu.Game.Overlays.Profile.Sections [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) { - IdleColour = colourProvider.Background3; - HoverColour = colourProvider.Background2; + IdleColour = colourProvider.Background2; + HoverColour = colourProvider.Background1; } protected override bool OnHover(HoverEvent e) diff --git a/osu.Game/Overlays/Profile/Sections/ProfileSubsection.cs b/osu.Game/Overlays/Profile/Sections/ProfileSubsection.cs index fdf8cb19ff..35d3ac1579 100644 --- a/osu.Game/Overlays/Profile/Sections/ProfileSubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/ProfileSubsection.cs @@ -5,22 +5,20 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using JetBrains.Annotations; using osu.Framework.Localisation; -using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Overlays.Profile.Sections { - public abstract class ProfileSubsection : FillFlowContainer + public abstract partial class ProfileSubsection : FillFlowContainer { - protected readonly Bindable User = new Bindable(); + protected readonly Bindable User = new Bindable(); private readonly LocalisableString headerText; private readonly CounterVisibilityState counterVisibilityState; - private ProfileSubsectionHeader header; + private ProfileSubsectionHeader header = null!; - protected ProfileSubsection(Bindable user, LocalisableString? headerText = null, CounterVisibilityState counterVisibilityState = CounterVisibilityState.AlwaysHidden) + protected ProfileSubsection(Bindable user, LocalisableString? headerText = null, CounterVisibilityState counterVisibilityState = CounterVisibilityState.AlwaysHidden) { this.headerText = headerText ?? string.Empty; this.counterVisibilityState = counterVisibilityState; @@ -44,7 +42,6 @@ namespace osu.Game.Overlays.Profile.Sections }; } - [NotNull] protected abstract Drawable CreateContent(); protected void SetCount(int value) => header.Current.Value = value; diff --git a/osu.Game/Overlays/Profile/Sections/ProfileSubsectionHeader.cs b/osu.Game/Overlays/Profile/Sections/ProfileSubsectionHeader.cs index 408cb00770..fa21535d02 100644 --- a/osu.Game/Overlays/Profile/Sections/ProfileSubsectionHeader.cs +++ b/osu.Game/Overlays/Profile/Sections/ProfileSubsectionHeader.cs @@ -15,7 +15,7 @@ using osu.Framework.Localisation; namespace osu.Game.Overlays.Profile.Sections { - public class ProfileSubsectionHeader : CompositeDrawable, IHasCurrentValue + public partial class ProfileSubsectionHeader : CompositeDrawable, IHasCurrentValue { private readonly BindableWithCurrent current = new BindableWithCurrent(); @@ -28,7 +28,7 @@ namespace osu.Game.Overlays.Profile.Sections private readonly LocalisableString text; private readonly CounterVisibilityState counterState; - private CounterPill counterPill; + private CounterPill counterPill = null!; public ProfileSubsectionHeader(LocalisableString text, CounterVisibilityState counterState) { diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs index 998f5d158e..529e78a7cf 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs @@ -2,10 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Diagnostics; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -17,27 +16,28 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Leaderboards; using osu.Game.Rulesets; using osu.Game.Rulesets.UI; +using osu.Game.Scoring.Drawables; using osu.Game.Utils; using osuTK; namespace osu.Game.Overlays.Profile.Sections.Ranks { - public class DrawableProfileScore : CompositeDrawable + public partial class DrawableProfileScore : CompositeDrawable { private const int height = 40; private const int performance_width = 100; private const float performance_background_shear = 0.45f; - protected readonly APIScore Score; + protected readonly SoloScoreInfo Score; [Resolved] - private OsuColour colours { get; set; } + private OsuColour colours { get; set; } = null!; [Resolved] - private OverlayColourProvider colourProvider { get; set; } + private OverlayColourProvider colourProvider { get; set; } = null!; - public DrawableProfileScore(APIScore score) + public DrawableProfileScore(SoloScoreInfo score) { Score = score; @@ -82,7 +82,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks Spacing = new Vector2(0, 2), Children = new Drawable[] { - new ScoreBeatmapMetadataContainer(Score.Beatmap), + new ScoreBeatmapMetadataContainer(Score.Beatmap.AsNonNull()), new FillFlowContainer { AutoSizeAxes = Axes.Both, @@ -92,11 +92,11 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks { new OsuSpriteText { - Text = $"{Score.Beatmap?.DifficultyName}", + Text = $"{Score.Beatmap.AsNonNull().DifficultyName}", Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular), Colour = colours.Yellow }, - new DrawableDate(Score.Date, 12) + new DrawableDate(Score.EndedAt, 12) { Colour = colourProvider.Foreground1 } @@ -134,9 +134,9 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks Spacing = new Vector2(2), Children = Score.Mods.Select(mod => { - var ruleset = rulesets.GetRuleset(Score.RulesetID) ?? throw new InvalidOperationException(); + var ruleset = rulesets.GetRuleset(Score.RulesetID) ?? throw new InvalidOperationException($"Ruleset with ID of {Score.RulesetID} not found locally"); - return new ModIcon(ruleset.CreateInstance().CreateModFromAcronym(mod.Acronym)) + return new ModIcon(mod.ToMod(ruleset.CreateInstance())) { Scale = new Vector2(0.35f) }; @@ -160,7 +160,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks Origin = Anchor.TopRight, RelativeSizeAxes = Axes.Both, Height = 0.5f, - Colour = colourProvider.Background4, + Colour = colourProvider.Background3, Shear = new Vector2(-performance_background_shear, 0), EdgeSmoothness = new Vector2(2, 0), }, @@ -172,7 +172,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks RelativePositionAxes = Axes.Y, Height = -0.5f, Position = new Vector2(0, 1), - Colour = colourProvider.Background4, + Colour = colourProvider.Background3, Shear = new Vector2(performance_background_shear, 0), EdgeSmoothness = new Vector2(2, 0), }, @@ -197,7 +197,6 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks }); } - [NotNull] protected virtual Drawable CreateRightContent() => CreateDrawableAccuracy(); protected Drawable CreateDrawableAccuracy() => new Container @@ -216,43 +215,46 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks private Drawable createDrawablePerformance() { - if (Score.PP.HasValue) + if (!Score.PP.HasValue) { - return new FillFlowContainer + if (Score.Beatmap?.Status.GrantsPerformancePoints() == true) + return new UnprocessedPerformancePointsPlaceholder { Size = new Vector2(16), Colour = colourProvider.Highlight1 }; + + return new OsuSpriteText { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Children = new[] - { - new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Font = OsuFont.GetFont(weight: FontWeight.Bold), - Text = $"{Score.PP:0}", - Colour = colourProvider.Highlight1 - }, - new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), - Text = "pp", - Colour = colourProvider.Light3 - } - } + Font = OsuFont.GetFont(weight: FontWeight.Bold), + Text = "-", + Colour = colourProvider.Highlight1 }; } - return new OsuSpriteText + return new FillFlowContainer { - Font = OsuFont.GetFont(weight: FontWeight.Bold), - Text = "-", - Colour = colourProvider.Highlight1 + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = new[] + { + new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.GetFont(weight: FontWeight.Bold), + Text = $"{Score.PP:0}", + Colour = colourProvider.Highlight1 + }, + new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), + Text = "pp", + Colour = colourProvider.Light3 + } + } }; } - private class ScoreBeatmapMetadataContainer : BeatmapMetadataContainer + private partial class ScoreBeatmapMetadataContainer : BeatmapMetadataContainer { public ScoreBeatmapMetadataContainer(IBeatmapInfo beatmapInfo) : base(beatmapInfo) @@ -263,8 +265,6 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks { var metadata = beatmapInfo.Metadata; - Debug.Assert(metadata != null); - return new Drawable[] { new OsuSpriteText diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs index 78ae0a5634..6cfe34ec6f 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs @@ -12,11 +12,11 @@ using osuTK; namespace osu.Game.Overlays.Profile.Sections.Ranks { - public class DrawableProfileWeightedScore : DrawableProfileScore + public partial class DrawableProfileWeightedScore : DrawableProfileScore { private readonly double weight; - public DrawableProfileWeightedScore(APIScore score, double weight) + public DrawableProfileWeightedScore(SoloScoreInfo score, double weight) : base(score) { this.weight = weight; @@ -40,12 +40,11 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks CreateDrawableAccuracy(), new Container { - AutoSizeAxes = Axes.Y, - Width = 50, + Size = new Vector2(50, 14), Child = new OsuSpriteText { Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true), - Text = $"{Score.PP * weight:0}pp", + Text = Score.PP.HasValue ? $"{Score.PP * weight:0}pp" : string.Empty, }, } } diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs index ef9f4b5ff3..c85d16a17c 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs @@ -15,11 +15,11 @@ using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Overlays.Profile.Sections.Ranks { - public class PaginatedScoreContainer : PaginatedProfileSubsection + public partial class PaginatedScoreContainer : PaginatedProfileSubsection { private readonly ScoreType type; - public PaginatedScoreContainer(ScoreType type, Bindable user, LocalisableString headerText) + public PaginatedScoreContainer(ScoreType type, Bindable user, LocalisableString headerText) : base(user, headerText) { this.type = type; @@ -52,7 +52,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks } } - protected override void OnItemsReceived(List items) + protected override void OnItemsReceived(List items) { if (CurrentPage == null || CurrentPage?.Offset == 0) drawableItemIndex = 0; @@ -60,12 +60,12 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks base.OnItemsReceived(items); } - protected override APIRequest> CreateRequest(PaginationParameters pagination) => - new GetUserScoresRequest(User.Value.Id, type, pagination); + protected override APIRequest> CreateRequest(UserProfileData user, PaginationParameters pagination) => + new GetUserScoresRequest(user.User.Id, type, pagination, user.Ruleset); private int drawableItemIndex; - protected override Drawable CreateDrawableItem(APIScore model) + protected override Drawable CreateDrawableItem(SoloScoreInfo model) { switch (type) { diff --git a/osu.Game/Overlays/Profile/Sections/RanksSection.cs b/osu.Game/Overlays/Profile/Sections/RanksSection.cs index f48e33dc12..ce831b30a8 100644 --- a/osu.Game/Overlays/Profile/Sections/RanksSection.cs +++ b/osu.Game/Overlays/Profile/Sections/RanksSection.cs @@ -8,7 +8,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections { - public class RanksSection : ProfileSection + public partial class RanksSection : ProfileSection { public override LocalisableString Title => UsersStrings.ShowExtraTopRanksTitle; diff --git a/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs b/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs index 7a27c6e4e1..0479ab7c16 100644 --- a/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs +++ b/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs @@ -3,6 +3,7 @@ using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -17,19 +18,19 @@ using osu.Game.Rulesets; namespace osu.Game.Overlays.Profile.Sections.Recent { - public class DrawableRecentActivity : CompositeDrawable + public partial class DrawableRecentActivity : CompositeDrawable { private const int font_size = 14; [Resolved] - private IAPIProvider api { get; set; } + private IAPIProvider api { get; set; } = null!; [Resolved] - private IRulesetStore rulesets { get; set; } + private IRulesetStore rulesets { get; set; } = null!; private readonly APIRecentActivity activity; - private LinkFlowContainer content; + private LinkFlowContainer content = null!; public DrawableRecentActivity(APIRecentActivity activity) { @@ -118,7 +119,13 @@ namespace osu.Game.Overlays.Profile.Sections.Recent }; default: - return Empty(); + return new RecentActivityIcon(activity) + { + RelativeSizeAxes = Axes.X, + Height = 11, + FillMode = FillMode.Fit, + Margin = new MarginPadding { Top = 2, Vertical = 2 } + }; } } @@ -208,15 +215,15 @@ namespace osu.Game.Overlays.Profile.Sections.Recent rulesets.AvailableRulesets.FirstOrDefault(r => r.ShortName == activity.Mode)?.Name ?? activity.Mode; private void addUserLink() - => content.AddLink(activity.User?.Username, LinkAction.OpenUserProfile, getLinkArgument(activity.User?.Url), creationParameters: t => t.Font = getLinkFont(FontWeight.Bold)); + => content.AddLink(activity.User.AsNonNull().Username, LinkAction.OpenUserProfile, getLinkArgument(activity.User.AsNonNull().Url), creationParameters: t => t.Font = getLinkFont(FontWeight.Bold)); private void addBeatmapLink() - => content.AddLink(activity.Beatmap?.Title, LinkAction.OpenBeatmap, getLinkArgument(activity.Beatmap?.Url), creationParameters: t => t.Font = getLinkFont()); + => content.AddLink(activity.Beatmap.AsNonNull().Title, LinkAction.OpenBeatmap, getLinkArgument(activity.Beatmap.AsNonNull().Url), creationParameters: t => t.Font = getLinkFont()); private void addBeatmapsetLink() - => content.AddLink(activity.Beatmapset?.Title, LinkAction.OpenBeatmapSet, getLinkArgument(activity.Beatmapset?.Url), creationParameters: t => t.Font = getLinkFont()); + => content.AddLink(activity.Beatmapset.AsNonNull().Title, LinkAction.OpenBeatmapSet, getLinkArgument(activity.Beatmapset.AsNonNull().Url), creationParameters: t => t.Font = getLinkFont()); - private string getLinkArgument(string url) => MessageFormatter.GetLinkDetails($"{api.APIEndpointUrl}{url}").Argument.ToString(); + private string getLinkArgument(string url) => MessageFormatter.GetLinkDetails($"{api.WebsiteRootUrl}{url}").Argument.ToString().AsNonNull(); private FontUsage getLinkFont(FontWeight fontWeight = FontWeight.Regular) => OsuFont.GetFont(size: font_size, weight: fontWeight, italics: true); diff --git a/osu.Game/Overlays/Profile/Sections/Recent/MedalIcon.cs b/osu.Game/Overlays/Profile/Sections/Recent/MedalIcon.cs index 0c1f8b2e92..6cb439ab33 100644 --- a/osu.Game/Overlays/Profile/Sections/Recent/MedalIcon.cs +++ b/osu.Game/Overlays/Profile/Sections/Recent/MedalIcon.cs @@ -10,7 +10,7 @@ using osu.Framework.Graphics.Textures; namespace osu.Game.Overlays.Profile.Sections.Recent { [LongRunningLoad] - public class MedalIcon : Container + public partial class MedalIcon : Container { private readonly string slug; private readonly Sprite sprite; diff --git a/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs b/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs index 77008d5f34..37a004f10c 100644 --- a/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs @@ -10,13 +10,12 @@ using System.Collections.Generic; using osuTK; using osu.Framework.Allocation; using osu.Game.Resources.Localisation.Web; -using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Overlays.Profile.Sections.Recent { - public class PaginatedRecentActivityContainer : PaginatedProfileSubsection + public partial class PaginatedRecentActivityContainer : PaginatedProfileSubsection { - public PaginatedRecentActivityContainer(Bindable user) + public PaginatedRecentActivityContainer(Bindable user) : base(user, missingText: EventsStrings.Empty) { } @@ -27,8 +26,8 @@ namespace osu.Game.Overlays.Profile.Sections.Recent ItemsContainer.Spacing = new Vector2(0, 8); } - protected override APIRequest> CreateRequest(PaginationParameters pagination) => - new GetUserRecentActivitiesRequest(User.Value.Id, pagination); + protected override APIRequest> CreateRequest(UserProfileData user, PaginationParameters pagination) => + new GetUserRecentActivitiesRequest(user.User.Id, pagination); protected override Drawable CreateDrawableItem(APIRecentActivity model) => new DrawableRecentActivity(model); } diff --git a/osu.Game/Overlays/Profile/Sections/Recent/RecentActivityIcon.cs b/osu.Game/Overlays/Profile/Sections/Recent/RecentActivityIcon.cs new file mode 100644 index 0000000000..8c40c6c7a5 --- /dev/null +++ b/osu.Game/Overlays/Profile/Sections/Recent/RecentActivityIcon.cs @@ -0,0 +1,119 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics; +using osu.Game.Online.API.Requests; +using osu.Framework.Allocation; +using osu.Game.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Profile.Sections.Recent +{ + public partial class RecentActivityIcon : Container + { + private readonly SpriteIcon icon; + private readonly APIRecentActivity activity; + + public RecentActivityIcon(APIRecentActivity activity) + { + this.activity = activity; + Child = icon = new SpriteIcon + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + } + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + // references: + // https://github.com/ppy/osu-web/blob/659b371dcadf25b4f601a4c9895a813078301084/resources/assets/lib/profile-page/parse-event.tsx + // https://github.com/ppy/osu-web/blob/master/resources/assets/less/bem/profile-extra-entries.less#L98-L128 + switch (activity.Type) + { + case RecentActivityType.BeatmapPlaycount: + icon.Icon = FontAwesome.Solid.Play; + icon.Colour = Color4.White; + break; + + case RecentActivityType.BeatmapsetApprove: + icon.Icon = FontAwesome.Solid.Check; + icon.Colour = getColorForApprovalType(activity.Approval); + break; + + case RecentActivityType.BeatmapsetDelete: + icon.Icon = FontAwesome.Solid.TrashAlt; + icon.Colour = colours.Red1; + break; + + case RecentActivityType.BeatmapsetRevive: + icon.Icon = FontAwesome.Solid.TrashRestore; + icon.Colour = Color4.White; + break; + + case RecentActivityType.BeatmapsetUpdate: + icon.Icon = FontAwesome.Solid.SyncAlt; + icon.Colour = colours.Green1; + break; + + case RecentActivityType.BeatmapsetUpload: + icon.Icon = FontAwesome.Solid.ArrowUp; + icon.Colour = colours.Orange1; + break; + + case RecentActivityType.RankLost: + icon.Icon = FontAwesome.Solid.AngleDoubleDown; + icon.Colour = Color4.White; + break; + + case RecentActivityType.UserSupportAgain: + icon.Icon = FontAwesome.Solid.Heart; + icon.Colour = colours.Pink; + break; + + case RecentActivityType.UserSupportFirst: + icon.Icon = FontAwesome.Solid.Heart; + icon.Colour = colours.Pink; + break; + + case RecentActivityType.UserSupportGift: + icon.Icon = FontAwesome.Solid.Gift; + icon.Colour = colours.Pink; + break; + + case RecentActivityType.UsernameChange: + icon.Icon = FontAwesome.Solid.Tag; + icon.Colour = Color4.White; + break; + } + } + + private Color4 getColorForApprovalType(BeatmapApproval approvalType) + { + switch (approvalType) + { + case BeatmapApproval.Approved: + case BeatmapApproval.Ranked: + return colours.Lime1; + + case BeatmapApproval.Loved: + return colours.Pink1; + + case BeatmapApproval.Qualified: + return colours.Blue1; + + default: + throw new ArgumentOutOfRangeException($"Unsupported {nameof(BeatmapApproval)} type", approvalType, nameof(approvalType)); + } + } + } +} diff --git a/osu.Game/Overlays/Profile/Sections/RecentSection.cs b/osu.Game/Overlays/Profile/Sections/RecentSection.cs index 33d435aa1b..e29dc7f635 100644 --- a/osu.Game/Overlays/Profile/Sections/RecentSection.cs +++ b/osu.Game/Overlays/Profile/Sections/RecentSection.cs @@ -7,7 +7,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections { - public class RecentSection : ProfileSection + public partial class RecentSection : ProfileSection { public override LocalisableString Title => UsersStrings.ShowExtraRecentActivityTitle; diff --git a/osu.Game/Overlays/Profile/UserGraph.cs b/osu.Game/Overlays/Profile/UserGraph.cs index 182221eea7..0a5e6ca710 100644 --- a/osu.Game/Overlays/Profile/UserGraph.cs +++ b/osu.Game/Overlays/Profile/UserGraph.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -24,12 +23,12 @@ namespace osu.Game.Overlays.Profile /// /// Type of data to be used for X-axis of the graph. /// Type of data to be used for Y-axis of the graph. - public abstract class UserGraph : Container, IHasCustomTooltip + public abstract partial class UserGraph : Container, IHasCustomTooltip { protected const float FADE_DURATION = 150; private readonly UserLineGraph graph; - private KeyValuePair[] data; + private KeyValuePair[]? data; private int hoveredIndex = -1; protected UserGraph() @@ -81,8 +80,7 @@ namespace osu.Game.Overlays.Profile /// /// Set of values which will be used to create a graph. /// - [CanBeNull] - protected KeyValuePair[] Data + protected KeyValuePair[]? Data { set { @@ -118,9 +116,9 @@ namespace osu.Game.Overlays.Profile protected virtual void ShowGraph() => graph.FadeIn(FADE_DURATION, Easing.Out); protected virtual void HideGraph() => graph.FadeOut(FADE_DURATION, Easing.Out); - public ITooltip GetCustomTooltip() => new UserGraphTooltip(); + public ITooltip GetCustomTooltip() => new UserGraphTooltip(); - public UserGraphTooltipContent TooltipContent + public UserGraphTooltipContent? TooltipContent { get { @@ -134,14 +132,14 @@ namespace osu.Game.Overlays.Profile protected abstract UserGraphTooltipContent GetTooltipContent(TKey key, TValue value); - protected class UserLineGraph : LineGraph + protected partial class UserLineGraph : LineGraph { private readonly CircularContainer movingBall; private readonly Container bar; private readonly Box ballBg; private readonly Box line; - public Action OnBallMove; + public Action? OnBallMove; public UserLineGraph() { @@ -189,7 +187,7 @@ namespace osu.Game.Overlays.Profile Vector2 position = calculateBallPosition(index); movingBall.MoveToY(position.Y, duration, Easing.OutQuint); bar.MoveToX(position.X, duration, Easing.OutQuint); - OnBallMove.Invoke(index); + OnBallMove?.Invoke(index); } public void ShowBar() => bar.FadeIn(FADE_DURATION); @@ -205,7 +203,7 @@ namespace osu.Game.Overlays.Profile } } - private class UserGraphTooltip : VisibilityContainer, ITooltip + private partial class UserGraphTooltip : VisibilityContainer, ITooltip { protected readonly OsuSpriteText Label, Counter, BottomText; private readonly Box background; @@ -265,8 +263,11 @@ namespace osu.Game.Overlays.Profile background.Colour = colours.Gray1; } - public void SetContent(UserGraphTooltipContent content) + public void SetContent(UserGraphTooltipContent? content) { + if (content == null) + return; + Label.Text = content.Name; Counter.Text = content.Count; BottomText.Text = content.Time; @@ -297,16 +298,8 @@ namespace osu.Game.Overlays.Profile public class UserGraphTooltipContent { - // todo: could use init-only properties on C# 9 which read better than a constructor. - public LocalisableString Name { get; } - public LocalisableString Count { get; } - public LocalisableString Time { get; } - - public UserGraphTooltipContent(LocalisableString name, LocalisableString count, LocalisableString time) - { - Name = name; - Count = count; - Time = time; - } + public LocalisableString Name { get; init; } + public LocalisableString Count { get; init; } + public LocalisableString Time { get; init; } } } diff --git a/osu.Game/Overlays/Profile/UserProfileData.cs b/osu.Game/Overlays/Profile/UserProfileData.cs new file mode 100644 index 0000000000..79518516e4 --- /dev/null +++ b/osu.Game/Overlays/Profile/UserProfileData.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets; + +namespace osu.Game.Overlays.Profile +{ + /// + /// Contains data about a profile presented on the . + /// + public class UserProfileData + { + /// + /// The user whose profile is being presented. + /// + public APIUser User { get; } + + /// + /// The ruleset that the user profile is being shown with. + /// + public RulesetInfo Ruleset { get; } + + public UserProfileData(APIUser user, RulesetInfo ruleset) + { + User = user; + Ruleset = ruleset; + } + } +} diff --git a/osu.Game/Overlays/Rankings/CountryFilter.cs b/osu.Game/Overlays/Rankings/CountryFilter.cs index 9950f36141..525816f8fd 100644 --- a/osu.Game/Overlays/Rankings/CountryFilter.cs +++ b/osu.Game/Overlays/Rankings/CountryFilter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -14,14 +16,14 @@ using osuTK; namespace osu.Game.Overlays.Rankings { - public class CountryFilter : CompositeDrawable, IHasCurrentValue + public partial class CountryFilter : CompositeDrawable, IHasCurrentValue { private const int duration = 200; private const int height = 70; - private readonly BindableWithCurrent current = new BindableWithCurrent(); + private readonly BindableWithCurrent current = new BindableWithCurrent(); - public Bindable Current + public Bindable Current { get => current.Current; set => current.Current = value; @@ -52,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 @@ -87,9 +89,9 @@ namespace osu.Game.Overlays.Rankings Current.BindValueChanged(onCountryChanged, true); } - private void onCountryChanged(ValueChangedEvent country) + private void onCountryChanged(ValueChangedEvent country) { - if (country.NewValue == null) + if (Current.Value == CountryCode.Unknown) { countryPill.Collapse(); this.ResizeHeightTo(0, duration, Easing.OutQuint); diff --git a/osu.Game/Overlays/Rankings/CountryPill.cs b/osu.Game/Overlays/Rankings/CountryPill.cs index edd7b596d2..5efa9d12f0 100644 --- a/osu.Game/Overlays/Rankings/CountryPill.cs +++ b/osu.Game/Overlays/Rankings/CountryPill.cs @@ -1,9 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -19,13 +22,13 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Rankings { - public class CountryPill : CompositeDrawable, IHasCurrentValue + public partial class CountryPill : CompositeDrawable, IHasCurrentValue { private const int duration = 200; - private readonly BindableWithCurrent current = new BindableWithCurrent(); + private readonly BindableWithCurrent current = new BindableWithCurrent(); - public Bindable Current + public Bindable Current { get => current.Current; set => current.Current = value; @@ -77,7 +80,7 @@ namespace osu.Game.Overlays.Rankings { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(30, 20) + Size = new Vector2(28, 20) }, countryName = new OsuSpriteText { @@ -91,7 +94,7 @@ namespace osu.Game.Overlays.Rankings { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Action = () => Current.Value = null + Action = Current.SetDefault, } } } @@ -128,16 +131,16 @@ namespace osu.Game.Overlays.Rankings this.FadeOut(duration, Easing.OutQuint); } - private void onCountryChanged(ValueChangedEvent country) + private void onCountryChanged(ValueChangedEvent country) { - if (country.NewValue == null) + if (Current.Value == CountryCode.Unknown) return; - flag.Country = country.NewValue; - countryName.Text = country.NewValue.FullName; + flag.CountryCode = country.NewValue; + countryName.Text = country.NewValue.GetDescription(); } - private class CloseButton : OsuHoverContainer + private partial class CloseButton : OsuHoverContainer { private readonly SpriteIcon icon; diff --git a/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs b/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs index 417b33ddf6..44f278a237 100644 --- a/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs +++ b/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Game.Localisation; using osu.Game.Resources.Localisation.Web; @@ -10,24 +12,24 @@ using osu.Game.Users; namespace osu.Game.Overlays.Rankings { - public class RankingsOverlayHeader : TabControlOverlayHeader + public partial class RankingsOverlayHeader : TabControlOverlayHeader { public Bindable Ruleset => rulesetSelector.Current; - public Bindable Country => countryFilter.Current; + public Bindable Country => countryFilter.Current; private OverlayRulesetSelector rulesetSelector; private CountryFilter countryFilter; protected override OverlayTitle CreateTitle() => new RankingsTitle(); - protected override Drawable CreateTitleContent() => rulesetSelector = new OverlayRulesetSelector(); + protected override Drawable CreateTabControlContent() => rulesetSelector = new OverlayRulesetSelector(); protected override Drawable CreateContent() => countryFilter = new CountryFilter(); protected override Drawable CreateBackground() => new OverlayHeaderBackground("Headers/rankings"); - private class RankingsTitle : OverlayTitle + private partial class RankingsTitle : OverlayTitle { public RankingsTitle() { diff --git a/osu.Game/Overlays/Rankings/RankingsScope.cs b/osu.Game/Overlays/Rankings/RankingsScope.cs index e660c2898a..2644fee58b 100644 --- a/osu.Game/Overlays/Rankings/RankingsScope.cs +++ b/osu.Game/Overlays/Rankings/RankingsScope.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; diff --git a/osu.Game/Overlays/Rankings/RankingsSortTabControl.cs b/osu.Game/Overlays/Rankings/RankingsSortTabControl.cs index f05795b2a2..9e73c3adb0 100644 --- a/osu.Game/Overlays/Rankings/RankingsSortTabControl.cs +++ b/osu.Game/Overlays/Rankings/RankingsSortTabControl.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Rankings { - public class RankingsSortTabControl : OverlaySortTabControl + public partial class RankingsSortTabControl : OverlaySortTabControl { public RankingsSortTabControl() { diff --git a/osu.Game/Overlays/Rankings/SpotlightSelector.cs b/osu.Game/Overlays/Rankings/SpotlightSelector.cs index c05c160463..190da04a5d 100644 --- a/osu.Game/Overlays/Rankings/SpotlightSelector.cs +++ b/osu.Game/Overlays/Rankings/SpotlightSelector.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -22,7 +24,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Rankings { - public class SpotlightSelector : CompositeDrawable, IHasCurrentValue + public partial class SpotlightSelector : CompositeDrawable, IHasCurrentValue { private readonly BindableWithCurrent current = new BindableWithCurrent(); public readonly Bindable Sort = new Bindable(); @@ -61,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, @@ -131,7 +133,7 @@ namespace osu.Game.Overlays.Rankings private LocalisableString dateToString(DateTimeOffset date) => date.ToLocalisableString(@"yyyy-MM-dd"); - private class InfoColumn : FillFlowContainer + private partial class InfoColumn : FillFlowContainer { public LocalisableString Value { @@ -173,7 +175,7 @@ namespace osu.Game.Overlays.Rankings } } - private class SpotlightsDropdown : OsuDropdown + private partial class SpotlightsDropdown : OsuDropdown { private OsuDropdownMenu menu; @@ -190,7 +192,7 @@ namespace osu.Game.Overlays.Rankings Padding = new MarginPadding { Vertical = 20 }; } - private class SpotlightsDropdownHeader : OsuDropdownHeader + private partial class SpotlightsDropdownHeader : OsuDropdownHeader { public SpotlightsDropdownHeader() { diff --git a/osu.Game/Overlays/Rankings/SpotlightsLayout.cs b/osu.Game/Overlays/Rankings/SpotlightsLayout.cs index 7f5d096fe2..8a1b929753 100644 --- a/osu.Game/Overlays/Rankings/SpotlightsLayout.cs +++ b/osu.Game/Overlays/Rankings/SpotlightsLayout.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using System.Threading; using osu.Framework.Allocation; @@ -19,7 +21,7 @@ using osuTK; namespace osu.Game.Overlays.Rankings { - public class SpotlightsLayout : CompositeDrawable + public partial class SpotlightsLayout : CompositeDrawable { public readonly Bindable Ruleset = new Bindable(); diff --git a/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs b/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs index a908380e95..3be5cc994c 100644 --- a/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using System; @@ -9,12 +11,13 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using System.Collections.Generic; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Extensions.LocalisationExtensions; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Rankings.Tables { - public class CountriesTable : RankingsTable + public partial class CountriesTable : RankingsTable { public CountriesTable(int page, IReadOnlyList rankings) : base(page, rankings) @@ -31,9 +34,9 @@ namespace osu.Game.Overlays.Rankings.Tables new RankingsTableColumn(RankingsStrings.StatAveragePerformance, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), }; - protected override Country GetCountry(CountryStatistics item) => item.Country; + protected override CountryCode GetCountryCode(CountryStatistics item) => item.Code; - protected override Drawable CreateFlagContent(CountryStatistics item) => new CountryName(item.Country); + protected override Drawable CreateFlagContent(CountryStatistics item) => new CountryName(item.Code); protected override Drawable[] CreateAdditionalContent(CountryStatistics item) => new Drawable[] { @@ -63,20 +66,20 @@ namespace osu.Game.Overlays.Rankings.Tables } }; - private class CountryName : LinkFlowContainer + private partial class CountryName : LinkFlowContainer { [Resolved(canBeNull: true)] private RankingsOverlay rankings { get; set; } - public CountryName(Country country) + public CountryName(CountryCode countryCode) : base(t => t.Font = OsuFont.GetFont(size: 12)) { AutoSizeAxes = Axes.X; RelativeSizeAxes = Axes.Y; TextAnchor = Anchor.CentreLeft; - if (!string.IsNullOrEmpty(country.FullName)) - AddLink(country.FullName, () => rankings?.ShowCountry(country)); + if (countryCode != CountryCode.Unknown) + AddLink(countryCode.GetDescription(), () => rankings?.ShowCountry(countryCode)); } } } diff --git a/osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs b/osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs index 6c85ec2753..19ed3afdca 100644 --- a/osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osu.Game.Users; namespace osu.Game.Overlays.Rankings.Tables { - public class PerformanceTable : UserBasedTable + public partial class PerformanceTable : UserBasedTable { public PerformanceTable(int page, IReadOnlyList rankings) : base(page, rankings) diff --git a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs index fd69b6c80a..27d894cdc2 100644 --- a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using System.Collections.Generic; @@ -18,10 +20,9 @@ using osu.Framework.Localisation; namespace osu.Game.Overlays.Rankings.Tables { - public abstract class RankingsTable : TableContainer + 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; @@ -37,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); } @@ -77,7 +78,7 @@ namespace osu.Game.Overlays.Rankings.Tables protected sealed override Drawable CreateHeader(int index, TableColumn column) => (column as RankingsTableColumn)?.CreateHeaderText() ?? new HeaderText(column?.Header ?? default, false); - protected abstract Country GetCountry(TModel item); + protected abstract CountryCode GetCountryCode(TModel item); protected abstract Drawable CreateFlagContent(TModel item); @@ -95,10 +96,10 @@ namespace osu.Game.Overlays.Rankings.Tables Margin = new MarginPadding { Bottom = row_spacing }, Children = new[] { - new UpdateableFlag(GetCountry(item)) + new UpdateableFlag(GetCountryCode(item)) { - Size = new Vector2(30, 20), - ShowPlaceholderOnNull = false, + Size = new Vector2(28, 20), + ShowPlaceholderOnUnknown = false, }, CreateFlagContent(item) } @@ -117,7 +118,7 @@ namespace osu.Game.Overlays.Rankings.Tables public virtual HeaderText CreateHeaderText() => new HeaderText(Header, Highlighted); } - protected class HeaderText : OsuSpriteText + protected partial class HeaderText : OsuSpriteText { private readonly bool isHighlighted; @@ -138,7 +139,7 @@ namespace osu.Game.Overlays.Rankings.Tables } } - protected class RowText : OsuSpriteText + protected partial class RowText : OsuSpriteText { public RowText() { @@ -147,7 +148,7 @@ namespace osu.Game.Overlays.Rankings.Tables } } - protected class ColouredRowText : RowText + protected partial class ColouredRowText : RowText { [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) diff --git a/osu.Game/Overlays/Rankings/Tables/ScoresTable.cs b/osu.Game/Overlays/Rankings/Tables/ScoresTable.cs index 934da4501e..0da3fba8cc 100644 --- a/osu.Game/Overlays/Rankings/Tables/ScoresTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/ScoresTable.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osu.Game.Users; namespace osu.Game.Overlays.Rankings.Tables { - public class ScoresTable : UserBasedTable + public partial class ScoresTable : UserBasedTable { public ScoresTable(int page, IReadOnlyList rankings) : base(page, rankings) diff --git a/osu.Game/Overlays/Rankings/Tables/TableRowBackground.cs b/osu.Game/Overlays/Rankings/Tables/TableRowBackground.cs index b49fec65db..54ec45f4ff 100644 --- a/osu.Game/Overlays/Rankings/Tables/TableRowBackground.cs +++ b/osu.Game/Overlays/Rankings/Tables/TableRowBackground.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,7 +12,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Rankings.Tables { - public class TableRowBackground : CompositeDrawable + public partial class TableRowBackground : CompositeDrawable { private const int fade_duration = 100; diff --git a/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs b/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs index 5d150c9535..4d25065578 100644 --- a/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Extensions.LocalisationExtensions; @@ -15,7 +17,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Rankings.Tables { - public abstract class UserBasedTable : RankingsTable + public abstract partial class UserBasedTable : RankingsTable { protected UserBasedTable(int page, IReadOnlyList rankings) : base(page, rankings) @@ -57,7 +59,7 @@ namespace osu.Game.Overlays.Rankings.Tables .Concat(GradeColumns.Select(grade => new GradeTableColumn(grade, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)))) .ToArray(); - protected sealed override Country GetCountry(UserStatistics item) => item.User.Country; + protected sealed override CountryCode GetCountryCode(UserStatistics item) => item.User.CountryCode; protected sealed override Drawable CreateFlagContent(UserStatistics item) { @@ -96,7 +98,7 @@ namespace osu.Game.Overlays.Rankings.Tables public override HeaderText CreateHeaderText() => new GradeHeaderText(Header, Highlighted); } - private class GradeHeaderText : HeaderText + private partial class GradeHeaderText : HeaderText { public GradeHeaderText(LocalisableString text, bool isHighlighted) : base(text, isHighlighted) diff --git a/osu.Game/Overlays/RankingsOverlay.cs b/osu.Game/Overlays/RankingsOverlay.cs index 80ce2e038d..f25bf80b6a 100644 --- a/osu.Game/Overlays/RankingsOverlay.cs +++ b/osu.Game/Overlays/RankingsOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -13,9 +15,9 @@ using osu.Game.Overlays.Rankings.Tables; namespace osu.Game.Overlays { - public class RankingsOverlay : TabbableOnlineOverlay + public partial class RankingsOverlay : TabbableOnlineOverlay { - protected Bindable Country => Header.Country; + protected Bindable Country => Header.Country; private APIRequest lastRequest; @@ -42,7 +44,7 @@ namespace osu.Game.Overlays Country.BindValueChanged(_ => { // if a country is requested, force performance scope. - if (Country.Value != null) + if (!Country.IsDefault) Header.Current.Value = RankingsScope.Performance; Scheduler.AddOnce(triggerTabChanged); @@ -74,7 +76,7 @@ namespace osu.Game.Overlays { // country filtering is only valid for performance scope. if (Header.Current.Value != RankingsScope.Performance) - Country.Value = null; + Country.SetDefault(); Scheduler.AddOnce(triggerTabChanged); } @@ -83,9 +85,9 @@ namespace osu.Game.Overlays protected override RankingsOverlayHeader CreateHeader() => new RankingsOverlayHeader(); - public void ShowCountry(Country requested) + public void ShowCountry(CountryCode requested) { - if (requested == null) + if (requested == default) return; Show(); @@ -126,7 +128,7 @@ namespace osu.Game.Overlays switch (Header.Current.Value) { case RankingsScope.Performance: - return new GetUserRankingsRequest(ruleset.Value, country: Country.Value?.FlagName); + return new GetUserRankingsRequest(ruleset.Value, countryCode: Country.Value); case RankingsScope.Country: return new GetCountryRankingsRequest(ruleset.Value); diff --git a/osu.Game/Overlays/RestoreDefaultValueButton.cs b/osu.Game/Overlays/RestoreDefaultValueButton.cs index afc4146199..97c66fdf02 100644 --- a/osu.Game/Overlays/RestoreDefaultValueButton.cs +++ b/osu.Game/Overlays/RestoreDefaultValueButton.cs @@ -1,22 +1,28 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osuTK; +using osu.Game.Localisation; namespace osu.Game.Overlays { - public class RestoreDefaultValueButton : OsuButton, IHasTooltip, IHasCurrentValue + public partial class RestoreDefaultValueButton : OsuClickableContainer, IHasCurrentValue { public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; @@ -49,15 +55,32 @@ namespace osu.Game.Overlays private const float size = 4; + private CircularContainer circle = null!; + private Box background = null!; + + public RestoreDefaultValueButton() + : base(HoverSampleSet.Button) + { + } + [BackgroundDependencyLoader] private void load(OsuColour colour) { - BackgroundColour = colour.Lime1; + // size intentionally much larger than actual drawn content, so that the button is easier to click. Size = new Vector2(3 * size); - Content.RelativeSizeAxes = Axes.None; - Content.Size = new Vector2(size); - Content.CornerRadius = size / 2; + Add(circle = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(size), + Masking = true, + Child = background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colour.Lime1 + } + }); Alpha = 0f; @@ -75,7 +98,7 @@ namespace osu.Game.Overlays FinishTransforms(true); } - public LocalisableString TooltipText => "revert to default"; + public override LocalisableString TooltipText => CommonStrings.RevertToDefault.ToLower(); protected override bool OnHover(HoverEvent e) { @@ -102,8 +125,8 @@ namespace osu.Game.Overlays if (!Current.Disabled) { this.FadeTo(Current.IsDefault ? 0 : 1, fade_duration, Easing.OutQuint); - Background.FadeColour(IsHovered ? colours.Lime0 : colours.Lime1, fade_duration, Easing.OutQuint); - Content.TweenEdgeEffectTo(new EdgeEffectParameters + background.FadeColour(IsHovered ? colours.Lime0 : colours.Lime1, fade_duration, Easing.OutQuint); + circle.TweenEdgeEffectTo(new EdgeEffectParameters { Colour = (IsHovered ? colours.Lime1 : colours.Lime3).Opacity(0.4f), Radius = IsHovered ? 8 : 4, @@ -112,8 +135,8 @@ namespace osu.Game.Overlays } else { - Background.FadeColour(colours.Lime3, fade_duration, Easing.OutQuint); - Content.TweenEdgeEffectTo(new EdgeEffectParameters + background.FadeColour(colours.Lime3, fade_duration, Easing.OutQuint); + circle.TweenEdgeEffectTo(new EdgeEffectParameters { Colour = colours.Lime3.Opacity(0.1f), Radius = 2, diff --git a/osu.Game/Overlays/Settings/DangerousSettingsButton.cs b/osu.Game/Overlays/Settings/DangerousSettingsButton.cs index 4ca3ace8a1..248b4d339a 100644 --- a/osu.Game/Overlays/Settings/DangerousSettingsButton.cs +++ b/osu.Game/Overlays/Settings/DangerousSettingsButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Game.Graphics; @@ -9,7 +11,7 @@ namespace osu.Game.Overlays.Settings /// /// A with pink colours to mark dangerous/destructive actions. /// - public class DangerousSettingsButton : SettingsButton + public partial class DangerousSettingsButton : SettingsButton { [BackgroundDependencyLoader] private void load(OsuColour colours) diff --git a/osu.Game/Overlays/Settings/ISettingsItem.cs b/osu.Game/Overlays/Settings/ISettingsItem.cs index 61191dcacf..509fc1ab0d 100644 --- a/osu.Game/Overlays/Settings/ISettingsItem.cs +++ b/osu.Game/Overlays/Settings/ISettingsItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics; diff --git a/osu.Game/Overlays/Settings/OutlinedTextBox.cs b/osu.Game/Overlays/Settings/OutlinedTextBox.cs index 93eaf74b77..56b662ecf0 100644 --- a/osu.Game/Overlays/Settings/OutlinedTextBox.cs +++ b/osu.Game/Overlays/Settings/OutlinedTextBox.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Input.Events; @@ -10,7 +12,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Settings { - public class OutlinedTextBox : OsuTextBox + public partial class OutlinedTextBox : OsuTextBox { private const float border_thickness = 3; diff --git a/osu.Game/Overlays/Settings/RulesetSettingsSubsection.cs b/osu.Game/Overlays/Settings/RulesetSettingsSubsection.cs index 3945a410ab..1bc2c08352 100644 --- a/osu.Game/Overlays/Settings/RulesetSettingsSubsection.cs +++ b/osu.Game/Overlays/Settings/RulesetSettingsSubsection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Game.Configuration; using osu.Game.Rulesets; @@ -12,7 +14,7 @@ namespace osu.Game.Overlays.Settings /// A which provides subclasses with the /// from the 's . /// - public abstract class RulesetSettingsSubsection : SettingsSubsection + public abstract partial class RulesetSettingsSubsection : SettingsSubsection { private readonly Ruleset ruleset; diff --git a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs index 858d555f06..a71f2a6d29 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Audio { - public class AudioDevicesSettings : SettingsSubsection + public partial class AudioDevicesSettings : SettingsSubsection { protected override LocalisableString Header => AudioSettingsStrings.AudioDevicesHeader; @@ -57,7 +59,11 @@ namespace osu.Game.Overlays.Settings.Sections.Audio // the dropdown. BASS does not give us a simple mechanism to select // specific audio devices in such a case anyways. Such // functionality would require involved OS-specific code. - dropdown.Items = deviceItems.Distinct().ToList(); + dropdown.Items = deviceItems + // Dropdown doesn't like null items. Somehow we are seeing some arrive here (see https://github.com/ppy/osu/issues/21271) + .Where(i => i != null) + .Distinct() + .ToList(); } protected override void Dispose(bool isDisposing) @@ -71,11 +77,11 @@ namespace osu.Game.Overlays.Settings.Sections.Audio } } - private class AudioDeviceSettingsDropdown : SettingsDropdown + private partial class AudioDeviceSettingsDropdown : SettingsDropdown { protected override OsuDropdown CreateDropdown() => new AudioDeviceDropdownControl(); - private class AudioDeviceDropdownControl : DropdownControl + private partial class AudioDeviceDropdownControl : DropdownControl { protected override LocalisableString GenerateItemText(string item) => string.IsNullOrEmpty(item) ? CommonStrings.Default : base.GenerateItemText(item); diff --git a/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs index eaacb9293f..1755c12f94 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -12,7 +14,7 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Audio { - public class OffsetSettings : SettingsSubsection + public partial class OffsetSettings : SettingsSubsection { protected override LocalisableString Header => AudioSettingsStrings.OffsetHeader; diff --git a/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs index 00c1cb8f43..7066be4f92 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs @@ -1,16 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Audio { - public class VolumeSettings : SettingsSubsection + public partial class VolumeSettings : SettingsSubsection { protected override LocalisableString Header => AudioSettingsStrings.VolumeHeader; @@ -19,7 +22,7 @@ namespace osu.Game.Overlays.Settings.Sections.Audio { Children = new Drawable[] { - new SettingsSlider + new VolumeAdjustSlider { LabelText = AudioSettingsStrings.MasterVolume, Current = audio.Volume, @@ -33,14 +36,15 @@ namespace osu.Game.Overlays.Settings.Sections.Audio KeyboardStep = 0.01f, DisplayAsPercentage = true }, - new SettingsSlider + new VolumeAdjustSlider { LabelText = AudioSettingsStrings.EffectVolume, Current = audio.VolumeSample, KeyboardStep = 0.01f, DisplayAsPercentage = true }, - new SettingsSlider + + new VolumeAdjustSlider { LabelText = AudioSettingsStrings.MusicVolume, Current = audio.VolumeTrack, @@ -49,5 +53,15 @@ namespace osu.Game.Overlays.Settings.Sections.Audio }, }; } + + private partial class VolumeAdjustSlider : SettingsSlider + { + protected override Drawable CreateControl() + { + var sliderBar = (RoundedSliderBar)base.CreateControl(); + sliderBar.PlaySamplesOnAdjust = false; + return sliderBar; + } + } } } diff --git a/osu.Game/Overlays/Settings/Sections/AudioSection.cs b/osu.Game/Overlays/Settings/Sections/AudioSection.cs index 7ffa0bd415..542d5bc8fd 100644 --- a/osu.Game/Overlays/Settings/Sections/AudioSection.cs +++ b/osu.Game/Overlays/Settings/Sections/AudioSection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . 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 osu.Framework.Localisation; @@ -11,7 +13,7 @@ using osu.Game.Overlays.Settings.Sections.Audio; namespace osu.Game.Overlays.Settings.Sections { - public class AudioSection : SettingsSection + public partial class AudioSection : SettingsSection { public override LocalisableString Header => AudioSettingsStrings.AudioSectionHeader; diff --git a/osu.Game/Overlays/Settings/Sections/DebugSection.cs b/osu.Game/Overlays/Settings/Sections/DebugSection.cs index 2e23d8a22a..509410fbb1 100644 --- a/osu.Game/Overlays/Settings/Sections/DebugSection.cs +++ b/osu.Game/Overlays/Settings/Sections/DebugSection.cs @@ -1,6 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; @@ -9,7 +12,7 @@ using osu.Game.Overlays.Settings.Sections.DebugSettings; namespace osu.Game.Overlays.Settings.Sections { - public class DebugSection : SettingsSection + public partial class DebugSection : SettingsSection { public override LocalisableString Header => DebugSettingsStrings.DebugSectionHeader; @@ -20,11 +23,12 @@ namespace osu.Game.Overlays.Settings.Sections public DebugSection() { - Children = new Drawable[] - { - new GeneralSettings(), - new MemorySettings(), - }; + Add(new GeneralSettings()); + + if (DebugUtils.IsDebugBuild) + Add(new BatchImportSettings()); + + Add(new MemorySettings()); } } } diff --git a/osu.Game/Overlays/Settings/Sections/DebugSettings/BatchImportSettings.cs b/osu.Game/Overlays/Settings/Sections/DebugSettings/BatchImportSettings.cs new file mode 100644 index 0000000000..1c17356313 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/DebugSettings/BatchImportSettings.cs @@ -0,0 +1,66 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Localisation; +using osu.Game.Database; + +namespace osu.Game.Overlays.Settings.Sections.DebugSettings +{ + public partial class BatchImportSettings : SettingsSubsection + { + protected override LocalisableString Header => @"Batch Import"; + + private SettingsButton importBeatmapsButton = null!; + private SettingsButton importCollectionsButton = null!; + private SettingsButton importScoresButton = null!; + private SettingsButton importSkinsButton = null!; + + [BackgroundDependencyLoader] + private void load(LegacyImportManager? legacyImportManager) + { + if (legacyImportManager?.SupportsImportFromStable != true) + return; + + AddRange(new[] + { + importBeatmapsButton = new SettingsButton + { + Text = @"Import beatmaps from stable", + Action = () => + { + importBeatmapsButton.Enabled.Value = false; + legacyImportManager.ImportFromStableAsync(StableContent.Beatmaps).ContinueWith(_ => Schedule(() => importBeatmapsButton.Enabled.Value = true)); + } + }, + importSkinsButton = new SettingsButton + { + Text = @"Import skins from stable", + Action = () => + { + importSkinsButton.Enabled.Value = false; + legacyImportManager.ImportFromStableAsync(StableContent.Skins).ContinueWith(_ => Schedule(() => importSkinsButton.Enabled.Value = true)); + } + }, + importCollectionsButton = new SettingsButton + { + Text = @"Import collections from stable", + Action = () => + { + importCollectionsButton.Enabled.Value = false; + legacyImportManager.ImportFromStableAsync(StableContent.Collections).ContinueWith(_ => Schedule(() => importCollectionsButton.Enabled.Value = true)); + } + }, + importScoresButton = new SettingsButton + { + Text = @"Import scores from stable", + Action = () => + { + importScoresButton.Enabled.Value = false; + legacyImportManager.ImportFromStableAsync(StableContent.Scores).ContinueWith(_ => Schedule(() => importScoresButton.Enabled.Value = true)); + } + }, + }); + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs index 8833420523..6c2bfedba0 100644 --- a/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/DebugSettings/GeneralSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Configuration; using osu.Framework.Graphics; @@ -9,10 +11,11 @@ using osu.Framework.Screens; using osu.Game.Localisation; using osu.Game.Screens; using osu.Game.Screens.Import; +using osu.Game.Screens.Utility; namespace osu.Game.Overlays.Settings.Sections.DebugSettings { - public class GeneralSettings : SettingsSubsection + public partial class GeneralSettings : SettingsSubsection { protected override LocalisableString Header => DebugSettingsStrings.GeneralHeader; @@ -30,13 +33,18 @@ namespace osu.Game.Overlays.Settings.Sections.DebugSettings { LabelText = DebugSettingsStrings.BypassFrontToBackPass, Current = config.GetBindable(DebugSetting.BypassFrontToBackPass) + }, + new SettingsButton + { + Text = DebugSettingsStrings.ImportFiles, + Action = () => performer?.PerformFromScreen(menu => menu.Push(new FileImportScreen())) + }, + new SettingsButton + { + Text = @"Run latency certifier", + Action = () => performer?.PerformFromScreen(menu => menu.Push(new LatencyCertifierScreen())) } }; - Add(new SettingsButton - { - Text = DebugSettingsStrings.ImportFiles, - Action = () => performer?.PerformFromScreen(menu => menu.Push(new FileImportScreen())) - }); } } } diff --git a/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs b/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs index f26326a220..bf0a48d2c2 100644 --- a/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs +++ b/osu.Game/Overlays/Settings/Sections/DebugSettings/MemorySettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Threading; using System.Threading.Tasks; @@ -14,7 +16,7 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.DebugSettings { - public class MemorySettings : SettingsSubsection + public partial class MemorySettings : SettingsSubsection { protected override LocalisableString Header => DebugSettingsStrings.MemoryHeader; @@ -33,11 +35,11 @@ namespace osu.Game.Overlays.Settings.Sections.DebugSettings }, new SettingsButton { - Text = DebugSettingsStrings.CompactRealm, + Text = "Compact realm", Action = () => { // Blocking operations implicitly causes a Compact(). - using (realm.BlockAllOperations()) + using (realm.BlockAllOperations("compact")) { } } @@ -56,7 +58,7 @@ namespace osu.Game.Overlays.Settings.Sections.DebugSettings { try { - var token = realm.BlockAllOperations(); + var token = realm.BlockAllOperations("maintenance"); blockAction.Enabled.Value = false; diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.cs index e2e00813bd..00eb6fa62c 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; @@ -9,7 +11,7 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Gameplay { - public class AudioSettings : SettingsSubsection + public partial class AudioSettings : SettingsSubsection { protected override LocalisableString Header => GameplaySettingsStrings.AudioHeader; diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs index 94e0c5e494..09e5f3e163 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; @@ -9,7 +11,7 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Gameplay { - public class BackgroundSettings : SettingsSubsection + public partial class BackgroundSettings : SettingsSubsection { protected override LocalisableString Header => GameplaySettingsStrings.BackgroundHeader; diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs index aaa60ce81b..da5fc519e6 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs @@ -1,7 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Configuration; @@ -9,13 +12,17 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Gameplay { - public class BeatmapSettings : SettingsSubsection + public partial class BeatmapSettings : SettingsSubsection { protected override LocalisableString Header => GameplaySettingsStrings.BeatmapHeader; + private readonly BindableFloat comboColourNormalisation = new BindableFloat(); + [BackgroundDependencyLoader] private void load(OsuConfigManager config) { + config.BindWith(OsuSetting.ComboColourNormalisationAmount, comboColourNormalisation); + Children = new Drawable[] { new SettingsCheckbox @@ -25,11 +32,13 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay }, new SettingsCheckbox { + Keywords = new[] { "combo", "override" }, LabelText = SkinSettingsStrings.BeatmapColours, Current = config.GetBindable(OsuSetting.BeatmapColours) }, new SettingsCheckbox { + Keywords = new[] { "samples", "override" }, LabelText = SkinSettingsStrings.BeatmapHitsounds, Current = config.GetBindable(OsuSetting.BeatmapHitsounds) }, @@ -38,6 +47,12 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay LabelText = GraphicsSettingsStrings.StoryboardVideo, Current = config.GetBindable(OsuSetting.ShowStoryboard) }, + new SettingsSlider + { + LabelText = GraphicsSettingsStrings.ComboColourNormalisation, + Current = comboColourNormalisation, + DisplayAsPercentage = true, + } }; } } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs index 5231ce1211..96d458a942 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; @@ -10,7 +12,7 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Overlays.Settings.Sections.Gameplay { - public class GeneralSettings : SettingsSubsection + public partial class GeneralSettings : SettingsSubsection { protected override LocalisableString Header => GameplaySettingsStrings.GeneralHeader; diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs index 0d31e70880..c67c14bb43 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; @@ -9,7 +11,7 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Gameplay { - public class HUDSettings : SettingsSubsection + public partial class HUDSettings : SettingsSubsection { protected override LocalisableString Header => GameplaySettingsStrings.HUDHeader; @@ -36,6 +38,11 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay Current = config.GetBindable(OsuSetting.KeyOverlay), Keywords = new[] { "counter" }, }, + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.AlwaysShowGameplayLeaderboard, + Current = config.GetBindable(OsuSetting.GameplayLeaderboard), + }, }; } } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs index 83ea655601..9291dfe923 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Gameplay { - public class InputSettings : SettingsSubsection + public partial class InputSettings : SettingsSubsection { protected override LocalisableString Header => GameplaySettingsStrings.InputHeader; @@ -30,6 +32,12 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay LabelText = SkinSettingsStrings.AutoCursorSize, Current = config.GetBindable(OsuSetting.AutoCursorSize) }, + new SettingsCheckbox + { + LabelText = SkinSettingsStrings.GameplayCursorDuringTouch, + Keywords = new[] { @"touchscreen" }, + Current = config.GetBindable(OsuSetting.GameplayCursorDuringTouch) + }, }; if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs index ed88d80570..f6b3c12487 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . 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 osu.Framework.Allocation; @@ -10,7 +12,7 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Gameplay { - public class ModsSettings : SettingsSubsection + public partial class ModsSettings : SettingsSubsection { protected override LocalisableString Header => GameplaySettingsStrings.ModsHeader; @@ -25,6 +27,7 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay { LabelText = GameplaySettingsStrings.IncreaseFirstObjectVisibility, Current = config.GetBindable(OsuSetting.IncreaseFirstObjectVisibility), + Keywords = new[] { @"approach", @"circle", @"hidden" }, }, }; } diff --git a/osu.Game/Overlays/Settings/Sections/GameplaySection.cs b/osu.Game/Overlays/Settings/Sections/GameplaySection.cs index 120e2d908c..ae6145752b 100644 --- a/osu.Game/Overlays/Settings/Sections/GameplaySection.cs +++ b/osu.Game/Overlays/Settings/Sections/GameplaySection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; @@ -9,7 +11,7 @@ using osu.Game.Overlays.Settings.Sections.Gameplay; namespace osu.Game.Overlays.Settings.Sections { - public class GameplaySection : SettingsSection + public partial class GameplaySection : SettingsSection { public override LocalisableString Header => GameplaySettingsStrings.GameplaySectionHeader; diff --git a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs index cdce187a35..982cbec376 100644 --- a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs @@ -12,17 +12,19 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.General { - public class LanguageSettings : SettingsSubsection + public partial class LanguageSettings : SettingsSubsection { - private SettingsDropdown languageSelection; - private Bindable frameworkLocale; + private SettingsDropdown languageSelection = null!; + private Bindable frameworkLocale = null!; + private IBindable localisationParameters = null!; protected override LocalisableString Header => GeneralSettingsStrings.LanguageHeader; [BackgroundDependencyLoader] - private void load(FrameworkConfigManager frameworkConfig, OsuConfigManager config) + private void load(FrameworkConfigManager frameworkConfig, OsuConfigManager config, LocalisationManager localisation) { frameworkLocale = frameworkConfig.GetBindable(FrameworkSetting.Locale); + localisationParameters = localisation.CurrentParameters.GetBoundCopy(); Children = new Drawable[] { @@ -42,11 +44,13 @@ namespace osu.Game.Overlays.Settings.Sections.General }, }; - if (!LanguageExtensions.TryParseCultureCode(frameworkLocale.Value, out var locale)) - locale = Language.en; - languageSelection.Current.Value = locale; + frameworkLocale.BindValueChanged(_ => updateSelection()); + localisationParameters.BindValueChanged(_ => updateSelection(), true); languageSelection.Current.BindValueChanged(val => frameworkLocale.Value = val.NewValue.ToCultureCode()); } + + private void updateSelection() => + languageSelection.Current.Value = LanguageExtensions.GetLanguageFor(frameworkLocale.Value, localisationParameters.Value); } } diff --git a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs index 5bc88b8692..2f68b3a82f 100644 --- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Threading.Tasks; using osu.Framework; using osu.Framework.Allocation; @@ -17,7 +19,7 @@ using osu.Game.Updater; namespace osu.Game.Overlays.Settings.Sections.General { - public class UpdateSettings : SettingsSubsection + public partial class UpdateSettings : SettingsSubsection { [Resolved(CanBeNull = true)] private UpdateManager updateManager { get; set; } @@ -52,7 +54,7 @@ namespace osu.Game.Overlays.Settings.Sections.General { notifications?.Post(new SimpleNotification { - Text = $"You are running the latest release ({game.Version})", + Text = GeneralSettingsStrings.RunningLatestRelease(game.Version), Icon = FontAwesome.Solid.CheckCircle, }); } @@ -68,6 +70,7 @@ namespace osu.Game.Overlays.Settings.Sections.General Add(new SettingsButton { Text = GeneralSettingsStrings.OpenOsuFolder, + Keywords = new[] { @"logs", @"files", @"access", "directory" }, Action = () => storage.PresentExternally(), }); diff --git a/osu.Game/Overlays/Settings/Sections/GeneralSection.cs b/osu.Game/Overlays/Settings/Sections/GeneralSection.cs index ced3116728..c5274d6223 100644 --- a/osu.Game/Overlays/Settings/Sections/GeneralSection.cs +++ b/osu.Game/Overlays/Settings/Sections/GeneralSection.cs @@ -5,15 +5,19 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Graphics; using osu.Game.Localisation; using osu.Game.Overlays.Settings.Sections.General; namespace osu.Game.Overlays.Settings.Sections { - public class GeneralSection : SettingsSection + public partial class GeneralSection : SettingsSection { [Resolved(CanBeNull = true)] - private FirstRunSetupOverlay firstRunSetupOverlay { get; set; } + private FirstRunSetupOverlay? firstRunSetupOverlay { get; set; } + + [Resolved(CanBeNull = true)] + private OsuGame? game { get; set; } public override LocalisableString Header => GeneralSettingsStrings.GeneralSectionHeader; @@ -22,15 +26,25 @@ namespace osu.Game.Overlays.Settings.Sections Icon = FontAwesome.Solid.Cog }; - public GeneralSection() + [BackgroundDependencyLoader] + private void load(OsuColour colours) { Children = new Drawable[] { new SettingsButton { Text = GeneralSettingsStrings.RunSetupWizard, + Keywords = new[] { @"first run", @"initial", @"getting started" }, + TooltipText = FirstRunSetupOverlayStrings.FirstRunSetupDescription, Action = () => firstRunSetupOverlay?.Show(), }, + new SettingsButton + { + Text = GeneralSettingsStrings.LearnMoreAboutLazer, + TooltipText = GeneralSettingsStrings.LearnMoreAboutLazerTooltip, + BackgroundColour = colours.YellowDark, + Action = () => game?.ShowWiki(@"Help_centre/Upgrading_to_lazer") + }, new LanguageSettings(), new UpdateSettings(), }; diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs index 05890ad882..2765d2b437 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Drawing; using System.Linq; using osu.Framework; @@ -19,45 +20,50 @@ using osu.Game.Configuration; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; +using osuTK; using osuTK.Graphics; namespace osu.Game.Overlays.Settings.Sections.Graphics { - public class LayoutSettings : SettingsSubsection + public partial class LayoutSettings : SettingsSubsection { protected override LocalisableString Header => GraphicsSettingsStrings.LayoutHeader; - private FillFlowContainer> scalingSettings; + private FillFlowContainer> scalingSettings = null!; private readonly Bindable currentDisplay = new Bindable(); - private readonly IBindableList windowModes = new BindableList(); - private Bindable scalingMode; - private Bindable sizeFullscreen; + private Bindable scalingMode = null!; + private Bindable sizeFullscreen = null!; private readonly BindableList resolutions = new BindableList(new[] { new Size(9999, 9999) }); private readonly IBindable fullscreenCapability = new Bindable(FullscreenCapability.Capable); [Resolved] - private OsuGameBase game { get; set; } + private OsuGameBase game { get; set; } = null!; [Resolved] - private GameHost host { get; set; } + private GameHost host { get; set; } = null!; - private SettingsDropdown resolutionDropdown; - private SettingsDropdown displayDropdown; - private SettingsDropdown windowModeDropdown; + private IWindow? window; - private Bindable scalingPositionX; - private Bindable scalingPositionY; - private Bindable scalingSizeX; - private Bindable scalingSizeY; + private SettingsDropdown resolutionDropdown = null!; + private SettingsDropdown displayDropdown = null!; + private SettingsDropdown windowModeDropdown = null!; + private SettingsCheckbox safeAreaConsiderationsCheckbox = null!; + + private Bindable scalingPositionX = null!; + private Bindable scalingPositionY = null!; + private Bindable scalingSizeX = null!; + private Bindable scalingSizeY = null!; private const int transition_duration = 400; [BackgroundDependencyLoader] private void load(FrameworkConfigManager config, OsuConfigManager osuConfig, GameHost host) { + window = host.Window; + scalingMode = osuConfig.GetBindable(OsuSetting.Scaling); sizeFullscreen = config.GetBindable(FrameworkSetting.SizeFullscreen); scalingSizeX = osuConfig.GetBindable(OsuSetting.ScalingSizeX); @@ -65,27 +71,28 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics scalingPositionX = osuConfig.GetBindable(OsuSetting.ScalingPositionX); scalingPositionY = osuConfig.GetBindable(OsuSetting.ScalingPositionY); - if (host.Window != null) + if (window != null) { - currentDisplay.BindTo(host.Window.CurrentDisplayBindable); - windowModes.BindTo(host.Window.SupportedWindowModes); + currentDisplay.BindTo(window.CurrentDisplayBindable); + window.DisplaysChanged += onDisplaysChanged; } - if (host.Window is WindowsWindow windowsWindow) - fullscreenCapability.BindTo(windowsWindow.FullscreenCapability); + if (host.Renderer is IWindowsRenderer windowsRenderer) + fullscreenCapability.BindTo(windowsRenderer.FullscreenCapability); Children = new Drawable[] { 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 { LabelText = GraphicsSettingsStrings.Display, - Items = host.Window?.Displays, + Items = window?.Displays, Current = currentDisplay, }, resolutionDropdown = new ResolutionSettingsDropdown @@ -95,6 +102,11 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics ItemSource = resolutions, Current = sizeFullscreen }, + safeAreaConsiderationsCheckbox = new SettingsCheckbox + { + LabelText = "Shrink game to avoid cameras and notches", + Current = osuConfig.GetBindable(OsuSetting.SafeAreaConsiderations), + }, new SettingsSlider { LabelText = GraphicsSettingsStrings.UIScaling, @@ -120,6 +132,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics new SettingsSlider { LabelText = GraphicsSettingsStrings.HorizontalPosition, + Keywords = new[] { "screen", "scaling" }, Current = scalingPositionX, KeyboardStep = 0.01f, DisplayAsPercentage = true @@ -127,6 +140,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics new SettingsSlider { LabelText = GraphicsSettingsStrings.VerticalPosition, + Keywords = new[] { "screen", "scaling" }, Current = scalingPositionY, KeyboardStep = 0.01f, DisplayAsPercentage = true @@ -134,6 +148,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics new SettingsSlider { LabelText = GraphicsSettingsStrings.HorizontalScale, + Keywords = new[] { "screen", "scaling" }, Current = scalingSizeX, KeyboardStep = 0.01f, DisplayAsPercentage = true @@ -141,6 +156,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics new SettingsSlider { LabelText = GraphicsSettingsStrings.VerticalScale, + Keywords = new[] { "screen", "scaling" }, Current = scalingSizeY, KeyboardStep = 0.01f, DisplayAsPercentage = true @@ -158,20 +174,12 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics scalingSettings.ForEach(s => bindPreviewEvent(s.Current)); - windowModeDropdown.Current.BindValueChanged(mode => + windowModeDropdown.Current.BindValueChanged(_ => { - updateDisplayModeDropdowns(); + updateDisplaySettingsVisibility(); updateScreenModeWarning(); }, true); - windowModes.BindCollectionChanged((sender, args) => - { - if (windowModes.Count > 1) - windowModeDropdown.Show(); - else - windowModeDropdown.Hide(); - }, true); - currentDisplay.BindValueChanged(display => Schedule(() => { resolutions.RemoveRange(1, resolutions.Count - 1); @@ -185,10 +193,10 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics .Distinct()); } - updateDisplayModeDropdowns(); + updateDisplaySettingsVisibility(); }), true); - scalingMode.BindValueChanged(mode => + scalingMode.BindValueChanged(_ => { scalingSettings.ClearTransforms(); scalingSettings.AutoSizeDuration = transition_duration; @@ -200,29 +208,36 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics // initial update bypasses transforms updateScalingModeVisibility(); - void updateDisplayModeDropdowns() - { - if (resolutions.Count > 1 && windowModeDropdown.Current.Value == WindowMode.Fullscreen) - resolutionDropdown.Show(); - else - resolutionDropdown.Hide(); - - if (displayDropdown.Items.Count() > 1) - displayDropdown.Show(); - else - displayDropdown.Hide(); - } - void updateScalingModeVisibility() { if (scalingMode.Value == ScalingMode.Off) scalingSettings.ResizeHeightTo(0, transition_duration, Easing.OutQuint); scalingSettings.AutoSizeAxes = scalingMode.Value != ScalingMode.Off ? Axes.Y : Axes.None; - scalingSettings.ForEach(s => s.TransferValueOnCommit = scalingMode.Value == ScalingMode.Everything); + scalingSettings.ForEach(s => + { + s.TransferValueOnCommit = scalingMode.Value == ScalingMode.Everything; + s.CanBeShown.Value = scalingMode.Value != ScalingMode.Off; + }); } } + private void onDisplaysChanged(IEnumerable displays) + { + Scheduler.AddOnce(d => + { + displayDropdown.Items = d; + updateDisplaySettingsVisibility(); + }, displays); + } + + private void updateDisplaySettingsVisibility() + { + 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; + } + private void updateScreenModeWarning() { if (RuntimeInfo.OS == RuntimeInfo.Platform.macOS) @@ -278,7 +293,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics }; } - private Drawable preview; + private Drawable? preview; private void showPreview() { @@ -289,7 +304,15 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics preview.Expire(); } - private class ScalingPreview : ScalingContainer + protected override void Dispose(bool isDisposing) + { + if (window != null) + window.DisplaysChanged -= onDisplaysChanged; + + base.Dispose(isDisposing); + } + + private partial class ScalingPreview : ScalingContainer { public ScalingPreview() { @@ -302,16 +325,16 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics } } - private class UIScaleSlider : OsuSliderBar + private partial class UIScaleSlider : RoundedSliderBar { public override LocalisableString TooltipText => base.TooltipText + "x"; } - private class DisplaySettingsDropdown : SettingsDropdown + private partial class DisplaySettingsDropdown : SettingsDropdown { protected override OsuDropdown CreateDropdown() => new DisplaySettingsDropdownControl(); - private class DisplaySettingsDropdownControl : DropdownControl + private partial class DisplaySettingsDropdownControl : DropdownControl { protected override LocalisableString GenerateItemText(Display item) { @@ -320,11 +343,11 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics } } - private class ResolutionSettingsDropdown : SettingsDropdown + private partial class ResolutionSettingsDropdown : SettingsDropdown { protected override OsuDropdown CreateDropdown() => new ResolutionDropdownControl(); - private class ResolutionDropdownControl : DropdownControl + private partial class ResolutionDropdownControl : DropdownControl { protected override LocalisableString GenerateItemText(Size item) { diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs index 8c3e45cd62..a1f728ca87 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs @@ -1,33 +1,50 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; +using 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 { - public class RendererSettings : SettingsSubsection + public partial class RendererSettings : SettingsSubsection { protected override LocalisableString Header => GraphicsSettingsStrings.RendererHeader; - private SettingsEnumDropdown frameLimiterDropdown; + 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 - frameLimiterDropdown = new SettingsEnumDropdown + new SettingsEnumDropdown { LabelText = GraphicsSettingsStrings.FrameLimiter, - Current = config.GetBindable(FrameworkSetting.FrameSync) + Current = config.GetBindable(FrameworkSetting.FrameSync), + Keywords = new[] { @"fps" }, }, new SettingsEnumDropdown { @@ -40,25 +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); + } } - protected override void LoadComplete() + private partial class RendererSettingsDropdown : SettingsEnumDropdown { - base.LoadComplete(); + protected override OsuDropdown CreateDropdown() => new RendererDropdown(); - frameLimiterDropdown.Current.BindValueChanged(limit => + protected partial class RendererDropdown : DropdownControl { - switch (limit.NewValue) - { - case FrameSync.Unlimited: - frameLimiterDropdown.SetNoticeText(GraphicsSettingsStrings.UnlimitedFramesNote, true); - break; + private RendererType hostResolvedRenderer; + private bool automaticRendererInUse; - default: - frameLimiterDropdown.ClearNoticeText(); - break; + [BackgroundDependencyLoader] + private void load(FrameworkConfigManager config, GameHost host) + { + var renderer = config.GetBindable(FrameworkSetting.Renderer); + automaticRendererInUse = renderer.Value == RendererType.Automatic; + hostResolvedRenderer = host.ResolvedRenderer; } - }, true); + + 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/Graphics/ScreenshotSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/ScreenshotSettings.cs index dbb9ddc1c1..8054b27de5 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/ScreenshotSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/ScreenshotSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; @@ -9,7 +11,7 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Graphics { - public class ScreenshotSettings : SettingsSubsection + public partial class ScreenshotSettings : SettingsSubsection { protected override LocalisableString Header => GraphicsSettingsStrings.Screenshots; diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/VideoSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/VideoSettings.cs index 921eab63ed..2e0bbe3c16 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/VideoSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/VideoSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Configuration; @@ -11,7 +13,7 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Graphics { - public class VideoSettings : SettingsSubsection + public partial class VideoSettings : SettingsSubsection { protected override LocalisableString Header => GraphicsSettingsStrings.VideoHeader; diff --git a/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs b/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs index 8cd3b841c2..323cdaf14d 100644 --- a/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs +++ b/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; @@ -9,7 +11,7 @@ using osu.Game.Overlays.Settings.Sections.Graphics; namespace osu.Game.Overlays.Settings.Sections { - public class GraphicsSection : SettingsSection + public partial class GraphicsSection : SettingsSection { public override LocalisableString Header => GraphicsSettingsStrings.GraphicsSectionHeader; diff --git a/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs index 3227decc46..2b478f6af3 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs @@ -1,16 +1,22 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System.Collections.Generic; +using System.Linq; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Input { - public class BindingSettings : SettingsSubsection + public partial class BindingSettings : SettingsSubsection { protected override LocalisableString Header => BindingSettingsStrings.ShortcutAndGameplayBindings; + public override IEnumerable FilterTerms => base.FilterTerms.Concat(new LocalisableString[] { @"keybindings", @"controls", @"keyboard", @"keys" }); + public BindingSettings(KeyBindingPanel keyConfig) { Children = new Drawable[] diff --git a/osu.Game/Overlays/Settings/Sections/Input/GlobalKeyBindingsSection.cs b/osu.Game/Overlays/Settings/Sections/Input/GlobalKeyBindingsSection.cs index 3350ff4eaa..291e9a93cf 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/GlobalKeyBindingsSection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/GlobalKeyBindingsSection.cs @@ -9,7 +9,7 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Input { - public class GlobalKeyBindingsSection : SettingsSection + public partial class GlobalKeyBindingsSection : SettingsSection { public override Drawable CreateIcon() => new SpriteIcon { @@ -21,13 +21,15 @@ namespace osu.Game.Overlays.Settings.Sections.Input public GlobalKeyBindingsSection(GlobalActionContainer manager) { Add(new DefaultBindingsSubsection(manager)); + Add(new OverlayBindingsSubsection(manager)); Add(new AudioControlKeyBindingsSubsection(manager)); Add(new SongSelectKeyBindingSubsection(manager)); Add(new InGameKeyBindingsSubsection(manager)); + Add(new ReplayKeyBindingsSubsection(manager)); Add(new EditorKeyBindingsSubsection(manager)); } - private class DefaultBindingsSubsection : KeyBindingsSubsection + private partial class DefaultBindingsSubsection : KeyBindingsSubsection { protected override LocalisableString Header => string.Empty; @@ -38,7 +40,18 @@ namespace osu.Game.Overlays.Settings.Sections.Input } } - private class SongSelectKeyBindingSubsection : KeyBindingsSubsection + private partial class OverlayBindingsSubsection : KeyBindingsSubsection + { + protected override LocalisableString Header => InputSettingsStrings.OverlaysSection; + + public OverlayBindingsSubsection(GlobalActionContainer manager) + : base(null) + { + Defaults = manager.OverlayKeyBindings; + } + } + + private partial class SongSelectKeyBindingSubsection : KeyBindingsSubsection { protected override LocalisableString Header => InputSettingsStrings.SongSelectSection; @@ -49,7 +62,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input } } - private class InGameKeyBindingsSubsection : KeyBindingsSubsection + private partial class InGameKeyBindingsSubsection : KeyBindingsSubsection { protected override LocalisableString Header => InputSettingsStrings.InGameSection; @@ -60,7 +73,18 @@ namespace osu.Game.Overlays.Settings.Sections.Input } } - private class AudioControlKeyBindingsSubsection : KeyBindingsSubsection + private partial class ReplayKeyBindingsSubsection : KeyBindingsSubsection + { + protected override LocalisableString Header => InputSettingsStrings.ReplaySection; + + public ReplayKeyBindingsSubsection(GlobalActionContainer manager) + : base(null) + { + Defaults = manager.ReplayKeyBindings; + } + } + + private partial class AudioControlKeyBindingsSubsection : KeyBindingsSubsection { protected override LocalisableString Header => InputSettingsStrings.AudioSection; @@ -71,7 +95,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input } } - private class EditorKeyBindingsSubsection : KeyBindingsSubsection + private partial class EditorKeyBindingsSubsection : KeyBindingsSubsection { protected override LocalisableString Header => InputSettingsStrings.EditorSection; diff --git a/osu.Game/Overlays/Settings/Sections/Input/JoystickSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/JoystickSettings.cs index 60849cd6d4..8455c09633 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/JoystickSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/JoystickSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Input { - public class JoystickSettings : SettingsSubsection + public partial class JoystickSettings : SettingsSubsection { protected override LocalisableString Header => JoystickSettingsStrings.JoystickGamepad; diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingPanel.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingPanel.cs index 67f1bb8d3e..30429c84f0 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingPanel.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Input.Bindings; @@ -9,7 +11,7 @@ using osu.Game.Rulesets; namespace osu.Game.Overlays.Settings.Sections.Input { - public class KeyBindingPanel : SettingsSubPanel + public partial class KeyBindingPanel : SettingsSubPanel { protected override Drawable CreateHeader() => new SettingsHeader(InputSettingsStrings.KeyBindingPanelHeader, InputSettingsStrings.KeyBindingPanelDescription); diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs index 7312748435..b04e514ec2 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -20,6 +22,7 @@ using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.Resources.Localisation.Web; @@ -29,8 +32,13 @@ using osuTK.Input; namespace osu.Game.Overlays.Settings.Sections.Input { - public class KeyBindingRow : Container, IFilterable + public partial class KeyBindingRow : Container, IFilterable { + /// + /// Invoked when the binding of this row is updated with a change being written. + /// + public Action BindingUpdated { get; set; } + private readonly object action; private readonly IEnumerable bindings; @@ -151,7 +159,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input Spacing = new Vector2(5), Children = new Drawable[] { - new CancelButton { Action = finalise }, + new CancelButton { Action = () => finalise(false) }, new ClearButton { Action = clear }, }, }, @@ -224,7 +232,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input } } - bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState)); + bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState), KeyCombination.FromMouseButton(e.Button)); return true; } @@ -238,7 +246,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input } if (bindTarget.IsHovered) - finalise(); + finalise(false); // prevent updating bind target before clear button's action else if (!cancelAndClearButtons.Any(b => b.IsHovered)) updateBindTarget(); @@ -250,7 +258,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input { if (bindTarget.IsHovered) { - bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState, e.ScrollDelta)); + bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState, e.ScrollDelta), KeyCombination.FromScrollDelta(e.ScrollDelta).First()); finalise(); return true; } @@ -261,10 +269,10 @@ namespace osu.Game.Overlays.Settings.Sections.Input protected override bool OnKeyDown(KeyDownEvent e) { - if (!HasFocus) + if (!HasFocus || e.Repeat) return false; - bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState)); + bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState), KeyCombination.FromKey(e.Key)); if (!isModifier(e.Key)) finalise(); return true; @@ -286,7 +294,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input if (!HasFocus) return false; - bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState)); + bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState), KeyCombination.FromJoystickButton(e.Button)); finalise(); return true; @@ -308,7 +316,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input if (!HasFocus) return false; - bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState)); + bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState), KeyCombination.FromMidiKey(e.Key)); finalise(); return true; @@ -325,16 +333,60 @@ namespace osu.Game.Overlays.Settings.Sections.Input finalise(); } + protected override bool OnTabletAuxiliaryButtonPress(TabletAuxiliaryButtonPressEvent e) + { + if (!HasFocus) + return false; + + bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState), KeyCombination.FromTabletAuxiliaryButton(e.Button)); + finalise(); + + return true; + } + + protected override void OnTabletAuxiliaryButtonRelease(TabletAuxiliaryButtonReleaseEvent e) + { + if (!HasFocus) + { + base.OnTabletAuxiliaryButtonRelease(e); + return; + } + + finalise(); + } + + protected override bool OnTabletPenButtonPress(TabletPenButtonPressEvent e) + { + if (!HasFocus) + return false; + + bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState), KeyCombination.FromTabletPenButton(e.Button)); + finalise(); + + return true; + } + + protected override void OnTabletPenButtonRelease(TabletPenButtonReleaseEvent e) + { + if (!HasFocus) + { + base.OnTabletPenButtonRelease(e); + return; + } + + finalise(); + } + private void clear() { if (bindTarget == null) return; bindTarget.UpdateKeyCombination(InputKey.None); - finalise(); + finalise(false); } - private void finalise() + private void finalise(bool hasChanged = true) { if (bindTarget != null) { @@ -347,6 +399,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input { // schedule to ensure we don't instantly get focus back on next OnMouseClick (see AcceptFocus impl.) bindTarget = null; + if (hasChanged) + BindingUpdated?.Invoke(this); }); } @@ -371,7 +425,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input protected override void OnFocusLost(FocusLostEvent e) { - finalise(); + finalise(false); base.OnFocusLost(e); } @@ -385,21 +439,15 @@ namespace osu.Game.Overlays.Settings.Sections.Input if (bindTarget != null) bindTarget.IsBinding = true; } - private void updateStoreFromButton(KeyButton button) - { - realm.Run(r => - { - var binding = r.Find(((IHasGuidPrimaryKey)button.KeyBinding).ID); - r.Write(() => binding.KeyCombinationString = button.KeyBinding.KeyCombinationString); - }); - } + private void updateStoreFromButton(KeyButton button) => + realm.WriteAsync(r => r.Find(button.KeyBinding.ID).KeyCombinationString = button.KeyBinding.KeyCombinationString); private void updateIsDefaultValue() { isDefault.Value = bindings.Select(b => b.KeyCombination).SequenceEqual(Defaults); } - private class CancelButton : TriangleButton + private partial class CancelButton : RoundedButton { public CancelButton() { @@ -408,7 +456,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input } } - public class ClearButton : DangerousTriangleButton + public partial class ClearButton : DangerousRoundedButton { public ClearButton() { @@ -417,7 +465,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input } } - public class KeyButton : Container + public partial class KeyButton : Container { public readonly RealmKeyBinding KeyBinding; @@ -523,6 +571,14 @@ namespace osu.Game.Overlays.Settings.Sections.Input } } + /// + /// Update from a key combination, only allowing a single non-modifier key to be specified. + /// + /// A generated from the full input state. + /// The key which triggered this update, and should be used as the binding. + public void UpdateKeyCombination(KeyCombination fullState, InputKey triggerKey) => + UpdateKeyCombination(new KeyCombination(fullState.Keys.Where(KeyCombination.IsModifierKey).Append(triggerKey))); + public void UpdateKeyCombination(KeyCombination newCombination) { if (KeyBinding.RulesetName != null && !RealmKeyBindingStore.CheckValidForGameplay(newCombination)) diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs index 297af35fb5..d6d4abfa92 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -15,8 +17,14 @@ using osuTK; namespace osu.Game.Overlays.Settings.Sections.Input { - public abstract class KeyBindingsSubsection : SettingsSubsection + public abstract partial class KeyBindingsSubsection : SettingsSubsection { + /// + /// After a successful binding, automatically select the next binding row to make quickly + /// binding a large set of keys easier on the user. + /// + protected virtual bool AutoAdvanceTarget => false; + protected IEnumerable Defaults; public RulesetInfo Ruleset { get; protected set; } @@ -47,7 +55,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input Add(new KeyBindingRow(defaultGroup.Key, bindings.Where(b => b.ActionInt.Equals(intKey)).ToList()) { AllowMainMouseButtons = Ruleset != null, - Defaults = defaultGroup.Select(d => d.KeyCombination) + Defaults = defaultGroup.Select(d => d.KeyCombination), + BindingUpdated = onBindingUpdated }); } @@ -56,9 +65,19 @@ namespace osu.Game.Overlays.Settings.Sections.Input Action = () => Children.OfType().ForEach(k => k.RestoreDefaults()) }); } + + private void onBindingUpdated(KeyBindingRow sender) + { + if (AutoAdvanceTarget) + { + var next = Children.SkipWhile(c => c != sender).Skip(1).FirstOrDefault(); + if (next != null) + GetContainingInputManager().ChangeFocus(next); + } + } } - public class ResetButton : DangerousSettingsButton + public partial class ResetButton : DangerousSettingsButton { [BackgroundDependencyLoader] private void load() diff --git a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs index 1511d53b6b..dfaeafbf5d 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -15,7 +17,7 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Input { - public class MouseSettings : SettingsSubsection + public partial class MouseSettings : SettingsSubsection { private readonly MouseHandler mouseHandler; @@ -124,7 +126,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input }, true); } - private class SensitivitySetting : SettingsSlider + public partial class SensitivitySetting : SettingsSlider { public SensitivitySetting() { @@ -133,7 +135,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input } } - private class SensitivitySlider : OsuSliderBar + public partial class SensitivitySlider : RoundedSliderBar { public override LocalisableString TooltipText => Current.Disabled ? MouseSettingsStrings.EnableHighPrecisionForSensitivityAdjust : $"{base.TooltipText}x"; } diff --git a/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs b/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs index dbdf600002..a66e44f8cb 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -13,7 +15,7 @@ using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Overlays.Settings.Sections.Input { - internal class RotationPresetButtons : CompositeDrawable + internal partial class RotationPresetButtons : CompositeDrawable { public new MarginPadding Padding { @@ -84,7 +86,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input }, true); } - public class RotationButton : RoundedButton + public partial class RotationButton : RoundedButton { [Resolved] private OsuColour colours { get; set; } diff --git a/osu.Game/Overlays/Settings/Sections/Input/RulesetBindingsSection.cs b/osu.Game/Overlays/Settings/Sections/Input/RulesetBindingsSection.cs index dae276c711..19f0d0f7d1 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/RulesetBindingsSection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/RulesetBindingsSection.cs @@ -1,7 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Diagnostics; +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; @@ -10,7 +11,7 @@ using osu.Game.Rulesets; namespace osu.Game.Overlays.Settings.Sections.Input { - public class RulesetBindingsSection : SettingsSection + public partial class RulesetBindingsSection : SettingsSection { public override Drawable CreateIcon() => ruleset?.CreateInstance().CreateIcon() ?? new SpriteIcon { @@ -27,8 +28,6 @@ namespace osu.Game.Overlays.Settings.Sections.Input var r = ruleset.CreateInstance(); - Debug.Assert(r != null); - foreach (int variant in r.AvailableVariants) Add(new VariantBindingsSubsection(ruleset, variant)); } diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletAreaSelection.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletAreaSelection.cs index 58abfab29c..8b15bc8f72 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TabletAreaSelection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletAreaSelection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -9,6 +11,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; using osu.Framework.Input.Handlers.Tablet; using osu.Framework.Utils; using osu.Game.Graphics; @@ -18,7 +21,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Settings.Sections.Input { - public class TabletAreaSelection : CompositeDrawable + public partial class TabletAreaSelection : CompositeDrawable { public bool IsWithinBounds { get; private set; } @@ -64,7 +67,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input RelativeSizeAxes = Axes.Both, Colour = colour.Gray1, }, - usableAreaContainer = new Container + usableAreaContainer = new UsableAreaContainer(handler) { Origin = Anchor.Centre, Children = new Drawable[] @@ -123,11 +126,11 @@ namespace osu.Game.Overlays.Settings.Sections.Input { usableAreaContainer.ResizeTo(val.NewValue, 100, Easing.OutQuint); - int x = (int)val.NewValue.X; - int y = (int)val.NewValue.Y; + int x = (int)Math.Round(val.NewValue.X); + int y = (int)Math.Round(val.NewValue.Y); int commonDivider = greatestCommonDivider(x, y); - usableAreaText.Text = $"{(float)x / commonDivider}:{(float)y / commonDivider}"; + usableAreaText.Text = $"{x / commonDivider}:{y / commonDivider}"; checkBounds(); }, true); @@ -223,4 +226,28 @@ namespace osu.Game.Overlays.Settings.Sections.Input tabletContainer.Scale = new Vector2(1 / adjust); } } + + public partial class UsableAreaContainer : Container + { + private readonly Bindable areaOffset; + + public UsableAreaContainer(ITabletHandler tabletHandler) + { + areaOffset = tabletHandler.AreaOffset.GetBoundCopy(); + } + + protected override bool OnDragStart(DragStartEvent e) => true; + + protected override void OnDrag(DragEvent e) + { + var newPos = Position + e.Delta; + this.MoveTo(Vector2.Clamp(newPos, Vector2.Zero, Parent.Size)); + } + + protected override void OnDragEnd(DragEndEvent e) + { + areaOffset.Value = Position; + base.OnDragEnd(e); + } + } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs index 5d31c38ae7..951cf3802f 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -19,7 +21,7 @@ using osu.Game.Online.Chat; namespace osu.Game.Overlays.Settings.Sections.Input { - public class TabletSettings : SettingsSubsection + public partial class TabletSettings : SettingsSubsection { public TabletAreaSelection AreaSelection { get; private set; } @@ -43,9 +45,9 @@ namespace osu.Game.Overlays.Settings.Sections.Input private GameHost host { get; set; } /// - /// Based on ultrawide monitor configurations. + /// Based on ultrawide monitor configurations, plus a bit of lenience for users which are intentionally aiming for higher horizontal velocity. /// - private const float largest_feasible_aspect_ratio = 21f / 9; + private const float largest_feasible_aspect_ratio = 23f / 9; private readonly BindableNumber aspectRatio = new BindableFloat(1) { @@ -70,7 +72,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, LocalisationManager localisation) { Children = new Drawable[] { @@ -108,11 +110,11 @@ namespace osu.Game.Overlays.Settings.Sections.Input if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows || RuntimeInfo.OS == RuntimeInfo.Platform.Linux) { t.NewLine(); - t.AddText("If your tablet is not detected, please read "); - t.AddLink("this FAQ", LinkAction.External, RuntimeInfo.OS == RuntimeInfo.Platform.Windows - ? @"https://opentabletdriver.net/Wiki/FAQ/Windows" - : @"https://opentabletdriver.net/Wiki/FAQ/Linux"); - t.AddText(" for troubleshooting steps."); + var formattedSource = MessageFormatter.FormatText(localisation.GetLocalisedBindableString(TabletSettingsStrings.NoTabletDetectedDescription( + RuntimeInfo.OS == RuntimeInfo.Platform.Windows + ? @"https://opentabletdriver.net/Wiki/FAQ/Windows" + : @"https://opentabletdriver.net/Wiki/FAQ/Linux")).Value); + t.AddLinks(formattedSource.Text, formattedSource.Links); } }), } @@ -141,6 +143,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input areaOffset.SetDefault(); areaSize.SetDefault(); }, + CanBeShown = { BindTarget = enabled } }, new SettingsButton { @@ -148,25 +151,29 @@ namespace osu.Game.Overlays.Settings.Sections.Input Action = () => { forceAspectRatio((float)host.Window.ClientSize.Width / host.Window.ClientSize.Height); - } + }, + CanBeShown = { BindTarget = enabled } }, new SettingsSlider { TransferValueOnCommit = true, LabelText = TabletSettingsStrings.XOffset, - Current = offsetX + Current = offsetX, + CanBeShown = { BindTarget = enabled } }, new SettingsSlider { TransferValueOnCommit = true, LabelText = TabletSettingsStrings.YOffset, - Current = offsetY + Current = offsetY, + CanBeShown = { BindTarget = enabled } }, new SettingsSlider { TransferValueOnCommit = true, LabelText = TabletSettingsStrings.Rotation, - Current = rotation + Current = rotation, + CanBeShown = { BindTarget = enabled } }, new RotationPresetButtons(tabletHandler) { @@ -179,24 +186,28 @@ namespace osu.Game.Overlays.Settings.Sections.Input { TransferValueOnCommit = true, LabelText = TabletSettingsStrings.AspectRatio, - Current = aspectRatio + Current = aspectRatio, + CanBeShown = { BindTarget = enabled } }, new SettingsCheckbox { LabelText = TabletSettingsStrings.LockAspectRatio, - Current = aspectLock + Current = aspectLock, + CanBeShown = { BindTarget = enabled } }, new SettingsSlider { TransferValueOnCommit = true, LabelText = CommonStrings.Width, - Current = sizeX + Current = sizeX, + CanBeShown = { BindTarget = enabled } }, new SettingsSlider { TransferValueOnCommit = true, LabelText = CommonStrings.Height, - Current = sizeY + Current = sizeY, + CanBeShown = { BindTarget = enabled } }, } }, @@ -213,21 +224,21 @@ namespace osu.Game.Overlays.Settings.Sections.Input rotation.BindTo(tabletHandler.Rotation); areaOffset.BindTo(tabletHandler.AreaOffset); - areaOffset.BindValueChanged(val => + areaOffset.BindValueChanged(val => Schedule(() => { offsetX.Value = val.NewValue.X; offsetY.Value = val.NewValue.Y; - }, true); + }), true); offsetX.BindValueChanged(val => areaOffset.Value = new Vector2(val.NewValue, areaOffset.Value.Y)); offsetY.BindValueChanged(val => areaOffset.Value = new Vector2(areaOffset.Value.X, val.NewValue)); areaSize.BindTo(tabletHandler.AreaSize); - areaSize.BindValueChanged(val => + areaSize.BindValueChanged(val => Schedule(() => { sizeX.Value = val.NewValue.X; sizeY.Value = val.NewValue.Y; - }, true); + }), true); sizeX.BindValueChanged(val => { @@ -253,7 +264,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input }); tablet.BindTo(tabletHandler.Tablet); - tablet.BindValueChanged(val => + tablet.BindValueChanged(val => Schedule(() => { Scheduler.AddOnce(updateVisibility); @@ -272,7 +283,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input sizeY.Default = sizeY.MaxValue = tab.Size.Y; areaSize.Default = new Vector2(sizeX.Default, sizeY.Default); - }, true); + areaOffset.Default = new Vector2(offsetX.Default, offsetY.Default); + }), true); } private void updateVisibility() @@ -307,9 +319,9 @@ namespace osu.Game.Overlays.Settings.Sections.Input // if lock is applied (or the specified values were out of range) aim to adjust the axis the user was not adjusting to conform. if (sizeChanged == sizeX) - sizeY.Value = (int)(areaSize.Value.X / aspectRatio.Value); + sizeY.Value = getHeight(areaSize.Value.X, aspectRatio.Value); else - sizeX.Value = (int)(areaSize.Value.Y * aspectRatio.Value); + sizeX.Value = getWidth(areaSize.Value.Y, aspectRatio.Value); } finally { @@ -323,12 +335,12 @@ namespace osu.Game.Overlays.Settings.Sections.Input { aspectLock.Value = false; - int proposedHeight = (int)(sizeX.Value / aspectRatio); + float proposedHeight = getHeight(sizeX.Value, aspectRatio); if (proposedHeight < sizeY.MaxValue) sizeY.Value = proposedHeight; else - sizeX.Value = (int)(sizeY.Value * aspectRatio); + sizeX.Value = getWidth(sizeY.Value, aspectRatio); updateAspectRatio(); @@ -339,5 +351,9 @@ namespace osu.Game.Overlays.Settings.Sections.Input private void updateAspectRatio() => aspectRatio.Value = currentAspectRatio; private float currentAspectRatio => sizeX.Value / sizeY.Value; + + private static float getHeight(float width, float aspectRatio) => width / aspectRatio; + + private static float getWidth(float height, float aspectRatio) => height * aspectRatio; } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/TouchSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/TouchSettings.cs new file mode 100644 index 0000000000..8d1b12d5b2 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Input/TouchSettings.cs @@ -0,0 +1,40 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Input.Handlers.Touch; +using osu.Framework.Localisation; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Settings.Sections.Input +{ + public partial class TouchSettings : SettingsSubsection + { + private readonly TouchHandler handler; + + public TouchSettings(TouchHandler handler) + { + this.handler = handler; + } + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + new SettingsCheckbox + { + LabelText = CommonStrings.Enabled, + Current = handler.Enabled + }, + }; + } + + public override IEnumerable FilterTerms => base.FilterTerms.Concat(new LocalisableString[] { @"touchscreen" }); + + protected override LocalisableString Header => handler.Description; + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Input/VariantBindingsSubsection.cs b/osu.Game/Overlays/Settings/Sections/Input/VariantBindingsSubsection.cs index a0f069b3bb..d00de7f549 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/VariantBindingsSubsection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/VariantBindingsSubsection.cs @@ -6,8 +6,10 @@ using osu.Game.Rulesets; namespace osu.Game.Overlays.Settings.Sections.Input { - public class VariantBindingsSubsection : KeyBindingsSubsection + public partial class VariantBindingsSubsection : KeyBindingsSubsection { + protected override bool AutoAdvanceTarget => true; + protected override LocalisableString Header { get; } public VariantBindingsSubsection(RulesetInfo ruleset, int variant) diff --git a/osu.Game/Overlays/Settings/Sections/InputSection.cs b/osu.Game/Overlays/Settings/Sections/InputSection.cs index d2c5d2fcf7..0647068da7 100644 --- a/osu.Game/Overlays/Settings/Sections/InputSection.cs +++ b/osu.Game/Overlays/Settings/Sections/InputSection.cs @@ -1,14 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Handlers; -using osu.Framework.Input.Handlers.Joystick; -using osu.Framework.Input.Handlers.Midi; -using osu.Framework.Input.Handlers.Mouse; -using osu.Framework.Input.Handlers.Tablet; using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Game.Localisation; @@ -16,15 +14,12 @@ using osu.Game.Overlays.Settings.Sections.Input; namespace osu.Game.Overlays.Settings.Sections { - public class InputSection : SettingsSection + public partial class InputSection : SettingsSection { private readonly KeyBindingPanel keyConfig; public override LocalisableString Header => InputSettingsStrings.InputSectionHeader; - [Resolved] - private GameHost host { get; set; } - public override Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.Solid.Keyboard @@ -36,7 +31,7 @@ namespace osu.Game.Overlays.Settings.Sections } [BackgroundDependencyLoader] - private void load() + private void load(GameHost host, OsuGameBase game) { Children = new Drawable[] { @@ -45,45 +40,14 @@ namespace osu.Game.Overlays.Settings.Sections foreach (var handler in host.AvailableInputHandlers) { - var handlerSection = createSectionFor(handler); + var handlerSection = game.CreateSettingsSubsectionFor(handler); if (handlerSection != null) Add(handlerSection); } } - private SettingsSubsection createSectionFor(InputHandler handler) - { - SettingsSubsection section; - - switch (handler) - { - // ReSharper disable once SuspiciousTypeConversion.Global (net standard fuckery) - case ITabletHandler th: - section = new TabletSettings(th); - break; - - case MouseHandler mh: - section = new MouseSettings(mh); - break; - - // whitelist the handlers which should be displayed to avoid any weird cases of users touching settings they shouldn't. - case JoystickHandler jh: - section = new JoystickSettings(jh); - break; - - case MidiHandler _: - section = new HandlerSection(handler); - break; - - default: - return null; - } - - return section; - } - - private class HandlerSection : SettingsSubsection + public partial class HandlerSection : SettingsSubsection { private readonly InputHandler handler; diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/BeatmapSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/BeatmapSettings.cs new file mode 100644 index 0000000000..4b1836ed86 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/BeatmapSettings.cs @@ -0,0 +1,73 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Settings.Sections.Maintenance +{ + public partial class BeatmapSettings : SettingsSubsection + { + protected override LocalisableString Header => CommonStrings.Beatmaps; + + private SettingsButton deleteBeatmapsButton = null!; + private SettingsButton deleteBeatmapVideosButton = null!; + private SettingsButton restoreButton = null!; + private SettingsButton undeleteButton = null!; + + [BackgroundDependencyLoader] + private void load(BeatmapManager beatmaps, IDialogOverlay? dialogOverlay) + { + Add(deleteBeatmapsButton = new DangerousSettingsButton + { + Text = MaintenanceSettingsStrings.DeleteAllBeatmaps, + Action = () => + { + dialogOverlay?.Push(new MassDeleteConfirmationDialog(() => + { + deleteBeatmapsButton.Enabled.Value = false; + Task.Run(() => beatmaps.Delete()).ContinueWith(_ => Schedule(() => deleteBeatmapsButton.Enabled.Value = true)); + })); + } + }); + + Add(deleteBeatmapVideosButton = new DangerousSettingsButton + { + Text = MaintenanceSettingsStrings.DeleteAllBeatmapVideos, + Action = () => + { + dialogOverlay?.Push(new MassVideoDeleteConfirmationDialog(() => + { + deleteBeatmapVideosButton.Enabled.Value = false; + Task.Run(beatmaps.DeleteAllVideos).ContinueWith(_ => Schedule(() => deleteBeatmapVideosButton.Enabled.Value = true)); + })); + } + }); + AddRange(new Drawable[] + { + restoreButton = new SettingsButton + { + Text = MaintenanceSettingsStrings.RestoreAllHiddenDifficulties, + Action = () => + { + restoreButton.Enabled.Value = false; + Task.Run(beatmaps.RestoreAll).ContinueWith(_ => Schedule(() => restoreButton.Enabled.Value = true)); + } + }, + undeleteButton = new SettingsButton + { + Text = MaintenanceSettingsStrings.RestoreAllRecentlyDeletedBeatmaps, + Action = () => + { + undeleteButton.Enabled.Value = false; + Task.Run(beatmaps.UndeleteAll).ContinueWith(_ => Schedule(() => undeleteButton.Enabled.Value = true)); + } + } + }); + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/CollectionsSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/CollectionsSettings.cs new file mode 100644 index 0000000000..09acc22c25 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/CollectionsSettings.cs @@ -0,0 +1,42 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Localisation; +using osu.Game.Collections; +using osu.Game.Database; +using osu.Game.Localisation; +using osu.Game.Overlays.Notifications; + +namespace osu.Game.Overlays.Settings.Sections.Maintenance +{ + public partial class CollectionsSettings : SettingsSubsection + { + protected override LocalisableString Header => CommonStrings.Collections; + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + [Resolved] + private INotificationOverlay? notificationOverlay { get; set; } + + [BackgroundDependencyLoader] + private void load(IDialogOverlay? dialogOverlay) + { + Add(new DangerousSettingsButton + { + Text = MaintenanceSettingsStrings.DeleteAllCollections, + Action = () => + { + dialogOverlay?.Push(new MassDeleteConfirmationDialog(deleteAllCollections)); + } + }); + } + + private void deleteAllCollections() + { + realm.Write(r => r.RemoveAll()); + notificationOverlay?.Post(new ProgressCompletionNotification { Text = MaintenanceSettingsStrings.DeletedAllCollections }); + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs index c7fd248842..e87ca32bf6 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using System.IO; using osu.Framework.Allocation; @@ -11,16 +13,15 @@ using osuTK; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; -using osu.Game.Graphics.UserInterface; using osu.Framework.Screens; using osu.Game.Graphics.Containers; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Maintenance { - public abstract class DirectorySelectScreen : OsuScreen + public abstract partial class DirectorySelectScreen : OsuScreen { - private TriangleButton selectionButton; + private RoundedButton selectionButton; private OsuDirectorySelector directorySelector; @@ -99,7 +100,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance }, new Drawable[] { - selectionButton = new TriangleButton + selectionButton = new RoundedButton { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs deleted file mode 100644 index 054de8dbd7..0000000000 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs +++ /dev/null @@ -1,175 +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.Threading.Tasks; -using JetBrains.Annotations; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Localisation; -using osu.Game.Beatmaps; -using osu.Game.Collections; -using osu.Game.Database; -using osu.Game.Localisation; -using osu.Game.Scoring; -using osu.Game.Skinning; - -namespace osu.Game.Overlays.Settings.Sections.Maintenance -{ - public class GeneralSettings : SettingsSubsection - { - protected override LocalisableString Header => "General"; - - private SettingsButton importBeatmapsButton; - private SettingsButton importScoresButton; - private SettingsButton importSkinsButton; - private SettingsButton importCollectionsButton; - private SettingsButton deleteBeatmapsButton; - private SettingsButton deleteScoresButton; - private SettingsButton deleteSkinsButton; - private SettingsButton restoreButton; - private SettingsButton undeleteButton; - private SettingsButton deleteBeatmapVideosButton; - - [BackgroundDependencyLoader(permitNulls: true)] - private void load(BeatmapManager beatmaps, ScoreManager scores, SkinManager skins, [CanBeNull] CollectionManager collectionManager, [CanBeNull] LegacyImportManager legacyImportManager, IDialogOverlay dialogOverlay) - { - if (legacyImportManager?.SupportsImportFromStable == true) - { - Add(importBeatmapsButton = new SettingsButton - { - Text = MaintenanceSettingsStrings.ImportBeatmapsFromStable, - Action = () => - { - importBeatmapsButton.Enabled.Value = false; - legacyImportManager.ImportFromStableAsync(StableContent.Beatmaps).ContinueWith(t => Schedule(() => importBeatmapsButton.Enabled.Value = true)); - } - }); - } - - Add(deleteBeatmapsButton = new DangerousSettingsButton - { - Text = MaintenanceSettingsStrings.DeleteAllBeatmaps, - Action = () => - { - dialogOverlay?.Push(new MassDeleteConfirmationDialog(() => - { - deleteBeatmapsButton.Enabled.Value = false; - Task.Run(() => beatmaps.Delete()).ContinueWith(t => Schedule(() => deleteBeatmapsButton.Enabled.Value = true)); - })); - } - }); - - Add(deleteBeatmapVideosButton = new DangerousSettingsButton - { - Text = MaintenanceSettingsStrings.DeleteAllBeatmapVideos, - Action = () => - { - dialogOverlay?.Push(new MassVideoDeleteConfirmationDialog(() => - { - deleteBeatmapVideosButton.Enabled.Value = false; - Task.Run(beatmaps.DeleteAllVideos).ContinueWith(t => Schedule(() => deleteBeatmapVideosButton.Enabled.Value = true)); - })); - } - }); - - if (legacyImportManager?.SupportsImportFromStable == true) - { - Add(importScoresButton = new SettingsButton - { - Text = MaintenanceSettingsStrings.ImportScoresFromStable, - Action = () => - { - importScoresButton.Enabled.Value = false; - legacyImportManager.ImportFromStableAsync(StableContent.Scores).ContinueWith(t => Schedule(() => importScoresButton.Enabled.Value = true)); - } - }); - } - - Add(deleteScoresButton = new DangerousSettingsButton - { - Text = MaintenanceSettingsStrings.DeleteAllScores, - Action = () => - { - dialogOverlay?.Push(new MassDeleteConfirmationDialog(() => - { - deleteScoresButton.Enabled.Value = false; - Task.Run(() => scores.Delete()).ContinueWith(t => Schedule(() => deleteScoresButton.Enabled.Value = true)); - })); - } - }); - - if (legacyImportManager?.SupportsImportFromStable == true) - { - Add(importSkinsButton = new SettingsButton - { - Text = MaintenanceSettingsStrings.ImportSkinsFromStable, - Action = () => - { - importSkinsButton.Enabled.Value = false; - legacyImportManager.ImportFromStableAsync(StableContent.Skins).ContinueWith(t => Schedule(() => importSkinsButton.Enabled.Value = true)); - } - }); - } - - Add(deleteSkinsButton = new DangerousSettingsButton - { - Text = MaintenanceSettingsStrings.DeleteAllSkins, - Action = () => - { - dialogOverlay?.Push(new MassDeleteConfirmationDialog(() => - { - deleteSkinsButton.Enabled.Value = false; - Task.Run(() => skins.Delete()).ContinueWith(t => Schedule(() => deleteSkinsButton.Enabled.Value = true)); - })); - } - }); - - if (collectionManager != null) - { - if (legacyImportManager?.SupportsImportFromStable == true) - { - Add(importCollectionsButton = new SettingsButton - { - Text = MaintenanceSettingsStrings.ImportCollectionsFromStable, - Action = () => - { - importCollectionsButton.Enabled.Value = false; - legacyImportManager.ImportFromStableAsync(StableContent.Collections).ContinueWith(t => Schedule(() => importCollectionsButton.Enabled.Value = true)); - } - }); - } - - Add(new DangerousSettingsButton - { - Text = MaintenanceSettingsStrings.DeleteAllCollections, - Action = () => - { - dialogOverlay?.Push(new MassDeleteConfirmationDialog(collectionManager.DeleteAll)); - } - }); - } - - AddRange(new Drawable[] - { - restoreButton = new SettingsButton - { - Text = MaintenanceSettingsStrings.RestoreAllHiddenDifficulties, - Action = () => - { - restoreButton.Enabled.Value = false; - Task.Run(beatmaps.RestoreAll).ContinueWith(t => Schedule(() => restoreButton.Enabled.Value = true)); - } - }, - undeleteButton = new SettingsButton - { - Text = MaintenanceSettingsStrings.RestoreAllRecentlyDeletedBeatmaps, - Action = () => - { - undeleteButton.Enabled.Value = false; - Task.Run(beatmaps.UndeleteAll).ContinueWith(t => Schedule(() => undeleteButton.Enabled.Value = true)); - } - }, - }); - } - } -} diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MassDeleteConfirmationDialog.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MassDeleteConfirmationDialog.cs index c481c80d82..99ef62d94b 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/MassDeleteConfirmationDialog.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MassDeleteConfirmationDialog.cs @@ -2,31 +2,16 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osu.Framework.Graphics.Sprites; using osu.Game.Overlays.Dialog; namespace osu.Game.Overlays.Settings.Sections.Maintenance { - public class MassDeleteConfirmationDialog : PopupDialog + public partial class MassDeleteConfirmationDialog : DangerousActionDialog { public MassDeleteConfirmationDialog(Action deleteAction) { BodyText = "Everything?"; - - Icon = FontAwesome.Regular.TrashAlt; - HeaderText = @"Confirm deletion of"; - Buttons = new PopupDialogButton[] - { - new PopupDialogDangerousButton - { - Text = @"Yes. Go for it.", - Action = deleteAction - }, - new PopupDialogCancelButton - { - Text = @"No! Abort mission!", - }, - }; + DangerousAction = deleteAction; } } } diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MassVideoDeleteConfirmationDialog.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MassVideoDeleteConfirmationDialog.cs index fc8c9d497b..6312e09b3e 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/MassVideoDeleteConfirmationDialog.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MassVideoDeleteConfirmationDialog.cs @@ -5,7 +5,7 @@ using System; namespace osu.Game.Overlays.Settings.Sections.Maintenance { - public class MassVideoDeleteConfirmationDialog : MassDeleteConfirmationDialog + public partial class MassVideoDeleteConfirmationDialog : MassDeleteConfirmationDialog { public MassVideoDeleteConfirmationDialog(Action deleteAction) : base(deleteAction) diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs index b7b797936e..5b24460ac2 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.IO; using System.Threading.Tasks; using osu.Framework.Allocation; @@ -13,13 +15,14 @@ using osu.Framework.Screens; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Overlays.Notifications; using osu.Game.Screens; using osuTK; namespace osu.Game.Overlays.Settings.Sections.Maintenance { - public class MigrationRunScreen : OsuScreen + public partial class MigrationRunScreen : OsuScreen { private readonly DirectoryInfo destination; @@ -69,14 +72,14 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Text = "Migration in progress", + Text = MaintenanceSettingsStrings.MigrationInProgress, Font = OsuFont.Default.With(size: 40) }, new OsuSpriteText { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Text = "This could take a few minutes depending on the speed of your disk(s).", + Text = MaintenanceSettingsStrings.MigrationDescription, Font = OsuFont.Default.With(size: 30) }, new LoadingSpinner(true) @@ -87,7 +90,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Text = "Please avoid interacting with the game!", + Text = MaintenanceSettingsStrings.ProhibitedInteractDuringMigration, Font = OsuFont.Default.With(size: 30) }, } @@ -109,7 +112,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { notifications.Post(new SimpleNotification { - Text = "Some files couldn't be cleaned up during migration. Clicking this notification will open the folder so you can manually clean things up.", + Text = MaintenanceSettingsStrings.FailedCleanupNotification, Activated = () => { originalStorage.PresentExternally(); diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs index 3cb5521e51..309e2a1401 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.IO; using System.Linq; @@ -10,11 +12,12 @@ using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Game.IO; +using osu.Game.Localisation; using osu.Game.Overlays.Dialog; namespace osu.Game.Overlays.Settings.Sections.Maintenance { - public class MigrationSelectScreen : DirectorySelectScreen + public partial class MigrationSelectScreen : DirectorySelectScreen { [Resolved] private Storage storage { get; set; } @@ -33,7 +36,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance public override bool HideOverlaysOnEnter => true; - public override LocalisableString HeaderText => "Please select a new location"; + public override LocalisableString HeaderText => MaintenanceSettingsStrings.SelectNewLocation; protected override void OnSelection(DirectoryInfo directory) { @@ -44,17 +47,17 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance var directoryInfos = target.GetDirectories(); var fileInfos = target.GetFiles(); - if (directoryInfos.Length > 0 || fileInfos.Length > 0) + if (directoryInfos.Length > 0 || fileInfos.Length > 0 || target.Parent == null) { // Quick test for whether there's already an osu! install at the target path. if (fileInfos.Any(f => f.Name == OsuGameBase.CLIENT_DATABASE_FILENAME)) { - dialogOverlay.Push(new ConfirmDialog("The target directory already seems to have an osu! install. Use that data instead?", () => + dialogOverlay.Push(new ConfirmDialog(MaintenanceSettingsStrings.TargetDirectoryAlreadyInstalledOsu, () => { - dialogOverlay.Push(new ConfirmDialog("To complete this operation, osu! will close. Please open it again to use the new data location.", () => + dialogOverlay.Push(new ConfirmDialog(MaintenanceSettingsStrings.RestartAndReOpenRequiredForCompletion, () => { (storage as OsuStorage)?.ChangeDataPath(target.FullName); - game.GracefullyExit(); + game.Exit(); }, () => { })); }, () => { })); @@ -62,7 +65,9 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance return; } - target = target.CreateSubdirectory("osu-lazer"); + // Not using CreateSubDirectory as it throws unexpectedly when attempting to create a directory when already at the root of a disk. + // See https://cs.github.com/dotnet/runtime/blob/f1bdd5a6182f43f3928b389b03f7bc26f826c8bc/src/libraries/System.Private.CoreLib/src/System/IO/DirectoryInfo.cs#L88-L94 + target = Directory.CreateDirectory(Path.Combine(target.FullName, @"osu-lazer")); } } catch (Exception e) diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/ModPresetSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/ModPresetSettings.cs new file mode 100644 index 0000000000..ba45d9c896 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/ModPresetSettings.cs @@ -0,0 +1,89 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Framework.Logging; +using osu.Game.Database; +using osu.Game.Overlays.Notifications; +using osu.Game.Rulesets.Mods; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Settings.Sections.Maintenance +{ + public partial class ModPresetSettings : SettingsSubsection + { + protected override LocalisableString Header => CommonStrings.ModPresets; + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + [Resolved] + private INotificationOverlay? notificationOverlay { get; set; } + + private SettingsButton undeleteButton = null!; + private SettingsButton deleteAllButton = null!; + + [BackgroundDependencyLoader] + private void load(IDialogOverlay? dialogOverlay) + { + AddRange(new Drawable[] + { + deleteAllButton = new DangerousSettingsButton + { + Text = MaintenanceSettingsStrings.DeleteAllModPresets, + Action = () => + { + dialogOverlay?.Push(new MassDeleteConfirmationDialog(() => + { + deleteAllButton.Enabled.Value = false; + Task.Run(deleteAllModPresets).ContinueWith(t => Schedule(onAllModPresetsDeleted, t)); + })); + } + }, + undeleteButton = new SettingsButton + { + Text = MaintenanceSettingsStrings.RestoreAllRecentlyDeletedModPresets, + Action = () => Task.Run(undeleteModPresets).ContinueWith(t => Schedule(onModPresetsUndeleted, t)) + } + }); + } + + private void deleteAllModPresets() => + realm.Write(r => + { + foreach (var preset in r.All()) + preset.DeletePending = true; + }); + + private void onAllModPresetsDeleted(Task deletionTask) + { + deleteAllButton.Enabled.Value = true; + + if (deletionTask.IsCompletedSuccessfully) + notificationOverlay?.Post(new ProgressCompletionNotification { Text = MaintenanceSettingsStrings.DeletedAllModPresets }); + else if (deletionTask.IsFaulted) + Logger.Error(deletionTask.Exception, "Failed to delete all mod presets"); + } + + private void undeleteModPresets() => + realm.Write(r => + { + foreach (var preset in r.All().Where(preset => preset.DeletePending)) + preset.DeletePending = false; + }); + + private void onModPresetsUndeleted(Task undeletionTask) + { + undeleteButton.Enabled.Value = true; + + if (undeletionTask.IsCompletedSuccessfully) + notificationOverlay?.Post(new ProgressCompletionNotification { Text = MaintenanceSettingsStrings.RestoredAllDeletedModPresets }); + else if (undeletionTask.IsFaulted) + Logger.Error(undeletionTask.Exception, "Failed to restore mod presets"); + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/ScoreSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/ScoreSettings.cs new file mode 100644 index 0000000000..c6f4f1e1a5 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/ScoreSettings.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Localisation; +using osu.Game.Localisation; +using osu.Game.Scoring; + +namespace osu.Game.Overlays.Settings.Sections.Maintenance +{ + public partial class ScoreSettings : SettingsSubsection + { + protected override LocalisableString Header => CommonStrings.Scores; + + private SettingsButton deleteScoresButton = null!; + + [BackgroundDependencyLoader] + private void load(ScoreManager scores, IDialogOverlay? dialogOverlay) + { + Add(deleteScoresButton = new DangerousSettingsButton + { + Text = MaintenanceSettingsStrings.DeleteAllScores, + Action = () => + { + dialogOverlay?.Push(new MassDeleteConfirmationDialog(() => + { + deleteScoresButton.Enabled.Value = false; + Task.Run(() => scores.Delete()).ContinueWith(_ => Schedule(() => deleteScoresButton.Enabled.Value = true)); + })); + } + }); + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/SkinSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/SkinSettings.cs new file mode 100644 index 0000000000..c3ac49af6d --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/SkinSettings.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Localisation; +using osu.Game.Localisation; +using osu.Game.Skinning; + +namespace osu.Game.Overlays.Settings.Sections.Maintenance +{ + public partial class SkinSettings : SettingsSubsection + { + protected override LocalisableString Header => CommonStrings.Skins; + + private SettingsButton deleteSkinsButton = null!; + + [BackgroundDependencyLoader] + private void load(SkinManager skins, IDialogOverlay? dialogOverlay) + { + Add(deleteSkinsButton = new DangerousSettingsButton + { + Text = MaintenanceSettingsStrings.DeleteAllSkins, + Action = () => + { + dialogOverlay?.Push(new MassDeleteConfirmationDialog(() => + { + deleteSkinsButton.Enabled.Value = false; + Task.Run(() => skins.Delete()).ContinueWith(_ => Schedule(() => deleteSkinsButton.Enabled.Value = true)); + })); + } + }); + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectoryLocationDialog.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectoryLocationDialog.cs index b16fd9a5a1..633bf8c5a5 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectoryLocationDialog.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectoryLocationDialog.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; @@ -10,7 +12,7 @@ using osu.Game.Screens; namespace osu.Game.Overlays.Settings.Sections.Maintenance { - public class StableDirectoryLocationDialog : PopupDialog + public partial class StableDirectoryLocationDialog : PopupDialog { [Resolved] private IPerformFromScreenRunner performer { get; set; } diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectorySelectScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectorySelectScreen.cs index 86934ae514..048f3ee683 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectorySelectScreen.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectorySelectScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.IO; using System.Linq; using System.Threading.Tasks; @@ -9,7 +11,7 @@ using osu.Framework.Screens; namespace osu.Game.Overlays.Settings.Sections.Maintenance { - public class StableDirectorySelectScreen : DirectorySelectScreen + public partial class StableDirectorySelectScreen : DirectorySelectScreen { private readonly TaskCompletionSource taskCompletionSource; diff --git a/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs b/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs index 9410a87848..bb0a952164 100644 --- a/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs +++ b/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs @@ -9,7 +9,7 @@ using osu.Game.Overlays.Settings.Sections.Maintenance; namespace osu.Game.Overlays.Settings.Sections { - public class MaintenanceSection : SettingsSection + public partial class MaintenanceSection : SettingsSection { public override LocalisableString Header => MaintenanceSettingsStrings.MaintenanceSectionHeader; @@ -22,7 +22,11 @@ namespace osu.Game.Overlays.Settings.Sections { Children = new Drawable[] { - new GeneralSettings() + new BeatmapSettings(), + new SkinSettings(), + new CollectionsSettings(), + new ScoreSettings(), + new ModPresetSettings() }; } } diff --git a/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs b/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs index 351a32c72e..dc6743c042 100644 --- a/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; @@ -9,7 +11,7 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Online { - public class AlertsAndPrivacySettings : SettingsSubsection + public partial class AlertsAndPrivacySettings : SettingsSubsection { protected override LocalisableString Header => OnlineSettingsStrings.AlertsAndPrivacyHeader; diff --git a/osu.Game/Overlays/Settings/Sections/Online/IntegrationSettings.cs b/osu.Game/Overlays/Settings/Sections/Online/IntegrationSettings.cs index 0207f2fd01..33748d0f5e 100644 --- a/osu.Game/Overlays/Settings/Sections/Online/IntegrationSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Online/IntegrationSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; @@ -9,7 +11,7 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Online { - public class IntegrationSettings : SettingsSubsection + public partial class IntegrationSettings : SettingsSubsection { protected override LocalisableString Header => OnlineSettingsStrings.IntegrationsHeader; diff --git a/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs b/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs index e864260cc6..d0707a434a 100644 --- a/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; @@ -9,7 +11,7 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Online { - public class WebSettings : SettingsSubsection + public partial class WebSettings : SettingsSubsection { protected override LocalisableString Header => OnlineSettingsStrings.WebHeader; diff --git a/osu.Game/Overlays/Settings/Sections/OnlineSection.cs b/osu.Game/Overlays/Settings/Sections/OnlineSection.cs index 8b523b90b9..775c6f9839 100644 --- a/osu.Game/Overlays/Settings/Sections/OnlineSection.cs +++ b/osu.Game/Overlays/Settings/Sections/OnlineSection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; @@ -9,7 +11,7 @@ using osu.Game.Overlays.Settings.Sections.Online; namespace osu.Game.Overlays.Settings.Sections { - public class OnlineSection : SettingsSection + public partial class OnlineSection : SettingsSection { public override LocalisableString Header => OnlineSettingsStrings.OnlineSectionHeader; diff --git a/osu.Game/Overlays/Settings/Sections/RulesetSection.cs b/osu.Game/Overlays/Settings/Sections/RulesetSection.cs index b9339d5299..aaad1ec4e2 100644 --- a/osu.Game/Overlays/Settings/Sections/RulesetSection.cs +++ b/osu.Game/Overlays/Settings/Sections/RulesetSection.cs @@ -1,19 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Logging; -using osu.Game.Rulesets; using osu.Game.Localisation; +using osu.Game.Rulesets; namespace osu.Game.Overlays.Settings.Sections { - public class RulesetSection : SettingsSection + public partial class RulesetSection : SettingsSection { public override LocalisableString Header => RulesetSettingsStrings.Rulesets; @@ -29,14 +28,14 @@ namespace osu.Game.Overlays.Settings.Sections { try { - SettingsSubsection section = ruleset.CreateSettings(); + SettingsSubsection? section = ruleset.CreateSettings(); if (section != null) Add(section); } - catch (Exception e) + catch { - Logger.Error(e, "Failed to load ruleset settings"); + Logger.Log($"Failed to load ruleset settings for {ruleset.RulesetInfo.Name}. Please check for an update from the developer.", level: LogLevel.Error); } } } diff --git a/osu.Game/Overlays/Settings/Sections/SizeSlider.cs b/osu.Game/Overlays/Settings/Sections/SizeSlider.cs index c8a46162af..20d77bef0d 100644 --- a/osu.Game/Overlays/Settings/Sections/SizeSlider.cs +++ b/osu.Game/Overlays/Settings/Sections/SizeSlider.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Globalization; using osu.Framework.Localisation; @@ -11,7 +13,7 @@ namespace osu.Game.Overlays.Settings.Sections /// /// A slider intended to show a "size" multiplier number, where 1x is 1.0. /// - public class SizeSlider : OsuSliderBar + public partial class SizeSlider : RoundedSliderBar where T : struct, IEquatable, IComparable, IConvertible, IFormattable { public override LocalisableString TooltipText => Current.Value.ToString(@"0.##x", NumberFormatInfo.CurrentInfo); diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index b83600a16d..5cf8157812 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -12,18 +14,17 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Framework.Platform; -using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; +using osu.Game.Overlays.SkinEditor; using osu.Game.Screens.Select; using osu.Game.Skinning; -using osu.Game.Skinning.Editor; using Realms; namespace osu.Game.Overlays.Settings.Sections { - public class SkinSection : SettingsSection + public partial class SkinSection : SettingsSection { private SkinSettingsDropdown skinDropdown; @@ -34,9 +35,6 @@ namespace osu.Game.Overlays.Settings.Sections Icon = FontAwesome.Solid.PaintBrush }; - private readonly Bindable> dropdownBindable = new Bindable> { Default = DefaultSkin.CreateInfo().ToLiveUnmanaged() }; - private readonly Bindable configBindable = new Bindable(); - private static readonly Live random_skin_info = new SkinInfo { ID = SkinInfo.RANDOM_SKIN, @@ -54,13 +52,15 @@ namespace osu.Game.Overlays.Settings.Sections private IDisposable realmSubscription; [BackgroundDependencyLoader(permitNulls: true)] - private void load(OsuConfigManager config, [CanBeNull] SkinEditorOverlay skinEditor) + private void load([CanBeNull] SkinEditorOverlay skinEditor) { Children = new Drawable[] { skinDropdown = new SkinSettingsDropdown { - LabelText = SkinSettingsStrings.CurrentSkin + LabelText = SkinSettingsStrings.CurrentSkin, + Current = skins.CurrentSkinInfo, + Keywords = new[] { @"skins" } }, new SettingsButton { @@ -70,47 +70,27 @@ namespace osu.Game.Overlays.Settings.Sections new ExportSkinButton(), new DeleteSkinButton(), }; - - config.BindWith(OsuSetting.Skin, configBindable); } protected override void LoadComplete() { base.LoadComplete(); - skinDropdown.Current = dropdownBindable; - - realmSubscription = realm.RegisterForNotifications(r => realm.Realm.All() + realmSubscription = realm.RegisterForNotifications(_ => realm.Realm.All() .Where(s => !s.DeletePending) - .OrderByDescending(s => s.Protected) // protected skins should be at the top. - .ThenBy(s => s.Name, StringComparer.OrdinalIgnoreCase), skinsChanged); + .OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase), skinsChanged); - configBindable.BindValueChanged(id => Scheduler.AddOnce(updateSelectedSkinFromConfig)); - - dropdownBindable.BindValueChanged(dropdownSelectionChanged); - } - - private void dropdownSelectionChanged(ValueChangedEvent> skin) - { - // Only handle cases where it's clear the user has intent to change skins. - if (skin.OldValue == null) return; - - if (skin.NewValue.Equals(random_skin_info)) + skinDropdown.Current.BindValueChanged(skin => { - var skinBefore = skins.CurrentSkinInfo.Value; - - skins.SelectRandomSkin(); - - if (skinBefore == skins.CurrentSkinInfo.Value) + if (skin.NewValue == random_skin_info) { - // the random selection didn't change the skin, so we should manually update the dropdown to match. - dropdownBindable.Value = skins.CurrentSkinInfo.Value; + // before selecting random, set the skin back to the previous selection. + // this is done because at this point it will be random_skin_info, and would + // cause SelectRandomSkin to be unable to skip the previous selection. + skins.CurrentSkinInfo.Value = skin.OldValue; + skins.SelectRandomSkin(); } - - return; - } - - configBindable.Value = skin.NewValue.ID.ToString(); + }); } private void skinsChanged(IRealmCollection sender, ChangeSet changes, Exception error) @@ -120,34 +100,21 @@ namespace osu.Game.Overlays.Settings.Sections if (!sender.Any()) return; - int protectedCount = sender.Count(s => s.Protected); - // For simplicity repopulate the full list. // In the future we should change this to properly handle ChangeSet events. dropdownItems.Clear(); - foreach (var skin in sender) + + dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.ARGON_SKIN).ToLive(realm)); + dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.ARGON_PRO_SKIN).ToLive(realm)); + dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.TRIANGLES_SKIN).ToLive(realm)); + dropdownItems.Add(sender.Single(s => s.ID == SkinInfo.CLASSIC_SKIN).ToLive(realm)); + + dropdownItems.Add(random_skin_info); + + foreach (var skin in sender.Where(s => !s.Protected)) dropdownItems.Add(skin.ToLive(realm)); - dropdownItems.Insert(protectedCount, random_skin_info); - Schedule(() => - { - skinDropdown.Items = dropdownItems; - - updateSelectedSkinFromConfig(); - }); - } - - private void updateSelectedSkinFromConfig() - { - if (!skinDropdown.Items.Any()) - return; - - Live skin = null; - - if (Guid.TryParse(configBindable.Value, out var configId)) - skin = skinDropdown.Items.FirstOrDefault(s => s.ID == configId); - - dropdownBindable.Value = skin ?? skinDropdown.Items.First(); + Schedule(() => skinDropdown.Items = dropdownItems); } protected override void Dispose(bool isDisposing) @@ -157,17 +124,17 @@ namespace osu.Game.Overlays.Settings.Sections realmSubscription?.Dispose(); } - private class SkinSettingsDropdown : SettingsDropdown> + private partial class SkinSettingsDropdown : SettingsDropdown> { protected override OsuDropdown> CreateDropdown() => new SkinDropdownControl(); - private class SkinDropdownControl : DropdownControl + private partial class SkinDropdownControl : DropdownControl { protected override LocalisableString GenerateItemText(Live item) => item.ToString(); } } - public class ExportSkinButton : SettingsButton + public partial class ExportSkinButton : SettingsButton { [Resolved] private SkinManager skins { get; set; } @@ -205,7 +172,7 @@ namespace osu.Game.Overlays.Settings.Sections } } - public class DeleteSkinButton : DangerousSettingsButton + public partial class DeleteSkinButton : DangerousSettingsButton { [Resolved] private SkinManager skins { get; set; } diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs index dd1b9cc2a0..2e8d005401 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; @@ -10,7 +12,7 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.UserInterface { - public class GeneralSettings : SettingsSubsection + public partial class GeneralSettings : SettingsSubsection { protected override LocalisableString Header => UserInterfaceStrings.GeneralHeader; diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs index fceffa09c5..4577fadb01 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Overlays.Settings.Sections.UserInterface { - public class MainMenuSettings : SettingsSubsection + public partial class MainMenuSettings : SettingsSubsection { protected override LocalisableString Header => UserInterfaceStrings.MainMenuHeader; diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs index b91b5c5243..d3303e409c 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs @@ -1,33 +1,24 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; +#nullable disable + using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Configuration; -using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; +using osu.Game.Overlays.Mods.Input; namespace osu.Game.Overlays.Settings.Sections.UserInterface { - public class SongSelectSettings : SettingsSubsection + public partial class SongSelectSettings : SettingsSubsection { - private Bindable minStars; - private Bindable maxStars; - protected override LocalisableString Header => UserInterfaceStrings.SongSelectHeader; [BackgroundDependencyLoader] private void load(OsuConfigManager config) { - minStars = config.GetBindable(OsuSetting.DisplayStarsMinimum); - maxStars = config.GetBindable(OsuSetting.DisplayStarsMaximum); - - minStars.ValueChanged += min => maxStars.Value = Math.Max(min.NewValue, maxStars.Value); - maxStars.ValueChanged += max => minStars.Value = Math.Min(max.NewValue, minStars.Value); - Children = new Drawable[] { new SettingsCheckbox @@ -41,36 +32,24 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface LabelText = UserInterfaceStrings.ShowConvertedBeatmaps, Current = config.GetBindable(OsuSetting.ShowConvertedBeatmaps), }, - new SettingsSlider - { - LabelText = UserInterfaceStrings.StarsMinimum, - Current = config.GetBindable(OsuSetting.DisplayStarsMinimum), - KeyboardStep = 0.1f, - Keywords = new[] { "minimum", "maximum", "star", "difficulty" } - }, - new SettingsSlider - { - LabelText = UserInterfaceStrings.StarsMaximum, - Current = config.GetBindable(OsuSetting.DisplayStarsMaximum), - KeyboardStep = 0.1f, - Keywords = new[] { "minimum", "maximum", "star", "difficulty" } - }, new SettingsEnumDropdown { LabelText = UserInterfaceStrings.RandomSelectionAlgorithm, Current = config.GetBindable(OsuSetting.RandomSelectAlgorithm), + }, + new SettingsEnumDropdown + { + LabelText = UserInterfaceStrings.ModSelectHotkeyStyle, + Current = config.GetBindable(OsuSetting.ModSelectHotkeyStyle), + ClassicDefault = ModSelectHotkeyStyle.Classic + }, + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.BackgroundBlur, + Current = config.GetBindable(OsuSetting.SongSelectBackgroundBlur), + ClassicDefault = false, } }; } - - private class MaximumStarsSlider : StarsSlider - { - public override LocalisableString TooltipText => Current.IsDefault ? UserInterfaceStrings.NoLimit : base.TooltipText; - } - - private class StarsSlider : OsuSliderBar - { - public override LocalisableString TooltipText => Current.Value.ToString(@"0.## stars"); - } } } diff --git a/osu.Game/Overlays/Settings/Sections/UserInterfaceSection.cs b/osu.Game/Overlays/Settings/Sections/UserInterfaceSection.cs index 6228c4c99a..0926574a54 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterfaceSection.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterfaceSection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; @@ -9,7 +11,7 @@ using osu.Game.Overlays.Settings.Sections.UserInterface; namespace osu.Game.Overlays.Settings.Sections { - public class UserInterfaceSection : SettingsSection + public partial class UserInterfaceSection : SettingsSection { public override LocalisableString Header => UserInterfaceStrings.UserInterfaceSectionHeader; diff --git a/osu.Game/Overlays/Settings/SettingsButton.cs b/osu.Game/Overlays/Settings/SettingsButton.cs index 9e4dc763ec..a837444758 100644 --- a/osu.Game/Overlays/Settings/SettingsButton.cs +++ b/osu.Game/Overlays/Settings/SettingsButton.cs @@ -1,19 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -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.Localisation; -using osu.Game.Graphics; using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Overlays.Settings { - public class SettingsButton : RoundedButton, IHasTooltip + public partial class SettingsButton : RoundedButton, IHasTooltip, IConditionalFilterable { public SettingsButton() { @@ -21,22 +20,25 @@ namespace osu.Game.Overlays.Settings Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS }; } - [BackgroundDependencyLoader(true)] - private void load([CanBeNull] OverlayColourProvider overlayColourProvider, OsuColour colours) - { - DefaultBackgroundColour = overlayColourProvider?.Highlight1 ?? colours.Blue3; - } - public LocalisableString TooltipText { get; set; } + public IEnumerable Keywords { get; set; } = Array.Empty(); + + public BindableBool CanBeShown { get; } = new BindableBool(true); + IBindable IConditionalFilterable.CanBeShown => CanBeShown; + public override IEnumerable FilterTerms { get { if (TooltipText != default) - return base.FilterTerms.Append(TooltipText); + yield return TooltipText; - return base.FilterTerms; + foreach (string s in Keywords) + yield return s; + + foreach (LocalisableString s in base.FilterTerms) + yield return s; } } } diff --git a/osu.Game/Overlays/Settings/SettingsCheckbox.cs b/osu.Game/Overlays/Settings/SettingsCheckbox.cs index 8a8fed4d30..a413bcf220 100644 --- a/osu.Game/Overlays/Settings/SettingsCheckbox.cs +++ b/osu.Game/Overlays/Settings/SettingsCheckbox.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Settings { - public class SettingsCheckbox : SettingsItem + public partial class SettingsCheckbox : SettingsItem { private LocalisableString labelText; diff --git a/osu.Game/Overlays/Settings/SettingsDropdown.cs b/osu.Game/Overlays/Settings/SettingsDropdown.cs index 3c10c084ab..5798d02e03 100644 --- a/osu.Game/Overlays/Settings/SettingsDropdown.cs +++ b/osu.Game/Overlays/Settings/SettingsDropdown.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Bindables; @@ -10,7 +12,7 @@ using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Settings { - public class SettingsDropdown : SettingsItem + public partial class SettingsDropdown : SettingsItem { protected new OsuDropdown Control => (OsuDropdown)base.Control; @@ -32,7 +34,7 @@ namespace osu.Game.Overlays.Settings protected virtual OsuDropdown CreateDropdown() => new DropdownControl(); - protected class DropdownControl : OsuDropdown + protected partial class DropdownControl : OsuDropdown { public DropdownControl() { diff --git a/osu.Game/Overlays/Settings/SettingsEnumDropdown.cs b/osu.Game/Overlays/Settings/SettingsEnumDropdown.cs index 199ba14b48..62dd4f2905 100644 --- a/osu.Game/Overlays/Settings/SettingsEnumDropdown.cs +++ b/osu.Game/Overlays/Settings/SettingsEnumDropdown.cs @@ -1,18 +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; using osu.Framework.Graphics; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Settings { - public class SettingsEnumDropdown : SettingsDropdown + public partial class SettingsEnumDropdown : SettingsDropdown where T : struct, Enum { protected override OsuDropdown CreateDropdown() => new DropdownControl(); - protected new class DropdownControl : OsuEnumDropdown + protected new partial class DropdownControl : OsuEnumDropdown { public DropdownControl() { diff --git a/osu.Game/Overlays/Settings/SettingsFooter.cs b/osu.Game/Overlays/Settings/SettingsFooter.cs index 263f2f4829..9ab0fa7ad8 100644 --- a/osu.Game/Overlays/Settings/SettingsFooter.cs +++ b/osu.Game/Overlays/Settings/SettingsFooter.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Logging; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -16,7 +18,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Settings { - public class SettingsFooter : FillFlowContainer + public partial class SettingsFooter : FillFlowContainer { [BackgroundDependencyLoader] private void load(OsuGameBase game, RulesetStore rulesets) @@ -26,32 +28,17 @@ namespace osu.Game.Overlays.Settings Direction = FillDirection.Vertical; Padding = new MarginPadding { Top = 20, Bottom = 30, Horizontal = SettingsPanel.CONTENT_MARGINS }; - var modes = new List(); - - foreach (var ruleset in rulesets.AvailableRulesets) - { - var icon = new ConstrainedIconContainer - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Icon = ruleset.CreateInstance().CreateIcon(), - Colour = Color4.Gray, - Size = new Vector2(20), - }; - - modes.Add(icon); - } + FillFlowContainer modes; Children = new Drawable[] { - new FillFlowContainer + modes = new FillFlowContainer { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Direction = FillDirection.Full, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Children = modes, Spacing = new Vector2(5), Padding = new MarginPadding { Bottom = 10 }, }, @@ -62,26 +49,45 @@ namespace osu.Game.Overlays.Settings Text = game.Name, Font = OsuFont.GetFont(size: 18, weight: FontWeight.Bold), }, - new BuildDisplay(game.Version, DebugUtils.IsDebugBuild) + new BuildDisplay(game.Version) { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, } }; + + foreach (var ruleset in rulesets.AvailableRulesets) + { + try + { + var icon = new ConstrainedIconContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Icon = ruleset.CreateInstance().CreateIcon(), + Colour = Color4.Gray, + Size = new Vector2(20), + }; + + modes.Add(icon); + } + catch + { + Logger.Log($"Could not create ruleset icon for {ruleset.Name}. Please check for an update from the developer.", level: LogLevel.Error); + } + } } - private class BuildDisplay : OsuAnimatedButton + private partial class BuildDisplay : OsuAnimatedButton { private readonly string version; - private readonly bool isDebug; [Resolved] private OsuColour colours { get; set; } - public BuildDisplay(string version, bool isDebug) + public BuildDisplay(string version) { this.version = version; - this.isDebug = isDebug; Content.RelativeSizeAxes = Axes.Y; Content.AutoSizeAxes = AutoSizeAxes = Axes.X; @@ -91,8 +97,7 @@ namespace osu.Game.Overlays.Settings [BackgroundDependencyLoader(true)] private void load(ChangelogOverlay changelog) { - if (!isDebug) - Action = () => changelog?.ShowBuild(OsuGameBase.CLIENT_STREAM_NAME, version); + Action = () => changelog?.ShowBuild(OsuGameBase.CLIENT_STREAM_NAME, version); Add(new OsuSpriteText { @@ -102,7 +107,7 @@ namespace osu.Game.Overlays.Settings Anchor = Anchor.Centre, Origin = Anchor.Centre, Padding = new MarginPadding(5), - Colour = isDebug ? colours.Red : Color4.White, + Colour = DebugUtils.IsDebugBuild ? colours.Red : Color4.White, }); } } diff --git a/osu.Game/Overlays/Settings/SettingsHeader.cs b/osu.Game/Overlays/Settings/SettingsHeader.cs index f9ee8df0bd..f2b84c4ba9 100644 --- a/osu.Game/Overlays/Settings/SettingsHeader.cs +++ b/osu.Game/Overlays/Settings/SettingsHeader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,7 +12,7 @@ using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.Settings { - public class SettingsHeader : Container + public partial class SettingsHeader : Container { private readonly LocalisableString heading; private readonly LocalisableString subheading; diff --git a/osu.Game/Overlays/Settings/SettingsItem.cs b/osu.Game/Overlays/Settings/SettingsItem.cs index ea076b77ac..5f4bb9d57f 100644 --- a/osu.Game/Overlays/Settings/SettingsItem.cs +++ b/osu.Game/Overlays/Settings/SettingsItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -20,7 +22,7 @@ using osuTK; namespace osu.Game.Overlays.Settings { - public abstract class SettingsItem : Container, IFilterable, ISettingsItem, IHasCurrentValue, IHasTooltip + public abstract partial class SettingsItem : Container, IConditionalFilterable, ISettingsItem, IHasCurrentValue, IHasTooltip { protected abstract Drawable CreateControl(); @@ -142,6 +144,9 @@ namespace osu.Game.Overlays.Settings public bool FilteringActive { get; set; } + public BindableBool CanBeShown { get; } = new BindableBool(true); + IBindable IConditionalFilterable.CanBeShown => CanBeShown; + public event Action SettingChanged; private T classicDefault; diff --git a/osu.Game/Overlays/Settings/SettingsNumberBox.cs b/osu.Game/Overlays/Settings/SettingsNumberBox.cs index d931c53e73..97b8f6de60 100644 --- a/osu.Game/Overlays/Settings/SettingsNumberBox.cs +++ b/osu.Game/Overlays/Settings/SettingsNumberBox.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; @@ -9,14 +11,14 @@ using osu.Framework.Graphics.UserInterface; namespace osu.Game.Overlays.Settings { - public class SettingsNumberBox : SettingsItem + public partial class SettingsNumberBox : SettingsItem { protected override Drawable CreateControl() => new NumberControl { RelativeSizeAxes = Axes.X, }; - private sealed class NumberControl : CompositeDrawable, IHasCurrentValue + private sealed partial class NumberControl : CompositeDrawable, IHasCurrentValue { private readonly BindableWithCurrent current = new BindableWithCurrent(); @@ -66,7 +68,7 @@ namespace osu.Game.Overlays.Settings } } - private class OutlinedNumberBox : OutlinedTextBox + private partial class OutlinedNumberBox : OutlinedTextBox { protected override bool AllowIme => false; diff --git a/osu.Game/Overlays/Settings/SettingsPercentageSlider.cs b/osu.Game/Overlays/Settings/SettingsPercentageSlider.cs new file mode 100644 index 0000000000..fa59d18de1 --- /dev/null +++ b/osu.Game/Overlays/Settings/SettingsPercentageSlider.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics; +using osu.Game.Configuration; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Overlays.Settings +{ + /// + /// A that displays its value as a percentage by default. + /// Mostly provided for convenience of use with . + /// + public partial class SettingsPercentageSlider : SettingsSlider + where TValue : struct, IEquatable, IComparable, IConvertible + { + protected override Drawable CreateControl() => ((RoundedSliderBar)base.CreateControl()).With(sliderBar => sliderBar.DisplayAsPercentage = true); + } +} diff --git a/osu.Game/Overlays/Settings/SettingsSection.cs b/osu.Game/Overlays/Settings/SettingsSection.cs index da596e4d9d..9602e4373f 100644 --- a/osu.Game/Overlays/Settings/SettingsSection.cs +++ b/osu.Game/Overlays/Settings/SettingsSection.cs @@ -1,9 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -17,7 +18,7 @@ using osuTK; namespace osu.Game.Overlays.Settings { - public abstract class SettingsSection : Container, IHasFilterableChildren + public abstract partial class SettingsSection : Container, IFilterable { protected FillFlowContainer FlowContent; protected override Container Content => FlowContent; @@ -31,7 +32,6 @@ namespace osu.Game.Overlays.Settings public abstract Drawable CreateIcon(); public abstract LocalisableString Header { get; } - public IEnumerable FilterableChildren => Children.OfType(); public virtual IEnumerable FilterTerms => new[] { Header }; public const int ITEM_SPACING = 14; diff --git a/osu.Game/Overlays/Settings/SettingsSidebar.cs b/osu.Game/Overlays/Settings/SettingsSidebar.cs index 4e6a1eb914..36411e01cc 100644 --- a/osu.Game/Overlays/Settings/SettingsSidebar.cs +++ b/osu.Game/Overlays/Settings/SettingsSidebar.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; @@ -8,7 +10,7 @@ using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.Settings { - public class SettingsSidebar : ExpandingButtonContainer + public partial class SettingsSidebar : ExpandingButtonContainer { public const float DEFAULT_WIDTH = 70; public const int EXPANDED_WIDTH = 200; diff --git a/osu.Game/Overlays/Settings/SettingsSlider.cs b/osu.Game/Overlays/Settings/SettingsSlider.cs index b95b0af11c..e1483d4202 100644 --- a/osu.Game/Overlays/Settings/SettingsSlider.cs +++ b/osu.Game/Overlays/Settings/SettingsSlider.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; @@ -8,14 +10,14 @@ using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Settings { - public class SettingsSlider : SettingsSlider> + public partial class SettingsSlider : SettingsSlider> where T : struct, IEquatable, IComparable, IConvertible { } - public class SettingsSlider : SettingsItem + public partial class SettingsSlider : SettingsItem where TValue : struct, IEquatable, IComparable, IConvertible - where TSlider : OsuSliderBar, new() + where TSlider : RoundedSliderBar, new() { protected override Drawable CreateControl() => new TSlider { diff --git a/osu.Game/Overlays/Settings/SettingsSubsection.cs b/osu.Game/Overlays/Settings/SettingsSubsection.cs index 21391d5ccf..784f20a6e8 100644 --- a/osu.Game/Overlays/Settings/SettingsSubsection.cs +++ b/osu.Game/Overlays/Settings/SettingsSubsection.cs @@ -1,12 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Sprites; using System.Collections.Generic; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Localisation; using osu.Framework.Testing; @@ -15,7 +16,7 @@ using osu.Game.Graphics; namespace osu.Game.Overlays.Settings { [ExcludeFromDynamicCompile] - public abstract class SettingsSubsection : FillFlowContainer, IHasFilterableChildren + public abstract partial class SettingsSubsection : FillFlowContainer, IFilterable { protected override Container Content => FlowContent; @@ -23,8 +24,6 @@ namespace osu.Game.Overlays.Settings protected abstract LocalisableString Header { get; } - public IEnumerable FilterableChildren => Children.OfType(); - public virtual IEnumerable FilterTerms => new[] { Header }; public bool MatchingFilter diff --git a/osu.Game/Overlays/Settings/SettingsTextBox.cs b/osu.Game/Overlays/Settings/SettingsTextBox.cs index a724003183..3f9fa06384 100644 --- a/osu.Game/Overlays/Settings/SettingsTextBox.cs +++ b/osu.Game/Overlays/Settings/SettingsTextBox.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Bindables; using osu.Framework.Graphics; namespace osu.Game.Overlays.Settings { - public class SettingsTextBox : SettingsItem + public partial class SettingsTextBox : SettingsItem { protected override Drawable CreateControl() => new OutlinedTextBox { diff --git a/osu.Game/Overlays/Settings/SidebarButton.cs b/osu.Game/Overlays/Settings/SidebarButton.cs index 1a34143e1f..aec0509394 100644 --- a/osu.Game/Overlays/Settings/SidebarButton.cs +++ b/osu.Game/Overlays/Settings/SidebarButton.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. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Input.Events; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Settings { - public abstract class SidebarButton : OsuButton + public abstract partial class SidebarButton : OsuButton { protected const double FADE_DURATION = 500; [Resolved] protected OverlayColourProvider ColourProvider { get; private set; } + protected SidebarButton() + : base(HoverSampleSet.ButtonSidebar) + { + } + [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game/Overlays/Settings/SidebarIconButton.cs b/osu.Game/Overlays/Settings/SidebarIconButton.cs index 6f3d3d5d52..4e5b361460 100644 --- a/osu.Game/Overlays/Settings/SidebarIconButton.cs +++ b/osu.Game/Overlays/Settings/SidebarIconButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -13,7 +15,7 @@ using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.Settings { - public class SidebarIconButton : SidebarButton + public partial class SidebarIconButton : SidebarButton { private const float selection_indicator_height_active = 18; private const float selection_indicator_height_inactive = 4; diff --git a/osu.Game/Overlays/SettingsOverlay.cs b/osu.Game/Overlays/SettingsOverlay.cs index 7cd8fc6d66..291281124c 100644 --- a/osu.Game/Overlays/SettingsOverlay.cs +++ b/osu.Game/Overlays/SettingsOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -15,7 +17,7 @@ using osu.Game.Localisation; namespace osu.Game.Overlays { - public class SettingsOverlay : SettingsPanel, INamedOverlayComponent + public partial class SettingsOverlay : SettingsPanel, INamedOverlayComponent { public string IconTexture => "Icons/Hexacons/settings"; public LocalisableString Title => SettingsStrings.HeaderTitle; diff --git a/osu.Game/Overlays/SettingsPanel.cs b/osu.Game/Overlays/SettingsPanel.cs index a5a6f9bce7..aefaccdb5d 100644 --- a/osu.Game/Overlays/SettingsPanel.cs +++ b/osu.Game/Overlays/SettingsPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -21,7 +23,7 @@ using osu.Game.Overlays.Settings; namespace osu.Game.Overlays { [Cached] - public abstract class SettingsPanel : OsuFocusedOverlayContainer + public abstract partial class SettingsPanel : OsuFocusedOverlayContainer { public const float CONTENT_MARGINS = 20; @@ -271,13 +273,13 @@ namespace osu.Game.Overlays } } - private class NonMaskedContent : Container + private partial class NonMaskedContent : Container { // masking breaks the pan-out transform with nested sub-settings panels. protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false; } - public class SettingsSectionsContainer : SectionsContainer + public partial class SettingsSectionsContainer : SectionsContainer { public SearchContainer SearchContainer; diff --git a/osu.Game/Overlays/SettingsSubPanel.cs b/osu.Game/Overlays/SettingsSubPanel.cs index d55c609d3f..5890d1c8fa 100644 --- a/osu.Game/Overlays/SettingsSubPanel.cs +++ b/osu.Game/Overlays/SettingsSubPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -12,7 +14,7 @@ using osuTK; namespace osu.Game.Overlays { - public abstract class SettingsSubPanel : SettingsPanel + public abstract partial class SettingsSubPanel : SettingsPanel { protected SettingsSubPanel() : base(true) @@ -32,7 +34,7 @@ namespace osu.Game.Overlays protected override bool DimMainContent => false; // dimming is handled by main overlay - public class BackButton : SidebarButton + public partial class BackButton : SidebarButton { private Container content; diff --git a/osu.Game/Overlays/SettingsToolboxGroup.cs b/osu.Game/Overlays/SettingsToolboxGroup.cs index 077762c0d0..c0948c1eab 100644 --- a/osu.Game/Overlays/SettingsToolboxGroup.cs +++ b/osu.Game/Overlays/SettingsToolboxGroup.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Caching; using osu.Framework.Extensions.EnumExtensions; @@ -19,27 +20,40 @@ using osuTK.Graphics; namespace osu.Game.Overlays { - public class SettingsToolboxGroup : Container, IExpandable + public partial class SettingsToolboxGroup : Container, IExpandable { + private readonly string title; public const int CONTAINER_WIDTH = 270; private const float transition_duration = 250; - private const int border_thickness = 2; private const int header_height = 30; private const int corner_radius = 5; - private const float fade_duration = 800; - private const float inactive_alpha = 0.5f; - private readonly Cached headerTextVisibilityCache = new Cached(); - private readonly FillFlowContainer content; + protected override Container Content => content; + + private readonly FillFlowContainer content = new FillFlowContainer + { + Name = @"Content", + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Direction = FillDirection.Vertical, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = 10, Top = 5, Bottom = 10 }, + Spacing = new Vector2(0, 15), + }; public BindableBool Expanded { get; } = new BindableBool(true); - private readonly OsuSpriteText headerText; + private OsuSpriteText headerText = null!; - private readonly Container headerContent; + private Container headerContent = null!; + + private Box background = null!; + + private IconButton expandButton = null!; /// /// Create a new instance. @@ -47,20 +61,25 @@ namespace osu.Game.Overlays /// The title to be displayed in the header of this group. public SettingsToolboxGroup(string title) { + this.title = title; + AutoSizeAxes = Axes.Y; Width = CONTAINER_WIDTH; Masking = true; + } + + [BackgroundDependencyLoader(true)] + private void load(OverlayColourProvider? colourProvider) + { CornerRadius = corner_radius; - BorderColour = Color4.Black; - BorderThickness = border_thickness; InternalChildren = new Drawable[] { - new Box + background = new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - Alpha = 0.5f, + Alpha = 0.1f, + Colour = colourProvider?.Background4 ?? Color4.Black, }, new FillFlowContainer { @@ -86,7 +105,7 @@ namespace osu.Game.Overlays Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 17), Padding = new MarginPadding { Left = 10, Right = 30 }, }, - new IconButton + expandButton = new IconButton { Origin = Anchor.Centre, Anchor = Anchor.CentreRight, @@ -97,19 +116,7 @@ namespace osu.Game.Overlays }, } }, - content = new FillFlowContainer - { - Name = @"Content", - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Direction = FillDirection.Vertical, - RelativeSizeAxes = Axes.X, - AutoSizeDuration = transition_duration, - AutoSizeEasing = Easing.OutQuint, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding(15), - Spacing = new Vector2(0, 15), - } + content } }, }; @@ -119,7 +126,8 @@ namespace osu.Game.Overlays { base.LoadComplete(); - Expanded.BindValueChanged(updateExpandedState, true); + Expanded.BindValueChanged(_ => updateExpandedState(true)); + updateExpandedState(false); this.Delay(600).Schedule(updateFadeState); } @@ -127,12 +135,14 @@ namespace osu.Game.Overlays protected override bool OnHover(HoverEvent e) { updateFadeState(); + updateExpandedState(true); return false; } protected override void OnHoverLost(HoverLostEvent e) { updateFadeState(); + updateExpandedState(true); base.OnHoverLost(e); } @@ -154,28 +164,33 @@ namespace osu.Game.Overlays return base.OnInvalidate(invalidation, source); } - private void updateExpandedState(ValueChangedEvent expanded) + private void updateExpandedState(bool animate) { // clearing transforms is necessary to avoid a previous height transform // potentially continuing to get processed while content has changed to autosize. content.ClearTransforms(); - if (expanded.NewValue) + if (Expanded.Value || IsHovered) + { content.AutoSizeAxes = Axes.Y; + content.AutoSizeDuration = animate ? transition_duration : 0; + content.AutoSizeEasing = Easing.OutQuint; + } else { content.AutoSizeAxes = Axes.None; - content.ResizeHeightTo(0, transition_duration, Easing.OutQuint); + content.ResizeHeightTo(0, animate ? transition_duration : 0, Easing.OutQuint); } - headerContent.FadeColour(expanded.NewValue ? Color4.White : OsuColour.Gray(0.5f), 200, Easing.OutQuint); + headerContent.FadeColour(Expanded.Value ? Color4.White : OsuColour.Gray(0.5f), 200, Easing.OutQuint); } private void updateFadeState() { - this.FadeTo(IsHovered ? 1 : inactive_alpha, fade_duration, Easing.OutQuint); - } + const float fade_duration = 500; - protected override Container Content => content; + background.FadeTo(IsHovered ? 1 : 0.1f, fade_duration, Easing.OutQuint); + expandButton.FadeTo(IsHovered ? 1 : 0, fade_duration, Easing.OutQuint); + } } } diff --git a/osu.Game/Overlays/SkinEditor/NonSkinnableScreenPlaceholder.cs b/osu.Game/Overlays/SkinEditor/NonSkinnableScreenPlaceholder.cs new file mode 100644 index 0000000000..daaa92035e --- /dev/null +++ b/osu.Game/Overlays/SkinEditor/NonSkinnableScreenPlaceholder.cs @@ -0,0 +1,75 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterfaceV2; +using osuTK; + +namespace osu.Game.Overlays.SkinEditor +{ + public partial class NonSkinnableScreenPlaceholder : CompositeDrawable + { + [Resolved] + private SkinEditorOverlay? skinEditorOverlay { get; set; } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = colourProvider.Dark6, + RelativeSizeAxes = Axes.Both, + Alpha = 0.95f, + }, + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0, 5), + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new SpriteIcon + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Icon = FontAwesome.Solid.ExclamationCircle, + Size = new Vector2(24), + Y = -5, + }, + new OsuTextFlowContainer(t => t.Font = OsuFont.Default.With(weight: FontWeight.SemiBold, size: 18)) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + TextAnchor = Anchor.Centre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Text = "Please navigate to a skinnable screen using the scene library", + }, + new RoundedButton + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Width = 200, + Margin = new MarginPadding { Top = 20 }, + Action = () => skinEditorOverlay?.Hide(), + Text = "Return to game" + } + } + }, + }; + } + } +} diff --git a/osu.Game/Skinning/Editor/SkinBlueprint.cs b/osu.Game/Overlays/SkinEditor/SkinBlueprint.cs similarity index 51% rename from osu.Game/Skinning/Editor/SkinBlueprint.cs rename to osu.Game/Overlays/SkinEditor/SkinBlueprint.cs index 1860c6006c..c090878899 100644 --- a/osu.Game/Skinning/Editor/SkinBlueprint.cs +++ b/osu.Game/Overlays/SkinEditor/SkinBlueprint.cs @@ -5,39 +5,53 @@ using System; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Edit; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Skinning; using osuTK; using osuTK.Graphics; -namespace osu.Game.Skinning.Editor +namespace osu.Game.Overlays.SkinEditor { - public class SkinBlueprint : SelectionBlueprint + public partial class SkinBlueprint : SelectionBlueprint { - private Container box; + private Container box = null!; - private Container outlineBox; + private AnchorOriginVisualiser anchorOriginVisualiser = null!; - private AnchorOriginVisualiser anchorOriginVisualiser; + private OsuSpriteText label = null!; private Drawable drawable => (Drawable)Item; protected override bool ShouldBeAlive => drawable.IsAlive && Item.IsPresent; - [Resolved] - private OsuColour colours { get; set; } + private Quad drawableQuad; - public SkinBlueprint(ISkinnableDrawable component) + public override Quad ScreenSpaceDrawQuad => drawableQuad; + public override Quad SelectionQuad => drawable.ScreenSpaceDrawQuad; + + public override bool Contains(Vector2 screenSpacePos) => drawableQuad.Contains(screenSpacePos); + + public override Vector2 ScreenSpaceSelectionPoint => drawable.ToScreenSpace(drawable.OriginPosition); + + protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => + drawableQuad.Contains(screenSpacePos); + + public SkinBlueprint(ISerialisableDrawable component) : base(component) { } [BackgroundDependencyLoader] - private void load() + private void load(OsuColour colours) { InternalChildren = new Drawable[] { @@ -45,26 +59,30 @@ namespace osu.Game.Skinning.Editor { Children = new Drawable[] { - outlineBox = new Container + new Container { RelativeSizeAxes = Axes.Both, Masking = true, - BorderThickness = 3, - BorderColour = Color4.White, + CornerRadius = 3, + BorderThickness = SelectionBox.BORDER_RADIUS / 2, + BorderColour = ColourInfo.GradientVertical(colours.Pink4.Darken(0.4f), colours.Pink4), Children = new Drawable[] { new Box { RelativeSizeAxes = Axes.Both, - Alpha = 0f, + Blending = BlendingParameters.Additive, + Alpha = 0.2f, + Colour = ColourInfo.GradientVertical(colours.Pink2, colours.Pink4), AlwaysPresent = true, }, } }, - new OsuSpriteText + label = new OsuSpriteText { Text = Item.GetType().Name, Font = OsuFont.Default.With(size: 10, weight: FontWeight.Bold), + Alpha = 0, Anchor = Anchor.BottomRight, Origin = Anchor.TopRight, }, @@ -82,7 +100,18 @@ namespace osu.Game.Skinning.Editor base.LoadComplete(); updateSelectedState(); - this.FadeInFromZero(200, Easing.OutQuint); + } + + protected override bool OnHover(HoverEvent e) + { + updateSelectedState(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateSelectedState(); + base.OnHoverLost(e); } protected override void OnSelected() @@ -99,73 +128,73 @@ namespace osu.Game.Skinning.Editor private void updateSelectedState() { - outlineBox.FadeColour(colours.Pink.Opacity(IsSelected ? 1 : 0.5f), 200, Easing.OutQuint); - outlineBox.Child.FadeTo(IsSelected ? 0.2f : 0, 200, Easing.OutQuint); - anchorOriginVisualiser.FadeTo(IsSelected ? 1 : 0, 200, Easing.OutQuint); + label.FadeTo(IsSelected || IsHovered ? 1 : 0, 200, Easing.OutQuint); } - private Quad drawableQuad; - - public override Quad ScreenSpaceDrawQuad => drawableQuad; - protected override void Update() { base.Update(); - drawableQuad = drawable.ScreenSpaceDrawQuad; - var quad = ToLocalSpace(drawable.ScreenSpaceDrawQuad); + drawableQuad = drawable.ToScreenSpace( + drawable.DrawRectangle + .Inflate(SkinSelectionHandler.INFLATE_SIZE)); - box.Position = drawable.ToSpaceOfOtherDrawable(Vector2.Zero, this); - box.Size = quad.Size; + var localSpaceQuad = ToLocalSpace(drawableQuad); + + box.Position = localSpaceQuad.TopLeft; + box.Size = localSpaceQuad.Size; box.Rotation = drawable.Rotation; box.Scale = new Vector2(MathF.Sign(drawable.Scale.X), MathF.Sign(drawable.Scale.Y)); } - - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => drawable.ReceivePositionalInputAt(screenSpacePos); - - public override Vector2 ScreenSpaceSelectionPoint => drawable.ToScreenSpace(drawable.OriginPosition); - - public override Quad SelectionQuad => drawable.ScreenSpaceDrawQuad; } - internal class AnchorOriginVisualiser : CompositeDrawable + internal partial class AnchorOriginVisualiser : CompositeDrawable { private readonly Drawable drawable; - private readonly Box originBox; + private Drawable originBox = null!; - private readonly Box anchorBox; - private readonly Box anchorLine; + private Drawable anchorBox = null!; + private Drawable anchorLine = null!; public AnchorOriginVisualiser(Drawable drawable) { this.drawable = drawable; + } - InternalChildren = new Drawable[] + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Color4 anchorColour = colours.Red1; + Color4 originColour = colours.Red3; + + InternalChildren = new[] { - anchorLine = new Box + anchorLine = new Circle { - Height = 2, + Height = 3f, Origin = Anchor.CentreLeft, - Colour = Color4.Yellow, - EdgeSmoothness = Vector2.One + Colour = ColourInfo.GradientHorizontal(originColour.Opacity(0.5f), originColour), }, - originBox = new Box + originBox = new Circle { - Colour = Color4.Red, + Colour = originColour, Origin = Anchor.Centre, - Size = new Vector2(5), + Size = new Vector2(7), }, - anchorBox = new Box + anchorBox = new Circle { - Colour = Color4.Red, + Colour = anchorColour, Origin = Anchor.Centre, - Size = new Vector2(5), + Size = new Vector2(10), }, }; } + private Vector2? anchorPosition; + private Vector2? originPositionInDrawableSpace; + protected override void Update() { base.Update(); @@ -173,8 +202,13 @@ namespace osu.Game.Skinning.Editor if (drawable.Parent == null) return; - originBox.Position = drawable.ToSpaceOfOtherDrawable(drawable.OriginPosition, this); - anchorBox.Position = drawable.Parent.ToSpaceOfOtherDrawable(drawable.AnchorPosition, this); + var newAnchor = drawable.Parent.ToSpaceOfOtherDrawable(drawable.AnchorPosition, this); + anchorPosition = tweenPosition(anchorPosition ?? newAnchor, newAnchor); + anchorBox.Position = anchorPosition.Value; + + // for the origin, tween in the drawable's local space to avoid unwanted tweening when the drawable is being dragged. + originPositionInDrawableSpace = originPositionInDrawableSpace != null ? tweenPosition(originPositionInDrawableSpace.Value, drawable.OriginPosition) : drawable.OriginPosition; + originBox.Position = drawable.ToSpaceOfOtherDrawable(originPositionInDrawableSpace.Value, this); var point1 = ToLocalSpace(anchorBox.ScreenSpaceDrawQuad.Centre); var point2 = ToLocalSpace(originBox.ScreenSpaceDrawQuad.Centre); @@ -183,5 +217,11 @@ namespace osu.Game.Skinning.Editor anchorLine.Width = (point2 - point1).Length; anchorLine.Rotation = MathHelper.RadiansToDegrees(MathF.Atan2(point2.Y - point1.Y, point2.X - point1.X)); } + + private Vector2 tweenPosition(Vector2 oldPosition, Vector2 newPosition) + => new Vector2( + (float)Interpolation.DampContinuously(oldPosition.X, newPosition.X, 25, Clock.ElapsedFrameTime), + (float)Interpolation.DampContinuously(oldPosition.Y, newPosition.Y, 25, Clock.ElapsedFrameTime) + ); } } diff --git a/osu.Game/Overlays/SkinEditor/SkinBlueprintContainer.cs b/osu.Game/Overlays/SkinEditor/SkinBlueprintContainer.cs new file mode 100644 index 0000000000..db27e20010 --- /dev/null +++ b/osu.Game/Overlays/SkinEditor/SkinBlueprintContainer.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.Collections.Generic; +using System.Collections.Specialized; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Edit; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Skinning; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Overlays.SkinEditor +{ + public partial class SkinBlueprintContainer : BlueprintContainer + { + private readonly ISerialisableDrawableContainer targetContainer; + + private readonly List> targetComponents = new List>(); + + [Resolved] + private SkinEditor editor { get; set; } = null!; + + protected override bool AllowCyclicSelection => true; + + public SkinBlueprintContainer(ISerialisableDrawableContainer targetContainer) + { + this.targetContainer = targetContainer; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + SelectedItems.BindTo(editor.SelectedComponents); + + var bindableList = new BindableList { BindTarget = targetContainer.Components }; + bindableList.BindCollectionChanged(componentsChanged, true); + + targetComponents.Add(bindableList); + } + + private void componentsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Schedule(() => + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + Debug.Assert(e.NewItems != null); + + foreach (var item in e.NewItems.Cast()) + AddBlueprintFor(item); + break; + + case NotifyCollectionChangedAction.Remove: + case NotifyCollectionChangedAction.Reset: + Debug.Assert(e.OldItems != null); + + foreach (var item in e.OldItems.Cast()) + RemoveBlueprintFor(item); + break; + + case NotifyCollectionChangedAction.Replace: + Debug.Assert(e.NewItems != null); + Debug.Assert(e.OldItems != null); + + foreach (var item in e.OldItems.Cast()) + RemoveBlueprintFor(item); + + foreach (var item in e.NewItems.Cast()) + AddBlueprintFor(item); + break; + } + }); + + protected override void AddBlueprintFor(ISerialisableDrawable item) + { + if (!item.IsEditable) + return; + + base.AddBlueprintFor(item); + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + switch (e.Key) + { + case Key.Left: + moveSelection(new Vector2(-1, 0)); + return true; + + case Key.Right: + moveSelection(new Vector2(1, 0)); + return true; + + case Key.Up: + moveSelection(new Vector2(0, -1)); + return true; + + case Key.Down: + moveSelection(new Vector2(0, 1)); + return true; + } + + return false; + } + + protected override void SelectAll() + { + SelectedItems.AddRange(targetComponents.SelectMany(list => list).Except(SelectedItems).ToArray()); + } + + /// + /// Move the current selection spatially by the specified delta, in screen coordinates (ie. the same coordinates as the blueprints). + /// + /// + private void moveSelection(Vector2 delta) + { + var firstBlueprint = SelectionHandler.SelectedBlueprints.FirstOrDefault(); + + if (firstBlueprint == null) + return; + + // convert to game space coordinates + delta = firstBlueprint.ToScreenSpace(delta) - firstBlueprint.ToScreenSpace(Vector2.Zero); + + SelectionHandler.HandleMovement(new MoveSelectionEvent(firstBlueprint, delta)); + } + + protected override SelectionHandler CreateSelectionHandler() => new SkinSelectionHandler(); + + protected override SelectionBlueprint CreateBlueprintFor(ISerialisableDrawable component) + => new SkinBlueprint(component); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + foreach (var list in targetComponents) + list.UnbindAll(); + } + } +} diff --git a/osu.Game/Skinning/Editor/SkinComponentToolbox.cs b/osu.Game/Overlays/SkinEditor/SkinComponentToolbox.cs similarity index 62% rename from osu.Game/Skinning/Editor/SkinComponentToolbox.cs rename to osu.Game/Overlays/SkinEditor/SkinComponentToolbox.cs index 756f229927..1ce253d67c 100644 --- a/osu.Game/Skinning/Editor/SkinComponentToolbox.cs +++ b/osu.Game/Overlays/SkinEditor/SkinComponentToolbox.cs @@ -2,36 +2,37 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Framework.Logging; -using osu.Game.Graphics; +using osu.Framework.Threading; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; -using osu.Game.Overlays; +using osu.Game.Localisation; using osu.Game.Screens.Edit.Components; +using osu.Game.Skinning; using osuTK; -namespace osu.Game.Skinning.Editor +namespace osu.Game.Overlays.SkinEditor { - public class SkinComponentToolbox : EditorSidebarSection + public partial class SkinComponentToolbox : EditorSidebarSection { - public Action RequestPlacement; + public Action? RequestPlacement; - private readonly CompositeDrawable target; + private readonly SkinComponentsContainer? target; - public SkinComponentToolbox(CompositeDrawable target = null) - : base("Components") + private FillFlowContainer fill = null!; + + public SkinComponentToolbox(SkinComponentsContainer? target = null) + : base(target?.Lookup.Ruleset == null ? SkinEditorStrings.Components : LocalisableString.Interpolate($"{SkinEditorStrings.Components} ({target.Lookup.Ruleset.Name})")) { this.target = target; } - private FillFlowContainer fill; - [BackgroundDependencyLoader] private void load() { @@ -40,7 +41,7 @@ namespace osu.Game.Skinning.Editor RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, - Spacing = new Vector2(2) + Spacing = new Vector2(EditorSidebar.PADDING) }; reloadComponents(); @@ -50,12 +51,7 @@ namespace osu.Game.Skinning.Editor { fill.Clear(); - var skinnableTypes = typeof(OsuGame).Assembly.GetTypes() - .Where(t => !t.IsInterface && !t.IsAbstract) - .Where(t => typeof(ISkinnableDrawable).IsAssignableFrom(t)) - .OrderBy(t => t.Name) - .ToArray(); - + var skinnableTypes = SerialisedDrawableInfo.GetAllAvailableDrawables(target?.Lookup.Ruleset); foreach (var type in skinnableTypes) attemptAddComponent(type); } @@ -64,15 +60,14 @@ namespace osu.Game.Skinning.Editor { try { - var instance = (Drawable)Activator.CreateInstance(type); + Drawable instance = (Drawable)Activator.CreateInstance(type)!; - Debug.Assert(instance != null); - - if (!((ISkinnableDrawable)instance).IsEditable) return; + if (!((ISerialisableDrawable)instance).IsEditable) return; fill.Add(new ToolboxComponentButton(instance, target) { - RequestPlacement = t => RequestPlacement?.Invoke(t) + RequestPlacement = t => RequestPlacement?.Invoke(t), + Expanding = contractOtherButtons, }); } catch (DependencyNotRegisteredException) @@ -86,23 +81,33 @@ namespace osu.Game.Skinning.Editor } } - private class ToolboxComponentButton : OsuButton + private void contractOtherButtons(ToolboxComponentButton obj) { - protected override bool ShouldBeConsideredForInput(Drawable child) => false; + foreach (var b in fill.OfType()) + { + if (b == obj) + continue; - public override bool PropagateNonPositionalInputSubTree => false; + b.Contract(); + } + } + + public partial class ToolboxComponentButton : OsuButton + { + public Action? RequestPlacement; + public Action? Expanding; private readonly Drawable component; - private readonly CompositeDrawable dependencySource; + private readonly CompositeDrawable? dependencySource; - public Action RequestPlacement; + private Container innerContainer = null!; - private Container innerContainer; + private ScheduledDelegate? expandContractAction; private const float contracted_size = 60; private const float expanded_size = 120; - public ToolboxComponentButton(Drawable component, CompositeDrawable dependencySource) + public ToolboxComponentButton(Drawable component, CompositeDrawable? dependencySource) { this.component = component; this.dependencySource = dependencySource; @@ -113,20 +118,45 @@ namespace osu.Game.Skinning.Editor Height = contracted_size; } + private const double animation_duration = 500; + protected override bool OnHover(HoverEvent e) { - this.Delay(300).ResizeHeightTo(expanded_size, 500, Easing.OutQuint); + expandContractAction?.Cancel(); + expandContractAction = Scheduler.AddDelayed(() => + { + this.ResizeHeightTo(expanded_size, animation_duration, Easing.OutQuint); + Expanding?.Invoke(this); + }, 100); + return base.OnHover(e); } protected override void OnHoverLost(HoverLostEvent e) { base.OnHoverLost(e); - this.ResizeHeightTo(contracted_size, 500, Easing.OutQuint); + + expandContractAction?.Cancel(); + // If no other component is selected for too long, force a contract. + // Otherwise we will generally contract when Contract() is called from outside. + expandContractAction = Scheduler.AddDelayed(Contract, 1000); + } + + public void Contract() + { + // Cheap debouncing to avoid stacking animations. + // The only place this is nulled is at the end of this method. + if (expandContractAction == null) + return; + + this.ResizeHeightTo(contracted_size, animation_duration, Easing.OutQuint); + + expandContractAction?.Cancel(); + expandContractAction = null; } [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider, OsuColour colours) + private void load(OverlayColourProvider colourProvider) { BackgroundColour = colourProvider.Background3; @@ -159,9 +189,9 @@ namespace osu.Game.Skinning.Editor component.Origin = Anchor.Centre; } - protected override void Update() + protected override void UpdateAfterChildren() { - base.Update(); + base.UpdateAfterChildren(); if (component.DrawSize != Vector2.Zero) { @@ -180,11 +210,15 @@ namespace osu.Game.Skinning.Editor } } - public class DependencyBorrowingContainer : Container + public partial class DependencyBorrowingContainer : Container { - private readonly CompositeDrawable donor; + protected override bool ShouldBeConsideredForInput(Drawable child) => false; - public DependencyBorrowingContainer(CompositeDrawable donor) + public override bool PropagateNonPositionalInputSubTree => false; + + private readonly CompositeDrawable? donor; + + public DependencyBorrowingContainer(CompositeDrawable? donor) { this.donor = donor; } diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs new file mode 100644 index 0000000000..2b23ce290f --- /dev/null +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -0,0 +1,710 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using Web = osu.Game.Resources.Localisation.Web; +using osu.Framework.Testing; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; +using osu.Game.Overlays.Dialog; +using osu.Game.Overlays.OSD; +using osu.Game.Overlays.Settings; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Components; +using osu.Game.Screens.Edit.Components.Menus; +using osu.Game.Skinning; + +namespace osu.Game.Overlays.SkinEditor +{ + [Cached(typeof(SkinEditor))] + public partial class SkinEditor : VisibilityContainer, ICanAcceptFiles, IKeyBindingHandler, IEditorChangeHandler + { + public const double TRANSITION_DURATION = 300; + + public const float MENU_HEIGHT = 40; + + public readonly BindableList SelectedComponents = new BindableList(); + + protected override bool StartHidden => true; + + private Drawable targetScreen = null!; + + private OsuTextFlowContainer headerText = null!; + + private Bindable currentSkin = null!; + + [Resolved] + private OsuGame? game { get; set; } + + [Resolved] + private SkinManager skins { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + [Resolved] + private EditorClipboard clipboard { get; set; } = null!; + + [Resolved] + private SkinEditorOverlay? skinEditorOverlay { get; set; } + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + private readonly Bindable selectedTarget = new Bindable(); + + private bool hasBegunMutating; + + private Container? content; + + private EditorSidebar componentsSidebar = null!; + private EditorSidebar settingsSidebar = null!; + + private SkinEditorChangeHandler? changeHandler; + + private EditorMenuItem undoMenuItem = null!; + private EditorMenuItem redoMenuItem = null!; + + private EditorMenuItem cutMenuItem = null!; + private EditorMenuItem copyMenuItem = null!; + private EditorMenuItem cloneMenuItem = null!; + private EditorMenuItem pasteMenuItem = null!; + + private readonly BindableWithCurrent canCut = new BindableWithCurrent(); + private readonly BindableWithCurrent canCopy = new BindableWithCurrent(); + private readonly BindableWithCurrent canPaste = new BindableWithCurrent(); + + [Resolved] + private OnScreenDisplay? onScreenDisplay { get; set; } + + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + + public SkinEditor() + { + } + + public SkinEditor(Drawable targetScreen) + { + UpdateTargetScreen(targetScreen); + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + + InternalChild = new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + }, + + Content = new[] + { + new Drawable[] + { + new Container + { + Name = @"Menu container", + RelativeSizeAxes = Axes.X, + Depth = float.MinValue, + Height = MENU_HEIGHT, + Children = new Drawable[] + { + new EditorMenuBar + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Both, + Items = new[] + { + new MenuItem(CommonStrings.MenuBarFile) + { + Items = new[] + { + new EditorMenuItem(Web.CommonStrings.ButtonsSave, MenuItemType.Standard, () => Save()), + new EditorMenuItem(CommonStrings.RevertToDefault, MenuItemType.Destructive, () => dialogOverlay?.Push(new RevertConfirmDialog(revert))), + new EditorMenuItemSpacer(), + new EditorMenuItem(CommonStrings.Exit, MenuItemType.Standard, () => skinEditorOverlay?.Hide()), + }, + }, + new MenuItem(CommonStrings.MenuBarEdit) + { + Items = new[] + { + undoMenuItem = new EditorMenuItem(CommonStrings.Undo, MenuItemType.Standard, Undo), + redoMenuItem = new EditorMenuItem(CommonStrings.Redo, MenuItemType.Standard, Redo), + new EditorMenuItemSpacer(), + cutMenuItem = new EditorMenuItem(CommonStrings.Cut, MenuItemType.Standard, Cut), + copyMenuItem = new EditorMenuItem(CommonStrings.Copy, MenuItemType.Standard, Copy), + pasteMenuItem = new EditorMenuItem(CommonStrings.Paste, MenuItemType.Standard, Paste), + cloneMenuItem = new EditorMenuItem(CommonStrings.Clone, MenuItemType.Standard, Clone), + } + }, + } + }, + headerText = new OsuTextFlowContainer + { + TextAnchor = Anchor.TopRight, + Padding = new MarginPadding(5), + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + }, + }, + }, + }, + new Drawable[] + { + new SkinEditorSceneLibrary + { + RelativeSizeAxes = Axes.X, + }, + }, + new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + componentsSidebar = new EditorSidebar(), + content = new Container + { + Depth = float.MaxValue, + RelativeSizeAxes = Axes.Both, + }, + settingsSidebar = new EditorSidebar(), + } + } + } + }, + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + canCut.Current.BindValueChanged(cut => cutMenuItem.Action.Disabled = !cut.NewValue, true); + canCopy.Current.BindValueChanged(copy => + { + copyMenuItem.Action.Disabled = !copy.NewValue; + cloneMenuItem.Action.Disabled = !copy.NewValue; + }, true); + canPaste.Current.BindValueChanged(paste => pasteMenuItem.Action.Disabled = !paste.NewValue, true); + + SelectedComponents.BindCollectionChanged((_, _) => + { + canCopy.Value = canCut.Value = SelectedComponents.Any(); + }, true); + + clipboard.Content.BindValueChanged(content => canPaste.Value = !string.IsNullOrEmpty(content.NewValue), true); + + Show(); + + game?.RegisterImportHandler(this); + + // as long as the skin editor is loaded, let's make sure we can modify the current skin. + currentSkin = skins.CurrentSkin.GetBoundCopy(); + + // schedule ensures this only happens when the skin editor is visible. + // also avoid some weird endless recursion / bindable feedback loop (something to do with tracking skins across three different bindable types). + // probably something which will be factored out in a future database refactor so not too concerning for now. + currentSkin.BindValueChanged(_ => + { + hasBegunMutating = false; + Scheduler.AddOnce(skinChanged); + }, true); + + SelectedComponents.BindCollectionChanged((_, _) => Scheduler.AddOnce(populateSettings), true); + + selectedTarget.BindValueChanged(targetChanged, true); + } + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case PlatformAction.Cut: + Cut(); + return true; + + case PlatformAction.Copy: + Copy(); + return true; + + case PlatformAction.Paste: + Paste(); + return true; + + case PlatformAction.Undo: + Undo(); + return true; + + case PlatformAction.Redo: + Redo(); + return true; + + case PlatformAction.Save: + if (e.Repeat) + return false; + + Save(); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + + public void UpdateTargetScreen(Drawable targetScreen) + { + this.targetScreen = targetScreen; + + changeHandler?.Dispose(); + + // Immediately clear the previous blueprint container to ensure it doesn't try to interact with the old target. + if (content?.Child is SkinBlueprintContainer) + content.Clear(); + + Scheduler.AddOnce(loadBlueprintContainer); + Scheduler.AddOnce(populateSettings); + + void loadBlueprintContainer() + { + selectedTarget.Default = getFirstTarget()?.Lookup; + + if (!availableTargets.Any(t => t.Lookup.Equals(selectedTarget.Value))) + selectedTarget.SetDefault(); + } + } + + private void targetChanged(ValueChangedEvent target) + { + foreach (var toolbox in componentsSidebar.OfType()) + toolbox.Expire(); + + componentsSidebar.Clear(); + SelectedComponents.Clear(); + + Debug.Assert(content != null); + + var skinComponentsContainer = getTarget(target.NewValue); + + if (target.NewValue == null || skinComponentsContainer == null) + { + content.Child = new NonSkinnableScreenPlaceholder(); + return; + } + + changeHandler = new SkinEditorChangeHandler(skinComponentsContainer); + changeHandler.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true); + changeHandler.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true); + + content.Child = new SkinBlueprintContainer(skinComponentsContainer); + + componentsSidebar.Children = new[] + { + new EditorSidebarSection("Current working layer") + { + Children = new Drawable[] + { + new SettingsDropdown + { + Items = availableTargets.Select(t => t.Lookup), + Current = selectedTarget, + } + } + }, + }; + + // If the new target has a ruleset, let's show ruleset-specific items at the top, and the rest below. + if (target.NewValue.Ruleset != null) + { + componentsSidebar.Add(new SkinComponentToolbox(skinComponentsContainer) + { + RequestPlacement = requestPlacement + }); + } + + // Remove the ruleset from the lookup to get base components. + componentsSidebar.Add(new SkinComponentToolbox(getTarget(new SkinComponentsContainerLookup(target.NewValue.Target))) + { + RequestPlacement = requestPlacement + }); + + void requestPlacement(Type type) + { + if (!(Activator.CreateInstance(type) is ISerialisableDrawable component)) + throw new InvalidOperationException($"Attempted to instantiate a component for placement which was not an {typeof(ISerialisableDrawable)}."); + + SelectedComponents.Clear(); + placeComponent(component); + } + } + + private void skinChanged() + { + headerText.Clear(); + + headerText.AddParagraph(SkinEditorStrings.SkinEditor, cp => cp.Font = OsuFont.Default.With(size: 16)); + headerText.NewParagraph(); + headerText.AddText(SkinEditorStrings.CurrentlyEditing, cp => + { + cp.Font = OsuFont.Default.With(size: 12); + cp.Colour = colours.Yellow; + }); + + headerText.AddText($" {currentSkin.Value.SkinInfo}", cp => + { + cp.Font = OsuFont.Default.With(size: 12, weight: FontWeight.Bold); + cp.Colour = colours.Yellow; + }); + + skins.EnsureMutableSkin(); + hasBegunMutating = true; + } + + /// + /// Attempt to place a given component in the current target. If successful, the new component will be added to . + /// + /// The component to be placed. + /// Whether to apply default anchor / origin / position values. + /// Whether placement succeeded. Could fail if no target is available, or if the current target has missing dependency requirements for the component. + private bool placeComponent(ISerialisableDrawable component, bool applyDefaults = true) + { + var targetContainer = getTarget(selectedTarget.Value); + + if (targetContainer == null) + return false; + + var drawableComponent = (Drawable)component; + + if (applyDefaults) + { + // give newly added components a sane starting location. + drawableComponent.Origin = Anchor.TopCentre; + drawableComponent.Anchor = Anchor.TopCentre; + drawableComponent.Y = targetContainer.DrawSize.Y / 2; + } + + try + { + targetContainer.Add(component); + } + catch + { + // May fail if dependencies are not available, for instance. + return false; + } + + SelectedComponents.Add(component); + return true; + } + + private void populateSettings() + { + settingsSidebar.Clear(); + + foreach (var component in SelectedComponents.OfType()) + settingsSidebar.Add(new SkinSettingsToolbox(component)); + } + + private IEnumerable availableTargets => targetScreen.ChildrenOfType(); + + private SkinComponentsContainer? getFirstTarget() => availableTargets.FirstOrDefault(); + + private SkinComponentsContainer? getTarget(SkinComponentsContainerLookup? target) + { + return availableTargets.FirstOrDefault(c => c.Lookup.Equals(target)); + } + + private void revert() + { + SkinComponentsContainer[] targetContainers = availableTargets.ToArray(); + + foreach (var t in targetContainers) + { + currentSkin.Value.ResetDrawableTarget(t); + + // add back default components + getTarget(t.Lookup)?.Reload(); + } + } + + protected void Cut() + { + Copy(); + DeleteItems(SelectedComponents.ToArray()); + } + + protected void Copy() + { + clipboard.Content.Value = JsonConvert.SerializeObject(SelectedComponents.Cast().Select(s => s.CreateSerialisedInfo()).ToArray()); + } + + protected void Clone() + { + // Avoid attempting to clone if copying is not available (as it may result in pasting something unexpected). + if (!canCopy.Value) + return; + + Copy(); + Paste(); + } + + protected void Paste() + { + changeHandler?.BeginChange(); + + var drawableInfo = JsonConvert.DeserializeObject(clipboard.Content.Value); + + if (drawableInfo == null) + return; + + var instances = drawableInfo.Select(d => d.CreateInstance()) + .OfType() + .ToArray(); + + SelectedComponents.Clear(); + + foreach (var i in instances) + placeComponent(i, false); + + changeHandler?.EndChange(); + } + + protected void Undo() => changeHandler?.RestoreState(-1); + + protected void Redo() => changeHandler?.RestoreState(1); + + public void Save(bool userTriggered = true) + { + if (!hasBegunMutating) + return; + + SkinComponentsContainer[] targetContainers = availableTargets.ToArray(); + + foreach (var t in targetContainers) + currentSkin.Value.UpdateDrawableTarget(t); + + // In the case the save was user triggered, always show the save message to make them feel confident. + if (skins.Save(skins.CurrentSkin.Value) || userTriggered) + onScreenDisplay?.Display(new SkinEditorToast(ToastStrings.SkinSaved, currentSkin.Value.SkinInfo.ToString() ?? "Unknown")); + } + + protected override bool OnHover(HoverEvent e) => true; + + protected override bool OnMouseDown(MouseDownEvent e) => true; + + public override void Hide() + { + base.Hide(); + SelectedComponents.Clear(); + } + + protected override void PopIn() + { + this.FadeIn(TRANSITION_DURATION, Easing.OutQuint); + } + + protected override void PopOut() + { + this.FadeOut(TRANSITION_DURATION, Easing.OutQuint); + } + + public void DeleteItems(ISerialisableDrawable[] items) + { + changeHandler?.BeginChange(); + + foreach (var item in items) + availableTargets.FirstOrDefault(t => t.Components.Contains(item))?.Remove(item, true); + + changeHandler?.EndChange(); + } + + public void BringSelectionToFront() + { + if (getTarget(selectedTarget.Value) is not SkinComponentsContainer target) + return; + + changeHandler?.BeginChange(); + + // Iterating by target components order ensures we maintain the same order across selected components, regardless + // of the order they were selected in. + foreach (var d in target.Components.ToArray()) + { + if (!SelectedComponents.Contains(d)) + continue; + + target.Remove(d, false); + + // Selection would be reset by the remove. + SelectedComponents.Add(d); + target.Add(d); + } + + changeHandler?.EndChange(); + } + + public void SendSelectionToBack() + { + if (getTarget(selectedTarget.Value) is not SkinComponentsContainer target) + return; + + changeHandler?.BeginChange(); + + foreach (var d in target.Components.ToArray()) + { + if (SelectedComponents.Contains(d)) + continue; + + target.Remove(d, false); + target.Add(d); + } + + changeHandler?.EndChange(); + } + + #region Drag & drop import handling + + public Task Import(params string[] paths) + { + Schedule(() => + { + var file = new FileInfo(paths.First()); + + // import to skin + currentSkin.Value.SkinInfo.PerformWrite(skinInfo => + { + using (var contents = file.OpenRead()) + skins.AddFile(skinInfo, contents, file.Name); + }); + + // Even though we are 100% on an update thread, we need to wait for realm callbacks to fire (to correctly invalidate caches in RealmBackedResourceStore). + // See https://github.com/realm/realm-dotnet/discussions/2634#discussioncomment-2483573 for further discussion. + // This is the best we can do for now. + realm.Run(r => r.Refresh()); + + var skinnableTarget = getFirstTarget(); + + // Import still should happen for now, even if not placeable (as it allows a user to import skin resources that would apply to legacy gameplay skins). + if (skinnableTarget == null) + return; + + // place component + var sprite = new SkinnableSprite + { + SpriteName = { Value = file.Name }, + Origin = Anchor.Centre, + Position = skinnableTarget.ToLocalSpace(GetContainingInputManager().CurrentState.Mouse.Position), + }; + + SelectedComponents.Clear(); + placeComponent(sprite, false); + + SkinSelectionHandler.ApplyClosestAnchor(sprite); + }); + + return Task.CompletedTask; + } + + Task ICanAcceptFiles.Import(ImportTask[] tasks, ImportParameters parameters) => throw new NotImplementedException(); + + public IEnumerable HandledExtensions => new[] { ".jpg", ".jpeg", ".png" }; + + #endregion + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + game?.UnregisterImportHandler(this); + } + + private partial class SkinEditorToast : Toast + { + public SkinEditorToast(LocalisableString value, string skinDisplayName) + : base(SkinSettingsStrings.SkinLayoutEditor, value, skinDisplayName) + { + } + } + + public partial class RevertConfirmDialog : DangerousActionDialog + { + public RevertConfirmDialog(Action revert) + { + HeaderText = CommonStrings.RevertToDefault; + BodyText = SkinEditorStrings.RevertToDefaultDescription; + DangerousAction = revert; + } + } + + #region Delegation of IEditorChangeHandler + + public event Action? OnStateChange + { + add => throw new NotImplementedException(); + remove => throw new NotImplementedException(); + } + + private IEditorChangeHandler? beginChangeHandler; + + public void BeginChange() + { + // Change handler may change between begin and end, which can cause unbalanced operations. + // Let's track the one that was used when beginning the change so we can call EndChange on it specifically. + (beginChangeHandler = changeHandler)?.BeginChange(); + } + + public void EndChange() => beginChangeHandler?.EndChange(); + public void SaveState() => changeHandler?.SaveState(); + + #endregion + } +} diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs b/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs new file mode 100644 index 0000000000..d1a1850796 --- /dev/null +++ b/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs @@ -0,0 +1,76 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Newtonsoft.Json; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Screens.Edit; +using osu.Game.Skinning; + +namespace osu.Game.Overlays.SkinEditor +{ + public partial class SkinEditorChangeHandler : EditorChangeHandler + { + private readonly ISerialisableDrawableContainer? firstTarget; + + // ReSharper disable once PrivateFieldCanBeConvertedToLocalVariable + private readonly BindableList? components; + + public SkinEditorChangeHandler(Drawable targetScreen) + { + // To keep things simple, we are currently only handling the current target screen for undo / redo. + // In the future we'll want this to cover all changes, even to skin's `InstantiationInfo`. + // We'll also need to consider cases where multiple targets are on screen at the same time. + + firstTarget = targetScreen.ChildrenOfType().FirstOrDefault(); + + if (firstTarget == null) + return; + + components = new BindableList { BindTarget = firstTarget.Components }; + components.BindCollectionChanged((_, _) => SaveState()); + } + + protected override void WriteCurrentStateToStream(MemoryStream stream) + { + if (firstTarget == null) + return; + + var skinnableInfos = firstTarget.CreateSerialisedInfo().ToArray(); + string json = JsonConvert.SerializeObject(skinnableInfos, new JsonSerializerSettings { Formatting = Formatting.Indented }); + stream.Write(Encoding.UTF8.GetBytes(json)); + } + + protected override void ApplyStateChange(byte[] previousState, byte[] newState) + { + if (firstTarget == null) + return; + + var deserializedContent = JsonConvert.DeserializeObject>(Encoding.UTF8.GetString(newState)); + + if (deserializedContent == null) + return; + + SerialisedDrawableInfo[] skinnableInfo = deserializedContent.ToArray(); + Drawable[] targetComponents = firstTarget.Components.OfType().ToArray(); + + if (!skinnableInfo.Select(s => s.Type).SequenceEqual(targetComponents.Select(d => d.GetType()))) + { + // Perform a naive full reload for now. + firstTarget.Reload(skinnableInfo); + } + else + { + int i = 0; + + foreach (var drawable in targetComponents) + drawable.ApplySerialisedInfo(skinnableInfo[i++]); + } + } + } +} diff --git a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs similarity index 85% rename from osu.Game/Skinning/Editor/SkinEditorOverlay.cs rename to osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs index a3110ced24..1c0ece28fe 100644 --- a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System.Diagnostics; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -12,28 +11,31 @@ using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; using osu.Game.Input.Bindings; using osu.Game.Screens; +using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components; using osuTK; -namespace osu.Game.Skinning.Editor +namespace osu.Game.Overlays.SkinEditor { /// /// A container which handles loading a skin editor on user request for a specified target. /// This also handles the scaling / positioning adjustment of the target. /// - public class SkinEditorOverlay : OverlayContainer, IKeyBindingHandler + public partial class SkinEditorOverlay : OverlayContainer, IKeyBindingHandler { private readonly ScalingContainer scalingContainer; protected override bool BlockNonPositionalInput => true; - [CanBeNull] - private SkinEditor skinEditor; + private SkinEditor? skinEditor; - [Resolved(canBeNull: true)] - private OsuGame game { get; set; } + [Cached] + public readonly EditorClipboard Clipboard = new EditorClipboard(); - private OsuScreen lastTargetScreen; + [Resolved] + private OsuGame game { get; set; } = null!; + + private OsuScreen? lastTargetScreen; private Vector2 lastDrawSize; @@ -68,7 +70,7 @@ namespace osu.Game.Skinning.Editor var editor = new SkinEditor(); - editor.State.BindValueChanged(visibility => updateComponentVisibility()); + editor.State.BindValueChanged(_ => updateComponentVisibility()); skinEditor = editor; @@ -79,6 +81,8 @@ namespace osu.Game.Skinning.Editor AddInternal(editor); + Debug.Assert(lastTargetScreen != null); + SetTarget(lastTargetScreen); }); } @@ -122,15 +126,15 @@ namespace osu.Game.Skinning.Editor { Scheduler.AddOnce(updateScreenSizing); - game?.Toolbar.Hide(); - game?.CloseAllOverlays(); + game.Toolbar.Hide(); + game.CloseAllOverlays(); } else { scalingContainer.SetCustomRect(null); if (lastTargetScreen?.HideOverlaysOnEnter != true) - game?.Toolbar.Show(); + game.Toolbar.Show(); } } @@ -147,7 +151,7 @@ namespace osu.Game.Skinning.Editor if (skinEditor == null) return; - skinEditor.Save(); + skinEditor.Save(userTriggered: false); // ensure the toolbar is re-hidden even if a new screen decides to try and show it. updateComponentVisibility(); @@ -156,7 +160,7 @@ namespace osu.Game.Skinning.Editor Scheduler.AddOnce(setTarget, screen); } - private void setTarget(OsuScreen target) + private void setTarget(OsuScreen? target) { if (target == null) return; diff --git a/osu.Game/Skinning/Editor/SkinEditorSceneLibrary.cs b/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs similarity index 79% rename from osu.Game/Skinning/Editor/SkinEditorSceneLibrary.cs rename to osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs index dc5a8aefc0..61195d7175 100644 --- a/osu.Game/Skinning/Editor/SkinEditorSceneLibrary.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -14,7 +13,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; -using osu.Game.Overlays; +using osu.Game.Localisation; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Screens; @@ -23,9 +22,9 @@ using osu.Game.Screens.Select; using osu.Game.Utils; using osuTK; -namespace osu.Game.Skinning.Editor +namespace osu.Game.Overlays.SkinEditor { - public class SkinEditorSceneLibrary : CompositeDrawable + public partial class SkinEditorSceneLibrary : CompositeDrawable { public const float HEIGHT = BUTTON_HEIGHT + padding * 2; @@ -33,14 +32,14 @@ namespace osu.Game.Skinning.Editor private const float padding = 10; - [Resolved(canBeNull: true)] - private IPerformFromScreenRunner performer { get; set; } + [Resolved] + private IPerformFromScreenRunner? performer { get; set; } [Resolved] - private IBindable ruleset { get; set; } + private IBindable ruleset { get; set; } = null!; [Resolved] - private Bindable> mods { get; set; } + private Bindable> mods { get; set; } = null!; public SkinEditorSceneLibrary() { @@ -64,7 +63,7 @@ namespace osu.Game.Skinning.Editor { new FillFlowContainer { - Name = "Scene library", + Name = @"Scene library", AutoSizeAxes = Axes.X, RelativeSizeAxes = Axes.Y, Spacing = new Vector2(padding), @@ -74,14 +73,14 @@ namespace osu.Game.Skinning.Editor { new OsuSpriteText { - Text = "Scene library", + Text = SkinEditorStrings.SceneLibrary, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Margin = new MarginPadding(10), }, new SceneButton { - Text = "Song Select", + Text = SkinEditorStrings.SongSelect, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Action = () => performer?.PerformFromScreen(screen => @@ -94,7 +93,7 @@ namespace osu.Game.Skinning.Editor }, new SceneButton { - Text = "Gameplay", + Text = SkinEditorStrings.Gameplay, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Action = () => performer?.PerformFromScreen(screen => @@ -104,7 +103,12 @@ namespace osu.Game.Skinning.Editor var replayGeneratingMod = ruleset.Value.CreateInstance().GetAutoplayMod(); - if (!ModUtils.CheckCompatibleSet(mods.Value.Append(replayGeneratingMod), out var invalid)) + IReadOnlyList usableMods = mods.Value; + + if (replayGeneratingMod != null) + usableMods = usableMods.Append(replayGeneratingMod).ToArray(); + + if (!ModUtils.CheckCompatibleSet(usableMods, out var invalid)) mods.Value = mods.Value.Except(invalid).ToArray(); if (replayGeneratingMod != null) @@ -118,7 +122,7 @@ namespace osu.Game.Skinning.Editor }; } - public class SceneButton : OsuButton + public partial class SceneButton : OsuButton { public SceneButton() { @@ -126,8 +130,8 @@ namespace osu.Game.Skinning.Editor Height = BUTTON_HEIGHT; } - [BackgroundDependencyLoader(true)] - private void load([CanBeNull] OverlayColourProvider overlayColourProvider, OsuColour colours) + [BackgroundDependencyLoader] + private void load(OverlayColourProvider? overlayColourProvider, OsuColour colours) { BackgroundColour = overlayColourProvider?.Background3 ?? colours.Blue3; Content.CornerRadius = 5; diff --git a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs b/osu.Game/Overlays/SkinEditor/SkinSelectionHandler.cs similarity index 92% rename from osu.Game/Skinning/Editor/SkinSelectionHandler.cs rename to osu.Game/Overlays/SkinEditor/SkinSelectionHandler.cs index 943425e099..b43f4eeb00 100644 --- a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs +++ b/osu.Game/Overlays/SkinEditor/SkinSelectionHandler.cs @@ -13,15 +13,17 @@ using osu.Framework.Utils; using osu.Game.Extensions; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; +using osu.Game.Screens.Edit.Components.Menus; using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Skinning; using osuTK; -namespace osu.Game.Skinning.Editor +namespace osu.Game.Overlays.SkinEditor { - public class SkinSelectionHandler : SelectionHandler + public partial class SkinSelectionHandler : SelectionHandler { [Resolved] - private SkinEditor skinEditor { get; set; } + private SkinEditor skinEditor { get; set; } = null!; public override bool HandleRotation(float angle) { @@ -146,7 +148,7 @@ namespace osu.Game.Skinning.Editor return true; } - public override bool HandleMovement(MoveSelectionEvent moveEvent) + public override bool HandleMovement(MoveSelectionEvent moveEvent) { foreach (var c in SelectedBlueprints) { @@ -177,10 +179,10 @@ namespace osu.Game.Skinning.Editor SelectionBox.CanReverse = false; } - protected override void DeleteItems(IEnumerable items) => + protected override void DeleteItems(IEnumerable items) => skinEditor.DeleteItems(items.ToArray()); - protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) + protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) { var closestItem = new TernaryStateRadioMenuItem("Closest", MenuItemType.Standard, _ => applyClosestAnchors()) { @@ -205,10 +207,18 @@ namespace osu.Game.Skinning.Editor ((Drawable)blueprint.Item).Position = Vector2.Zero; }); + yield return new EditorMenuItemSpacer(); + + yield return new OsuMenuItem("Bring to front", MenuItemType.Standard, () => skinEditor.BringSelectionToFront()); + + yield return new OsuMenuItem("Send to back", MenuItemType.Standard, () => skinEditor.SendSelectionToBack()); + + yield return new EditorMenuItemSpacer(); + foreach (var item in base.GetContextMenuItemsForSelection(selection)) yield return item; - IEnumerable createAnchorItems(Func checkFunction, Action applyFunction) + IEnumerable createAnchorItems(Func checkFunction, Action applyFunction) { var displayableAnchors = new[] { @@ -240,6 +250,8 @@ namespace osu.Game.Skinning.Editor private void applyOrigins(Anchor origin) { + OnOperationBegan(); + foreach (var item in SelectedItems) { var drawable = (Drawable)item; @@ -254,6 +266,8 @@ namespace osu.Game.Skinning.Editor ApplyClosestAnchor(drawable); } + + OnOperationEnded(); } /// @@ -265,6 +279,8 @@ namespace osu.Game.Skinning.Editor private void applyFixedAnchors(Anchor anchor) { + OnOperationBegan(); + foreach (var item in SelectedItems) { var drawable = (Drawable)item; @@ -272,15 +288,21 @@ namespace osu.Game.Skinning.Editor item.UsesFixedAnchor = true; applyAnchor(drawable, anchor); } + + OnOperationEnded(); } private void applyClosestAnchors() { + OnOperationBegan(); + foreach (var item in SelectedItems) { item.UsesFixedAnchor = false; ApplyClosestAnchor((Drawable)item); } + + OnOperationEnded(); } private static Anchor getClosestAnchor(Drawable drawable) diff --git a/osu.Game/Overlays/SkinEditor/SkinSettingsToolbox.cs b/osu.Game/Overlays/SkinEditor/SkinSettingsToolbox.cs new file mode 100644 index 0000000000..a2b9db2665 --- /dev/null +++ b/osu.Game/Overlays/SkinEditor/SkinSettingsToolbox.cs @@ -0,0 +1,56 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Configuration; +using osu.Game.Localisation; +using osu.Game.Overlays.Settings; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Components; +using osuTK; + +namespace osu.Game.Overlays.SkinEditor +{ + internal partial class SkinSettingsToolbox : EditorSidebarSection + { + [Resolved] + private IEditorChangeHandler? changeHandler { get; set; } + + protected override Container Content { get; } + + private readonly Drawable component; + + public SkinSettingsToolbox(Drawable component) + : base(SkinEditorStrings.Settings(component.GetType().Name)) + { + this.component = component; + + base.Content.Add(Content = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + }); + } + + [BackgroundDependencyLoader] + private void load() + { + var controls = component.CreateSettingsControls().ToArray(); + + Content.AddRange(controls); + + // track any changes to update undo states. + foreach (var c in controls.OfType()) + { + // TODO: SettingChanged is called too often for cases like SettingsTextBox and SettingsSlider. + // We will want to expose a SettingCommitted or similar to make this work better. + c.SettingChanged += () => changeHandler?.SaveState(); + } + } + } +} diff --git a/osu.Game/Overlays/SortDirection.cs b/osu.Game/Overlays/SortDirection.cs index 3af9614972..98ac31103f 100644 --- a/osu.Game/Overlays/SortDirection.cs +++ b/osu.Game/Overlays/SortDirection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Overlays { public enum SortDirection diff --git a/osu.Game/Overlays/TabControlOverlayHeader.cs b/osu.Game/Overlays/TabControlOverlayHeader.cs index 1b0bd658d9..2b87535708 100644 --- a/osu.Game/Overlays/TabControlOverlayHeader.cs +++ b/osu.Game/Overlays/TabControlOverlayHeader.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; +#nullable disable + using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -19,12 +21,12 @@ namespace osu.Game.Overlays /// An overlay header which contains a . /// /// The type of item to be represented by tabs. - public abstract class TabControlOverlayHeader : OverlayHeader, IHasCurrentValue + public abstract partial class TabControlOverlayHeader : OverlayHeader, IHasCurrentValue { - protected OsuTabControl TabControl; + protected OsuTabControl TabControl { get; } + protected Container TabControlContainer { get; } private readonly Box controlBackground; - private readonly Container tabControlContainer; private readonly BindableWithCurrent current = new BindableWithCurrent(); public Bindable Current @@ -39,7 +41,7 @@ namespace osu.Game.Overlays set { base.ContentSidePadding = value; - tabControlContainer.Padding = new MarginPadding { Horizontal = value }; + TabControlContainer.Padding = new MarginPadding { Horizontal = value }; } } @@ -55,15 +57,23 @@ namespace osu.Game.Overlays { RelativeSizeAxes = Axes.Both, }, - tabControlContainer = new Container + TabControlContainer = new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Padding = new MarginPadding { Horizontal = ContentSidePadding }, - Child = TabControl = CreateTabControl().With(control => + Children = new[] { - control.Current = Current; - }) + TabControl = CreateTabControl().With(control => + { + control.Current = Current; + }), + CreateTabControlContent().With(content => + { + content.Anchor = Anchor.CentreRight; + content.Origin = Anchor.CentreRight; + }), + } } } }); @@ -78,7 +88,13 @@ namespace osu.Game.Overlays [NotNull] protected virtual OsuTabControl CreateTabControl() => new OverlayHeaderTabControl(); - public class OverlayHeaderTabControl : OverlayTabControl + /// + /// Creates a on the opposite side of the . Used mostly to create . + /// + [NotNull] + protected virtual Drawable CreateTabControlContent() => Empty(); + + public partial class OverlayHeaderTabControl : OverlayTabControl { private const float bar_height = 1; @@ -101,24 +117,12 @@ namespace osu.Game.Overlays Direction = FillDirection.Horizontal, }; - private class OverlayHeaderTabItem : OverlayTabItem + private partial class OverlayHeaderTabItem : OverlayTabItem { public OverlayHeaderTabItem(T value) : base(value) { - if (!(Value is Enum enumValue)) - Text.Text = Value.ToString().ToLower(); - else - { - var localisableDescription = enumValue.GetLocalisableDescription(); - string nonLocalisableDescription = enumValue.GetDescription(); - - // If localisable == non-localisable, then we must have a basic string, so .ToLower() is used. - Text.Text = localisableDescription.Equals(nonLocalisableDescription) - ? nonLocalisableDescription.ToLower() - : localisableDescription; - } - + Text.Text = value.GetLocalisableDescription().ToLower(); Text.Font = OsuFont.GetFont(size: 14); Text.Margin = new MarginPadding { Vertical = 16.5f }; // 15px padding + 1.5px line-height difference compensation Bar.Margin = new MarginPadding { Bottom = bar_height }; diff --git a/osu.Game/Overlays/TabbableOnlineOverlay.cs b/osu.Game/Overlays/TabbableOnlineOverlay.cs index 9ceab12d3d..d92dd4ae54 100644 --- a/osu.Game/Overlays/TabbableOnlineOverlay.cs +++ b/osu.Game/Overlays/TabbableOnlineOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -10,7 +12,7 @@ using osu.Game.Online.API; namespace osu.Game.Overlays { - public abstract class TabbableOnlineOverlay : OnlineOverlay + public abstract partial class TabbableOnlineOverlay : OnlineOverlay where THeader : TabControlOverlayHeader { private readonly IBindable apiState = new Bindable(); diff --git a/osu.Game/Overlays/Toolbar/AnalogClockDisplay.cs b/osu.Game/Overlays/Toolbar/AnalogClockDisplay.cs index 9228900e99..a5ed0d65bd 100644 --- a/osu.Game/Overlays/Toolbar/AnalogClockDisplay.cs +++ b/osu.Game/Overlays/Toolbar/AnalogClockDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Toolbar { - public class AnalogClockDisplay : ClockDisplay + public partial class AnalogClockDisplay : ClockDisplay { private const float hand_thickness = 2.4f; @@ -74,7 +76,7 @@ namespace osu.Game.Overlays.Toolbar hand.RotateTo(rotation, duration, Easing.OutElastic); } - private class CentreCircle : CompositeDrawable + private partial class CentreCircle : CompositeDrawable { [BackgroundDependencyLoader] private void load(OsuColour colours) @@ -99,7 +101,7 @@ namespace osu.Game.Overlays.Toolbar } } - private class SecondHand : CompositeDrawable + private partial class SecondHand : CompositeDrawable { [BackgroundDependencyLoader] private void load(OsuColour colours) @@ -124,7 +126,7 @@ namespace osu.Game.Overlays.Toolbar } } - private class LargeHand : CompositeDrawable + private partial class LargeHand : CompositeDrawable { public LargeHand(float length) { diff --git a/osu.Game/Overlays/Toolbar/ClockDisplay.cs b/osu.Game/Overlays/Toolbar/ClockDisplay.cs index c1befbb198..088631f8d6 100644 --- a/osu.Game/Overlays/Toolbar/ClockDisplay.cs +++ b/osu.Game/Overlays/Toolbar/ClockDisplay.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics.Containers; namespace osu.Game.Overlays.Toolbar { - public abstract class ClockDisplay : CompositeDrawable + public abstract partial class ClockDisplay : CompositeDrawable { private int? lastSecond; diff --git a/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs b/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs index ac6f563336..ada2f6ff86 100644 --- a/osu.Game/Overlays/Toolbar/DigitalClockDisplay.cs +++ b/osu.Game/Overlays/Toolbar/DigitalClockDisplay.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; using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; namespace osu.Game.Overlays.Toolbar { - public class DigitalClockDisplay : ClockDisplay + public partial class DigitalClockDisplay : ClockDisplay { private OsuSpriteText realTime; private OsuSpriteText gameTime; @@ -67,7 +70,7 @@ namespace osu.Game.Overlays.Toolbar protected override void UpdateDisplay(DateTimeOffset now) { - realTime.Text = use24HourDisplay ? $"{now:HH:mm:ss}" : $"{now:h:mm:ss tt}"; + realTime.Text = now.ToLocalisableString(use24HourDisplay ? @"HH:mm:ss" : @"h:mm:ss tt"); gameTime.Text = $"running {new TimeSpan(TimeSpan.TicksPerSecond * (int)(Clock.CurrentTime / 1000)):c}"; } diff --git a/osu.Game/Overlays/Toolbar/Toolbar.cs b/osu.Game/Overlays/Toolbar/Toolbar.cs index b7fb2e45be..93294a9d30 100644 --- a/osu.Game/Overlays/Toolbar/Toolbar.cs +++ b/osu.Game/Overlays/Toolbar/Toolbar.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -14,11 +16,12 @@ using osu.Framework.Bindables; using osu.Framework.Input.Events; using osu.Game.Rulesets; using osu.Framework.Input.Bindings; +using osu.Game.Graphics.Containers; using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Toolbar { - public class Toolbar : OverlayContainer, IKeyBindingHandler + public partial class Toolbar : OverlayContainer, IKeyBindingHandler { public const float HEIGHT = 40; public const float TOOLTIP_HEIGHT = 30; @@ -39,9 +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; - - protected override bool BlockScrollInput => false; + public override bool PropagateNonPositionalInputSubTree => OverlayActivationMode.Value != OverlayActivation.Disabled; public Toolbar() { @@ -64,52 +65,133 @@ namespace osu.Game.Overlays.Toolbar [BackgroundDependencyLoader(true)] private void load(OsuGame osuGame) { + ToolbarBackground background; + HoverInterceptor interceptor; + Children = new Drawable[] { - new ToolbarBackground(), - new FillFlowContainer + background = new ToolbarBackground(), + new GridContainer { - Direction = FillDirection.Horizontal, - RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X, - Children = new Drawable[] + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] { - new ToolbarSettingsButton(), - new ToolbarHomeButton + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] { - Action = () => OnHome?.Invoke() + new Container + { + Name = "Left buttons", + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Depth = float.MinValue, + Children = new Drawable[] + { + new Box + { + Colour = OsuColour.Gray(0.1f), + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + Direction = FillDirection.Horizontal, + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Children = new Drawable[] + { + new ToolbarSettingsButton(), + new ToolbarHomeButton + { + Action = () => OnHome?.Invoke() + }, + }, + }, + } + }, + new Container + { + Name = "Ruleset selector", + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new OsuScrollContainer(Direction.Horizontal) + { + ScrollbarVisible = false, + RelativeSizeAxes = Axes.Both, + Masking = false, + Children = new Drawable[] + { + rulesetSelector = new ToolbarRulesetSelector() + } + }, + new Box + { + Colour = ColourInfo.GradientHorizontal(OsuColour.Gray(0.1f).Opacity(0), OsuColour.Gray(0.1f)), + Width = 50, + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }, + } + }, + new Container + { + Name = "Right buttons", + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Children = new Drawable[] + { + new Box + { + Colour = OsuColour.Gray(0.1f), + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Direction = FillDirection.Horizontal, + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Children = new Drawable[] + { + new ToolbarNewsButton(), + new ToolbarChangelogButton(), + new ToolbarRankingsButton(), + new ToolbarBeatmapListingButton(), + new ToolbarChatButton(), + new ToolbarSocialButton(), + new ToolbarWikiButton(), + new ToolbarMusicButton(), + //new ToolbarButton + //{ + // Icon = FontAwesome.Solid.search + //}, + userButton = new ToolbarUserButton(), + new ToolbarClock(), + new ToolbarNotificationButton(), + } + }, + } + }, }, - rulesetSelector = new ToolbarRulesetSelector() } }, - new FillFlowContainer + interceptor = new HoverInterceptor { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Direction = FillDirection.Horizontal, - RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X, - Children = new Drawable[] - { - new ToolbarNewsButton(), - new ToolbarChangelogButton(), - new ToolbarRankingsButton(), - new ToolbarBeatmapListingButton(), - new ToolbarChatButton(), - new ToolbarSocialButton(), - new ToolbarWikiButton(), - new ToolbarMusicButton(), - //new ToolbarButton - //{ - // Icon = FontAwesome.Solid.search - //}, - userButton = new ToolbarUserButton(), - new ToolbarClock(), - new ToolbarNotificationButton(), - } + RelativeSizeAxes = Axes.Both } }; + ((IBindable)background.ShowGradient).BindTo(interceptor.ReceivedHover); + if (osuGame != null) OverlayActivationMode.BindTo(osuGame.OverlayActivationMode); } @@ -121,8 +203,10 @@ namespace osu.Game.Overlays.Toolbar rulesetSelector.Current.BindTo(ruleset); } - public class ToolbarBackground : Container + public partial class ToolbarBackground : Container { + public Bindable ShowGradient { get; } = new BindableBool(); + private readonly Box gradientBackground; public ToolbarBackground() @@ -147,15 +231,43 @@ namespace osu.Game.Overlays.Toolbar }; } + protected override void LoadComplete() + { + base.LoadComplete(); + + ShowGradient.BindValueChanged(_ => updateState(), true); + } + + private void updateState() + { + if (ShowGradient.Value) + gradientBackground.FadeIn(transition_time, Easing.OutQuint); + else + gradientBackground.FadeOut(transition_time, Easing.OutQuint); + } + } + + /// + /// Whenever the mouse cursor is within the bounds of the toolbar, we want the background gradient to show, for toolbar button descriptions to be legible. + /// Unfortunately we also need to ensure that the toolbar buttons handle hover, to prevent the possibility of multiple descriptions being shown + /// due to hover events passing through multiple buttons. + /// This drawable is a workaround, that when placed front-most in the toolbar, allows to see whether hover events have been propagated through it without handling them. + /// + private partial class HoverInterceptor : Drawable + { + public IBindable ReceivedHover => receivedHover; + private readonly Bindable receivedHover = new BindableBool(); + protected override bool OnHover(HoverEvent e) { - gradientBackground.FadeIn(transition_time, Easing.OutQuint); - return true; + receivedHover.Value = true; + return base.OnHover(e); } protected override void OnHoverLost(HoverLostEvent e) { - gradientBackground.FadeOut(transition_time, Easing.OutQuint); + receivedHover.Value = false; + base.OnHoverLost(e); } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarBeatmapListingButton.cs b/osu.Game/Overlays/Toolbar/ToolbarBeatmapListingButton.cs index bfe36a6a0f..efcb011293 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarBeatmapListingButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarBeatmapListingButton.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Toolbar { - public class ToolbarBeatmapListingButton : ToolbarOverlayToggleButton + public partial class ToolbarBeatmapListingButton : ToolbarOverlayToggleButton { protected override Anchor TooltipAnchor => Anchor.TopRight; diff --git a/osu.Game/Overlays/Toolbar/ToolbarButton.cs b/osu.Game/Overlays/Toolbar/ToolbarButton.cs index b686f11c13..4193e52584 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -26,7 +28,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Toolbar { - public abstract class ToolbarButton : OsuClickableContainer, IKeyBindingHandler + public abstract partial class ToolbarButton : OsuClickableContainer, IKeyBindingHandler { protected GlobalAction? Hotkey { get; set; } @@ -161,7 +163,7 @@ namespace osu.Game.Overlays.Toolbar }; } - protected override bool OnMouseDown(MouseDownEvent e) => true; + protected override bool OnMouseDown(MouseDownEvent e) => false; protected override bool OnClick(ClickEvent e) { @@ -177,7 +179,7 @@ namespace osu.Game.Overlays.Toolbar HoverBackground.FadeIn(200); tooltipContainer.FadeIn(100); - return base.OnHover(e); + return true; } protected override void OnHoverLost(HoverLostEvent e) @@ -217,7 +219,7 @@ namespace osu.Game.Overlays.Toolbar } } - public class OpaqueBackground : Container + public partial class OpaqueBackground : Container { public OpaqueBackground() { diff --git a/osu.Game/Overlays/Toolbar/ToolbarChangelogButton.cs b/osu.Game/Overlays/Toolbar/ToolbarChangelogButton.cs index 86bc73361a..30e32d831c 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarChangelogButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarChangelogButton.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; namespace osu.Game.Overlays.Toolbar { - public class ToolbarChangelogButton : ToolbarOverlayToggleButton + public partial class ToolbarChangelogButton : ToolbarOverlayToggleButton { protected override Anchor TooltipAnchor => Anchor.TopRight; diff --git a/osu.Game/Overlays/Toolbar/ToolbarChatButton.cs b/osu.Game/Overlays/Toolbar/ToolbarChatButton.cs index 2d3b33e9bc..7bb94067ab 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarChatButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarChatButton.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Toolbar { - public class ToolbarChatButton : ToolbarOverlayToggleButton + public partial class ToolbarChatButton : ToolbarOverlayToggleButton { protected override Anchor TooltipAnchor => Anchor.TopRight; diff --git a/osu.Game/Overlays/Toolbar/ToolbarClock.cs b/osu.Game/Overlays/Toolbar/ToolbarClock.cs index 12529da07f..f1310d8535 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarClock.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarClock.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -11,12 +13,13 @@ using osu.Framework.Input.Events; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; using osuTK; using osuTK.Graphics; namespace osu.Game.Overlays.Toolbar { - public class ToolbarClock : OsuClickableContainer + public partial class ToolbarClock : OsuClickableContainer { private Bindable clockDisplayMode; private Bindable prefer24HourTime; @@ -121,6 +124,8 @@ namespace osu.Game.Overlays.Toolbar base.OnHoverLost(e); } + protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverClickSounds(sampleSet); + private void cycleDisplayMode() { switch (clockDisplayMode.Value) diff --git a/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs b/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs index 76fbd40d66..dba4e8feb6 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs @@ -1,12 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Game.Input.Bindings; +using osu.Game.Localisation; namespace osu.Game.Overlays.Toolbar { - public class ToolbarHomeButton : ToolbarButton + public partial class ToolbarHomeButton : ToolbarButton { public ToolbarHomeButton() { @@ -17,8 +20,8 @@ namespace osu.Game.Overlays.Toolbar [BackgroundDependencyLoader] private void load() { - TooltipMain = "home"; - TooltipSub = "return to the main menu"; + TooltipMain = ToolbarStrings.HomeHeaderTitle; + TooltipSub = ToolbarStrings.HomeHeaderDescription; SetIcon("Icons/Hexacons/home"); } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarMusicButton.cs b/osu.Game/Overlays/Toolbar/ToolbarMusicButton.cs index 0f5e8e5456..69597c6b46 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarMusicButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarMusicButton.cs @@ -1,25 +1,137 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Transforms; +using osu.Framework.Input.Events; +using osu.Framework.Threading; using osu.Game.Input.Bindings; +using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Overlays.Toolbar { - public class ToolbarMusicButton : ToolbarOverlayToggleButton + public partial class ToolbarMusicButton : ToolbarOverlayToggleButton { + private Circle volumeBar; + protected override Anchor TooltipAnchor => Anchor.TopRight; public ToolbarMusicButton() { Hotkey = GlobalAction.ToggleNowPlaying; + AutoSizeAxes = Axes.X; } [BackgroundDependencyLoader(true)] private void load(NowPlayingOverlay music) { StateContainer = music; + + Flow.Padding = new MarginPadding { Horizontal = Toolbar.HEIGHT / 4 }; + Flow.Add(volumeDisplay = new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Width = 3f, + Height = IconContainer.Height, + Margin = new MarginPadding { Horizontal = 2.5f }, + Masking = true, + Children = new[] + { + new Circle + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.White.Opacity(0.25f), + }, + volumeBar = new Circle + { + RelativeSizeAxes = Axes.Both, + Height = 0f, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Colour = Color4.White, + } + } + }); + } + + [Resolved] + private AudioManager audio { get; set; } + + [Resolved(canBeNull: true)] + private VolumeOverlay volume { get; set; } + + private IBindable globalVolume; + private Container volumeDisplay; + + protected override void LoadComplete() + { + base.LoadComplete(); + + globalVolume = audio.Volume.GetBoundCopy(); + globalVolume.BindValueChanged(v => volumeBar.ResizeHeightTo((float)v.NewValue, 200, Easing.OutQuint), true); + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (!IsHovered) + return false; + + switch (e.Key) + { + case Key.Up: + focusForAdjustment(); + volume?.Adjust(GlobalAction.IncreaseVolume); + return true; + + case Key.Down: + focusForAdjustment(); + volume?.Adjust(GlobalAction.DecreaseVolume); + return true; + } + + return base.OnKeyDown(e); + } + + protected override bool OnScroll(ScrollEvent e) + { + focusForAdjustment(); + volume?.Adjust(GlobalAction.IncreaseVolume, e.ScrollDelta.Y, e.IsPrecise); + return true; + } + + private void focusForAdjustment() + { + volume?.FocusMasterVolume(); + expandVolumeBarTemporarily(); + } + + private TransformSequence expandTransform; + private ScheduledDelegate contractTransform; + + private void expandVolumeBarTemporarily() + { + // avoid starting a new transform if one is already active. + if (expandTransform == null) + { + expandTransform = volumeDisplay.ResizeWidthTo(6, 500, Easing.OutQuint); + expandTransform.Finally(_ => expandTransform = null); + } + + contractTransform?.Cancel(); + contractTransform = Scheduler.AddDelayed(() => + { + volumeDisplay.ResizeWidthTo(3f, 500, Easing.OutQuint); + }, 1000); } } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarNewsButton.cs b/osu.Game/Overlays/Toolbar/ToolbarNewsButton.cs index 9b2573ad07..bdcf6c3fec 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarNewsButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarNewsButton.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; namespace osu.Game.Overlays.Toolbar { - public class ToolbarNewsButton : ToolbarOverlayToggleButton + public partial class ToolbarNewsButton : ToolbarOverlayToggleButton { protected override Anchor TooltipAnchor => Anchor.TopRight; diff --git a/osu.Game/Overlays/Toolbar/ToolbarNotificationButton.cs b/osu.Game/Overlays/Toolbar/ToolbarNotificationButton.cs index 313a2bc3f4..3dfec2cba0 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarNotificationButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarNotificationButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -14,7 +16,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Toolbar { - public class ToolbarNotificationButton : ToolbarOverlayToggleButton + public partial class ToolbarNotificationButton : ToolbarOverlayToggleButton { protected override Anchor TooltipAnchor => Anchor.TopRight; @@ -56,7 +58,7 @@ namespace osu.Game.Overlays.Toolbar }; } - private class CountCircle : CompositeDrawable + private partial class CountCircle : CompositeDrawable { private readonly OsuSpriteText countText; private readonly Circle circle; diff --git a/osu.Game/Overlays/Toolbar/ToolbarOverlayToggleButton.cs b/osu.Game/Overlays/Toolbar/ToolbarOverlayToggleButton.cs index 0dea71cc08..7bd48174db 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarOverlayToggleButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarOverlayToggleButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osu.Game.Graphics; namespace osu.Game.Overlays.Toolbar { - public class ToolbarOverlayToggleButton : ToolbarButton + public partial class ToolbarOverlayToggleButton : ToolbarButton { private readonly Box stateBackground; diff --git a/osu.Game/Overlays/Toolbar/ToolbarRankingsButton.cs b/osu.Game/Overlays/Toolbar/ToolbarRankingsButton.cs index 312fc41aab..ddbf4889b6 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarRankingsButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarRankingsButton.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; namespace osu.Game.Overlays.Toolbar { - public class ToolbarRankingsButton : ToolbarOverlayToggleButton + public partial class ToolbarRankingsButton : ToolbarOverlayToggleButton { protected override Anchor TooltipAnchor => Anchor.TopRight; diff --git a/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs b/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs index 6e5fbf6efb..715076b368 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -19,7 +21,7 @@ using osu.Framework.Audio.Sample; namespace osu.Game.Overlays.Toolbar { - public class ToolbarRulesetSelector : RulesetSelector + public partial class ToolbarRulesetSelector : RulesetSelector { protected Drawable ModeButtonLine { get; private set; } diff --git a/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs b/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs index a70a0d8a71..07f7d52545 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs @@ -1,16 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using osu.Framework.Graphics; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Localisation; using osu.Game.Rulesets; using osuTK.Graphics; -using osu.Framework.Graphics; -using osu.Framework.Input.Events; namespace osu.Game.Overlays.Toolbar { - public class ToolbarRulesetTabButton : TabItem + public partial class ToolbarRulesetTabButton : TabItem { private readonly RulesetButton ruleset; @@ -27,7 +30,7 @@ namespace osu.Game.Overlays.Toolbar var rInstance = value.CreateInstance(); ruleset.TooltipMain = rInstance.Description; - ruleset.TooltipSub = $"play some {rInstance.Description}"; + ruleset.TooltipSub = ToolbarStrings.PlaySomeRuleset(rInstance.Description); ruleset.SetIcon(rInstance.CreateIcon()); } @@ -35,7 +38,7 @@ namespace osu.Game.Overlays.Toolbar protected override void OnDeactivated() => ruleset.Active = false; - private class RulesetButton : ToolbarButton + private partial class RulesetButton : ToolbarButton { public bool Active { diff --git a/osu.Game/Overlays/Toolbar/ToolbarSettingsButton.cs b/osu.Game/Overlays/Toolbar/ToolbarSettingsButton.cs index c53f4a55d9..6ebf2a4c02 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarSettingsButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarSettingsButton.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Toolbar { - public class ToolbarSettingsButton : ToolbarOverlayToggleButton + public partial class ToolbarSettingsButton : ToolbarOverlayToggleButton { public ToolbarSettingsButton() { diff --git a/osu.Game/Overlays/Toolbar/ToolbarSocialButton.cs b/osu.Game/Overlays/Toolbar/ToolbarSocialButton.cs index 1e00afc5fd..a8a88813d2 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarSocialButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarSocialButton.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Toolbar { - public class ToolbarSocialButton : ToolbarOverlayToggleButton + public partial class ToolbarSocialButton : ToolbarOverlayToggleButton { protected override Anchor TooltipAnchor => Anchor.TopRight; diff --git a/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs b/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs index d8ba07dc3b..028decea1e 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs @@ -1,76 +1,124 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; -using osu.Game.Resources.Localisation.Web; using osu.Game.Users.Drawables; using osuTK; using osuTK.Graphics; namespace osu.Game.Overlays.Toolbar { - public class ToolbarUserButton : ToolbarOverlayToggleButton + public partial class ToolbarUserButton : ToolbarOverlayToggleButton { - private readonly UpdateableAvatar avatar; + private UpdateableAvatar avatar = null!; - [Resolved] - private IAPIProvider api { get; set; } + private IBindable localUser = null!; - private readonly IBindable apiState = new Bindable(); + private LoadingSpinner spinner = null!; + + private SpriteIcon failingIcon = null!; + + private IBindable apiState = null!; public ToolbarUserButton() { AutoSizeAxes = Axes.X; + } - DrawableText.Font = OsuFont.GetFont(italics: true); - + [BackgroundDependencyLoader] + private void load(OsuColour colours, IAPIProvider api, LoginOverlay? login) + { Add(new OpaqueBackground { Depth = 1 }); - Flow.Add(avatar = new UpdateableAvatar(isInteractive: false) + Flow.Add(new Container { Masking = true, + CornerRadius = 4, Size = new Vector2(32), Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - CornerRadius = 4, EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Shadow, Radius = 4, Colour = Color4.Black.Opacity(0.1f), + }, + Children = new Drawable[] + { + avatar = new UpdateableAvatar(isInteractive: false) + { + RelativeSizeAxes = Axes.Both, + }, + spinner = new LoadingLayer(dimBackground: true, withBox: false, blockInput: false) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + }, + failingIcon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + Size = new Vector2(0.3f), + Icon = FontAwesome.Solid.ExclamationTriangle, + RelativeSizeAxes = Axes.Both, + Colour = colours.YellowLight, + }, } }); - } - [BackgroundDependencyLoader(true)] - private void load(LoginOverlay login) - { - apiState.BindTo(api.State); + apiState = api.State.GetBoundCopy(); apiState.BindValueChanged(onlineStateChanged, true); + localUser = api.LocalUser.GetBoundCopy(); + localUser.BindValueChanged(userChanged, true); + StateContainer = login; } + private void userChanged(ValueChangedEvent user) => Schedule(() => + { + Text = user.NewValue.Username; + avatar.User = user.NewValue; + }); + private void onlineStateChanged(ValueChangedEvent state) => Schedule(() => { + failingIcon.FadeTo(state.NewValue == APIState.Failing ? 1 : 0, 200, Easing.OutQuint); + switch (state.NewValue) { - default: - Text = UsersStrings.AnonymousUsername; - avatar.User = new APIUser(); + case APIState.Connecting: + TooltipText = ToolbarStrings.Connecting; + spinner.Show(); break; - case APIState.Online: - Text = api.LocalUser.Value.Username; - avatar.User = api.LocalUser.Value; + case APIState.Failing: + TooltipText = ToolbarStrings.AttemptingToReconnect; + spinner.Show(); break; + + case APIState.Offline: + case APIState.Online: + TooltipText = string.Empty; + spinner.Hide(); + break; + + default: + throw new ArgumentOutOfRangeException(); } }); } diff --git a/osu.Game/Overlays/Toolbar/ToolbarWikiButton.cs b/osu.Game/Overlays/Toolbar/ToolbarWikiButton.cs index a521219b4f..49e6be7978 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarWikiButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarWikiButton.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; namespace osu.Game.Overlays.Toolbar { - public class ToolbarWikiButton : ToolbarOverlayToggleButton + public partial class ToolbarWikiButton : ToolbarOverlayToggleButton { protected override Anchor TooltipAnchor => Anchor.TopRight; diff --git a/osu.Game/Overlays/UserProfileOverlay.cs b/osu.Game/Overlays/UserProfileOverlay.cs index 518a2bf9c8..d1fe877e55 100644 --- a/osu.Game/Overlays/UserProfileOverlay.cs +++ b/osu.Game/Overlays/UserProfileOverlay.cs @@ -2,51 +2,74 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Game.Extensions; +using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Profile; using osu.Game.Overlays.Profile.Sections; +using osu.Game.Rulesets; using osu.Game.Users; using osuTK; using osuTK.Graphics; namespace osu.Game.Overlays { - public class UserProfileOverlay : FullscreenOverlay + public partial class UserProfileOverlay : FullscreenOverlay { - private ProfileSection lastSection; - private ProfileSection[] sections; - private GetUserRequest userReq; - private ProfileSectionsContainer sectionsContainer; - private ProfileSectionTabControl tabs; + protected override Container Content => onlineViewContainer; - public const float CONTENT_X_MARGIN = 70; + private readonly OnlineViewContainer onlineViewContainer; + private readonly LoadingLayer loadingLayer; + + private ProfileSection? lastSection; + private ProfileSection[]? sections; + private GetUserRequest? userReq; + private ProfileSectionsContainer? sectionsContainer; + private ProfileSectionTabControl? tabs; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; public UserProfileOverlay() : base(OverlayColourScheme.Pink) { + base.Content.AddRange(new Drawable[] + { + onlineViewContainer = new OnlineViewContainer($"Sign in to view the {Header.Title.Title}") + { + RelativeSizeAxes = Axes.Both + }, + loadingLayer = new LoadingLayer(true) + }); } protected override ProfileHeader CreateHeader() => new ProfileHeader(); - protected override Color4 BackgroundColour => ColourProvider.Background6; + protected override Color4 BackgroundColour => ColourProvider.Background5; - public void ShowUser(IUser user) + public void ShowUser(IUser user, IRulesetInfo? ruleset = null) { if (user.OnlineID == APIUser.SYSTEM_USER_ID) return; Show(); - if (user.OnlineID == Header?.User.Value?.Id) + if (user.OnlineID == Header.User.Value?.User.Id && ruleset?.MatchesOnlineID(Header.User.Value?.Ruleset) == true) return; if (sectionsContainer != null) @@ -76,17 +99,22 @@ namespace osu.Game.Overlays Origin = Anchor.TopCentre, }; - Add(sectionsContainer = new ProfileSectionsContainer + Add(new OsuContextMenuContainer { - ExpandableHeader = Header, - FixedHeader = tabs, - HeaderBackground = new Box + RelativeSizeAxes = Axes.Both, + Child = sectionsContainer = new ProfileSectionsContainer { - // this is only visible as the ProfileTabControl background - Colour = ColourProvider.Background5, - RelativeSizeAxes = Axes.Both - }, + ExpandableHeader = Header, + FixedHeader = tabs, + HeaderBackground = new Box + { + // this is only visible as the ProfileTabControl background + Colour = ColourProvider.Background5, + RelativeSizeAxes = Axes.Both + }, + } }); + sectionsContainer.SelectedSection.ValueChanged += section => { if (lastSection != section.NewValue) @@ -115,23 +143,20 @@ namespace osu.Game.Overlays sectionsContainer.ScrollToTop(); - // Check arbitrarily whether this user has already been populated. - // This is only generally used by tests, but should be quite safe unless we want to force a refresh on loading a previous user in the future. - if (user is APIUser apiUser && apiUser.JoinDate != default) - { - userReq = null; - userLoadComplete(apiUser); - return; - } - - userReq = user.OnlineID > 1 ? new GetUserRequest(user.OnlineID) : new GetUserRequest(user.Username); - userReq.Success += userLoadComplete; + userReq = user.OnlineID > 1 ? new GetUserRequest(user.OnlineID, ruleset) : new GetUserRequest(user.Username, ruleset); + userReq.Success += u => userLoadComplete(u, ruleset); API.Queue(userReq); + loadingLayer.Show(); } - private void userLoadComplete(APIUser user) + private void userLoadComplete(APIUser user, IRulesetInfo? ruleset) { - Header.User.Value = user; + Debug.Assert(sections != null && sectionsContainer != null && tabs != null); + + var actualRuleset = rulesets.GetRuleset(ruleset?.ShortName ?? user.PlayMode).AsNonNull(); + + var userProfile = new UserProfileData(user, actualRuleset); + Header.User.Value = userProfile; if (user.ProfileOrder != null) { @@ -141,60 +166,88 @@ namespace osu.Game.Overlays if (sec != null) { - sec.User.Value = user; + sec.User.Value = userProfile; sectionsContainer.Add(sec); tabs.AddItem(sec); } } } + + loadingLayer.Hide(); } - private class ProfileSectionTabControl : OverlayTabControl + private partial class ProfileSectionTabControl : OsuTabControl { - private const float bar_height = 2; - public ProfileSectionTabControl() { - TabContainer.RelativeSizeAxes &= ~Axes.X; - TabContainer.AutoSizeAxes |= Axes.X; - TabContainer.Anchor |= Anchor.x1; - TabContainer.Origin |= Anchor.x1; - - Height = 36 + bar_height; - BarHeight = bar_height; + Height = 40; + Padding = new MarginPadding { Horizontal = HORIZONTAL_PADDING }; + TabContainer.Spacing = new Vector2(20); } - protected override TabItem CreateTabItem(ProfileSection value) => new ProfileSectionTabItem(value) - { - AccentColour = AccentColour, - }; - - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) - { - AccentColour = colourProvider.Highlight1; - } + protected override TabItem CreateTabItem(ProfileSection value) => new ProfileSectionTabItem(value); protected override bool OnClick(ClickEvent e) => true; protected override bool OnHover(HoverEvent e) => true; - private class ProfileSectionTabItem : OverlayTabItem + private partial class ProfileSectionTabItem : TabItem { + private OsuSpriteText text = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + public ProfileSectionTabItem(ProfileSection value) : base(value) { - Text.Text = value.Title; - Text.Font = Text.Font.With(size: 16); - Text.Margin = new MarginPadding { Bottom = 10 + bar_height }; - Bar.ExpandedSize = 10; - Bar.Margin = new MarginPadding { Bottom = bar_height }; + } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + + InternalChild = text = new OsuSpriteText + { + Text = Value.Title + }; + + updateState(); + } + + protected override void OnActivated() => updateState(); + + protected override void OnDeactivated() => updateState(); + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) => updateState(); + + private void updateState() + { + text.Font = OsuFont.Default.With(size: 14, weight: Active.Value ? FontWeight.SemiBold : FontWeight.Regular); + + Colour4 textColour; + + if (IsHovered) + textColour = colourProvider.Light1; + else + textColour = Active.Value ? colourProvider.Content1 : colourProvider.Light2; + + text.FadeColour(textColour, 300, Easing.OutQuint); } } } - private class ProfileSectionsContainer : SectionsContainer + private partial class ProfileSectionsContainer : SectionsContainer { public ProfileSectionsContainer() { @@ -203,12 +256,16 @@ namespace osu.Game.Overlays protected override UserTrackingScrollContainer CreateScrollContainer() => new OverlayScrollContainer(); - protected override FlowContainer CreateScrollContentContainer() => new FillFlowContainer + // 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. + protected override FlowContainer CreateScrollContentContainer() => new ReverseChildIDFillFlowContainer { Direction = FillDirection.Vertical, AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, - Spacing = new Vector2(0, 20), + Spacing = new Vector2(0, 10), + Padding = new MarginPadding { Horizontal = 10 }, + Margin = new MarginPadding { Bottom = 10 }, }; } } diff --git a/osu.Game/Overlays/VersionManager.cs b/osu.Game/Overlays/VersionManager.cs index fe6613fba2..0e74cada29 100644 --- a/osu.Game/Overlays/VersionManager.cs +++ b/osu.Game/Overlays/VersionManager.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Development; using osu.Framework.Graphics; @@ -14,7 +16,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays { - public class VersionManager : VisibilityContainer + public partial class VersionManager : VisibilityContainer { [BackgroundDependencyLoader] private void load(OsuColour colours, TextureStore textures, OsuGameBase game) diff --git a/osu.Game/Overlays/Volume/MuteButton.cs b/osu.Game/Overlays/Volume/MuteButton.cs index e9d3b31207..c83ad4ac0d 100644 --- a/osu.Game/Overlays/Volume/MuteButton.cs +++ b/osu.Game/Overlays/Volume/MuteButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -17,7 +19,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Volume { - public class MuteButton : OsuButton, IHasCurrentValue + public partial class MuteButton : OsuButton, IHasCurrentValue { private readonly Bindable current = new Bindable(); @@ -26,8 +28,7 @@ namespace osu.Game.Overlays.Volume get => current; set { - if (value == null) - throw new ArgumentNullException(nameof(value)); + ArgumentNullException.ThrowIfNull(value); current.UnbindBindings(); current.BindTo(value); @@ -87,5 +88,13 @@ namespace osu.Game.Overlays.Volume { Content.TransformTo, ColourInfo>("BorderColour", unhoveredColour, 500, Easing.OutQuint); } + + protected override bool OnMouseDown(MouseDownEvent e) + { + base.OnMouseDown(e); + + // Block mouse down to avoid dismissing overlays sitting behind the mute button + return true; + } } } diff --git a/osu.Game/Overlays/Volume/VolumeControlReceptor.cs b/osu.Game/Overlays/Volume/VolumeControlReceptor.cs index c601ce4130..4ddbc9dd48 100644 --- a/osu.Game/Overlays/Volume/VolumeControlReceptor.cs +++ b/osu.Game/Overlays/Volume/VolumeControlReceptor.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics.Containers; using osu.Framework.Input; @@ -10,7 +12,7 @@ using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Volume { - public class VolumeControlReceptor : Container, IScrollBindingHandler, IHandleGlobalKeyboardInput + public partial class VolumeControlReceptor : Container, IScrollBindingHandler, IHandleGlobalKeyboardInput { public Func ActionRequested; public Func ScrollActionRequested; @@ -21,10 +23,14 @@ namespace osu.Game.Overlays.Volume { case GlobalAction.DecreaseVolume: case GlobalAction.IncreaseVolume: + ActionRequested?.Invoke(e.Action); + return true; + case GlobalAction.ToggleMute: case GlobalAction.NextVolumeMeter: case GlobalAction.PreviousVolumeMeter: - ActionRequested?.Invoke(e.Action); + if (!e.Repeat) + ActionRequested?.Invoke(e.Action); return true; } diff --git a/osu.Game/Overlays/Volume/VolumeMeter.cs b/osu.Game/Overlays/Volume/VolumeMeter.cs index 24d9f785f2..d366f0bddb 100644 --- a/osu.Game/Overlays/Volume/VolumeMeter.cs +++ b/osu.Game/Overlays/Volume/VolumeMeter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Globalization; using osu.Framework; @@ -27,7 +29,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Volume { - public class VolumeMeter : Container, IStateful + public partial class VolumeMeter : Container, IStateful { private CircularProgress volumeCircle; private CircularProgress volumeCircleGlow; diff --git a/osu.Game/Overlays/VolumeOverlay.cs b/osu.Game/Overlays/VolumeOverlay.cs index 9d2ed3f837..5470c70400 100644 --- a/osu.Game/Overlays/VolumeOverlay.cs +++ b/osu.Game/Overlays/VolumeOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; @@ -21,7 +23,7 @@ using osuTK.Input; namespace osu.Game.Overlays { - public class VolumeOverlay : VisibilityContainer + public partial class VolumeOverlay : VisibilityContainer { private const float offset = 10; @@ -140,11 +142,16 @@ namespace osu.Game.Overlays private ScheduledDelegate popOutDelegate; + public void FocusMasterVolume() + { + volumeMeters.Select(volumeMeterMaster); + } + public override void Show() { // Focus on the master meter as a default if previously hidden if (State.Value == Visibility.Hidden) - volumeMeters.Select(volumeMeterMaster); + FocusMasterVolume(); if (State.Value == Visibility.Visible) schedulePopOut(); diff --git a/osu.Game/Overlays/WaveOverlayContainer.cs b/osu.Game/Overlays/WaveOverlayContainer.cs index 52ae4dbdbb..00474cc0d8 100644 --- a/osu.Game/Overlays/WaveOverlayContainer.cs +++ b/osu.Game/Overlays/WaveOverlayContainer.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Containers; using osu.Framework.Graphics; using osu.Game.Graphics.Containers; namespace osu.Game.Overlays { - public abstract class WaveOverlayContainer : OsuFocusedOverlayContainer + public abstract partial class WaveOverlayContainer : OsuFocusedOverlayContainer { protected readonly WaveContainer Waves; @@ -20,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/Markdown/WikiMarkdownContainer.cs b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs index 789ed457a4..7c36caa62f 100644 --- a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs +++ b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs @@ -1,7 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; +using Markdig.Extensions.CustomContainers; using Markdig.Extensions.Yaml; using Markdig.Syntax; using Markdig.Syntax.Inlines; @@ -11,8 +14,15 @@ using osu.Game.Graphics.Containers.Markdown; namespace osu.Game.Overlays.Wiki.Markdown { - public class WikiMarkdownContainer : OsuMarkdownContainer + public partial class WikiMarkdownContainer : OsuMarkdownContainer { + protected override OsuMarkdownContainerOptions Options => new OsuMarkdownContainerOptions + { + Footnotes = true, + CustomContainers = true, + BlockAttributes = true + }; + public string CurrentPath { set => DocumentUrl = value; @@ -22,6 +32,11 @@ namespace osu.Game.Overlays.Wiki.Markdown { switch (markdownObject) { + case CustomContainer: + // infoboxes are parsed into CustomContainer objects, but we don't have support for infoboxes yet. + // todo: add support for infobox. + break; + case YamlFrontMatterBlock yamlFrontMatterBlock: container.Add(new WikiNoticeContainer(yamlFrontMatterBlock)); break; @@ -42,7 +57,7 @@ namespace osu.Game.Overlays.Wiki.Markdown public override MarkdownTextFlowContainer CreateTextFlow() => new WikiMarkdownTextFlowContainer(); - private class WikiMarkdownTextFlowContainer : OsuMarkdownTextFlowContainer + private partial class WikiMarkdownTextFlowContainer : OsuMarkdownTextFlowContainer { protected override void AddImage(LinkInline linkInline) => AddDrawable(new WikiMarkdownImage(linkInline)); } diff --git a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImage.cs b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImage.cs index 27d1fe9b2f..71c2df538d 100644 --- a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImage.cs +++ b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImage.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Markdig.Syntax.Inlines; using osu.Game.Graphics.Containers.Markdown; namespace osu.Game.Overlays.Wiki.Markdown { - public class WikiMarkdownImage : OsuMarkdownImage + public partial class WikiMarkdownImage : OsuMarkdownImage { public WikiMarkdownImage(LinkInline linkInline) : base(linkInline) diff --git a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImageBlock.cs b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImageBlock.cs index 501e00bc00..641c6242b6 100644 --- a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImageBlock.cs +++ b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImageBlock.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Markdig.Syntax.Inlines; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osuTK; namespace osu.Game.Overlays.Wiki.Markdown { - public class WikiMarkdownImageBlock : FillFlowContainer + public partial class WikiMarkdownImageBlock : FillFlowContainer { [Resolved] private IMarkdownTextFlowComponent parentFlowComponent { get; set; } @@ -47,7 +49,7 @@ namespace osu.Game.Overlays.Wiki.Markdown textFlow.AddText(linkInline.Title); } - private class BlockMarkdownImage : WikiMarkdownImage + private partial class BlockMarkdownImage : WikiMarkdownImage { public BlockMarkdownImage(LinkInline linkInline) : base(linkInline) @@ -58,7 +60,7 @@ namespace osu.Game.Overlays.Wiki.Markdown protected override ImageContainer CreateImageContainer(string url) => new BlockImageContainer(url); - private class BlockImageContainer : ImageContainer + private partial class BlockImageContainer : ImageContainer { public BlockImageContainer(string url) : base(url) @@ -69,7 +71,7 @@ namespace osu.Game.Overlays.Wiki.Markdown protected override Sprite CreateImageSprite() => new ImageSprite(); - private class ImageSprite : Sprite + private partial class ImageSprite : Sprite { public ImageSprite() { diff --git a/osu.Game/Overlays/Wiki/Markdown/WikiNoticeContainer.cs b/osu.Game/Overlays/Wiki/Markdown/WikiNoticeContainer.cs index 11cab80a57..a40bd14878 100644 --- a/osu.Game/Overlays/Wiki/Markdown/WikiNoticeContainer.cs +++ b/osu.Game/Overlays/Wiki/Markdown/WikiNoticeContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Markdig.Extensions.Yaml; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -10,31 +12,38 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Resources.Localisation.Web; +using osuTK; namespace osu.Game.Overlays.Wiki.Markdown { - public class WikiNoticeContainer : FillFlowContainer + public partial class WikiNoticeContainer : FillFlowContainer { private readonly bool isOutdated; private readonly bool needsCleanup; + private readonly bool isStub; public WikiNoticeContainer(YamlFrontMatterBlock yamlFrontMatterBlock) { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; Direction = FillDirection.Vertical; + Spacing = new Vector2(10); foreach (object line in yamlFrontMatterBlock.Lines) { switch (line.ToString()) { - case "outdated: true": + case @"outdated: true": isOutdated = true; break; - case "needs_cleanup: true": + case @"needs_cleanup: true": needsCleanup = true; break; + + case @"stub: true": + isStub = true; + break; } } } @@ -58,9 +67,17 @@ namespace osu.Game.Overlays.Wiki.Markdown Text = WikiStrings.ShowNeedsCleanupOrRewrite, }); } + + if (isStub) + { + Add(new NoticeBox + { + Text = WikiStrings.ShowStub, + }); + } } - private class NoticeBox : Container + private partial class NoticeBox : Container { [Resolved] private IMarkdownTextFlowComponent parentFlowComponent { get; set; } diff --git a/osu.Game/Overlays/Wiki/WikiArticlePage.cs b/osu.Game/Overlays/Wiki/WikiArticlePage.cs index 0061bff8ea..342a395871 100644 --- a/osu.Game/Overlays/Wiki/WikiArticlePage.cs +++ b/osu.Game/Overlays/Wiki/WikiArticlePage.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using Markdig.Syntax; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osu.Game.Overlays.Wiki.Markdown; namespace osu.Game.Overlays.Wiki { - public class WikiArticlePage : CompositeDrawable + public partial class WikiArticlePage : CompositeDrawable { public Container SidebarContainer { get; } @@ -54,7 +56,7 @@ namespace osu.Game.Overlays.Wiki { Vertical = 20, Left = 30, - Right = 50, + Right = WaveOverlayContainer.HORIZONTAL_PADDING, }, OnAddHeading = sidebar.AddEntry, } @@ -63,7 +65,7 @@ namespace osu.Game.Overlays.Wiki }; } - private class ArticleMarkdownContainer : WikiMarkdownContainer + private partial class ArticleMarkdownContainer : WikiMarkdownContainer { public Action OnAddHeading; diff --git a/osu.Game/Overlays/Wiki/WikiHeader.cs b/osu.Game/Overlays/Wiki/WikiHeader.cs index 3e81d2cffe..9317813fc4 100644 --- a/osu.Game/Overlays/Wiki/WikiHeader.cs +++ b/osu.Game/Overlays/Wiki/WikiHeader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using osu.Framework.Bindables; @@ -12,7 +14,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Wiki { - public class WikiHeader : BreadcrumbControlOverlayHeader + public partial class WikiHeader : BreadcrumbControlOverlayHeader { private const string index_path = "Main_Page"; @@ -73,7 +75,7 @@ namespace osu.Game.Overlays.Wiki protected override OverlayTitle CreateTitle() => new WikiHeaderTitle(); - private class WikiHeaderTitle : OverlayTitle + private partial class WikiHeaderTitle : OverlayTitle { public WikiHeaderTitle() { diff --git a/osu.Game/Overlays/Wiki/WikiMainPage.cs b/osu.Game/Overlays/Wiki/WikiMainPage.cs index 9416ec77f1..457309ae44 100644 --- a/osu.Game/Overlays/Wiki/WikiMainPage.cs +++ b/osu.Game/Overlays/Wiki/WikiMainPage.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -13,7 +15,7 @@ using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.Wiki { - public class WikiMainPage : FillFlowContainer + public partial class WikiMainPage : FillFlowContainer { public string Markdown; diff --git a/osu.Game/Overlays/Wiki/WikiPanelContainer.cs b/osu.Game/Overlays/Wiki/WikiPanelContainer.cs index 7e7e005586..ef31e9cfdd 100644 --- a/osu.Game/Overlays/Wiki/WikiPanelContainer.cs +++ b/osu.Game/Overlays/Wiki/WikiPanelContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using Markdig.Syntax; using osu.Framework.Allocation; @@ -20,7 +22,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Wiki { - public class WikiPanelContainer : Container + public partial class WikiPanelContainer : Container { private WikiPanelMarkdownContainer panelContainer; @@ -76,7 +78,7 @@ namespace osu.Game.Overlays.Wiki Height = Math.Max(panelContainer.Height, Parent.DrawHeight); } - private class WikiPanelMarkdownContainer : WikiMarkdownContainer + private partial class WikiPanelMarkdownContainer : WikiMarkdownContainer { private readonly bool isFullWidth; @@ -102,7 +104,7 @@ namespace osu.Game.Overlays.Wiki }; } - private class WikiPanelHeading : OsuMarkdownHeading + private partial class WikiPanelHeading : OsuMarkdownHeading { public bool IsFullWidth; diff --git a/osu.Game/Overlays/Wiki/WikiSidebar.cs b/osu.Game/Overlays/Wiki/WikiSidebar.cs index da96885fb5..4c8fe83703 100644 --- a/osu.Game/Overlays/Wiki/WikiSidebar.cs +++ b/osu.Game/Overlays/Wiki/WikiSidebar.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Markdig.Syntax; using Markdig.Syntax.Inlines; using osu.Framework.Extensions.LocalisationExtensions; @@ -13,7 +15,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Wiki { - public class WikiSidebar : OverlaySidebar + public partial class WikiSidebar : OverlaySidebar { private WikiTableOfContents tableOfContents; diff --git a/osu.Game/Overlays/Wiki/WikiTableOfContents.cs b/osu.Game/Overlays/Wiki/WikiTableOfContents.cs index c0615dce1f..5566233c39 100644 --- a/osu.Game/Overlays/Wiki/WikiTableOfContents.cs +++ b/osu.Game/Overlays/Wiki/WikiTableOfContents.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.Wiki { - public class WikiTableOfContents : CompositeDrawable + public partial class WikiTableOfContents : CompositeDrawable { private readonly FillFlowContainer content; @@ -52,7 +54,7 @@ namespace osu.Game.Overlays.Wiki content.Add(lastMainTitle = entry.With(d => d.Margin = new MarginPadding { Bottom = 5 })); } - private class TableOfContentsEntry : OsuHoverContainer + private partial class TableOfContentsEntry : OsuHoverContainer { private readonly MarkdownHeading target; diff --git a/osu.Game/Overlays/WikiOverlay.cs b/osu.Game/Overlays/WikiOverlay.cs index 4015d8e196..2444aa4fa2 100644 --- a/osu.Game/Overlays/WikiOverlay.cs +++ b/osu.Game/Overlays/WikiOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using System.Threading; @@ -15,10 +17,12 @@ using osu.Game.Overlays.Wiki; namespace osu.Game.Overlays { - public class WikiOverlay : OnlineOverlay + public partial class WikiOverlay : OnlineOverlay { private const string index_path = @"main_page"; + public string CurrentPath => path.Value; + private readonly Bindable path = new Bindable(index_path); private readonly Bindable wikiData = new Bindable(); @@ -98,6 +102,14 @@ namespace osu.Game.Overlays private void onPathChanged(ValueChangedEvent e) { + // the path could change as a result of redirecting to a newer location of the same page. + // we already have the correct wiki data, so we can safely return here. + if (e.NewValue == wikiData.Value?.Path) + return; + + if (e.NewValue == "error") + return; + cancellationToken?.Cancel(); request?.Cancel(); @@ -111,7 +123,11 @@ namespace osu.Game.Overlays Loading.Show(); request.Success += response => Schedule(() => onSuccess(response)); - request.Failure += _ => Schedule(onFail); + request.Failure += ex => + { + if (ex is not OperationCanceledException) + Schedule(onFail, request.Path); + }; api.PerformAsync(request); } @@ -119,6 +135,7 @@ namespace osu.Game.Overlays private void onSuccess(APIWikiPage response) { wikiData.Value = response; + path.Value = response.Path; if (response.Layout == index_path) { @@ -128,7 +145,7 @@ namespace osu.Game.Overlays Padding = new MarginPadding { Vertical = 20, - Horizontal = 50, + Horizontal = HORIZONTAL_PADDING, }, }); } @@ -138,10 +155,11 @@ namespace osu.Game.Overlays } } - private void onFail() + private void onFail(string originalPath) { + path.Value = "error"; LoadDisplay(articlePage = new WikiArticlePage($@"{api.WebsiteRootUrl}/wiki/", - $"Something went wrong when trying to fetch page \"{path.Value}\".\n\n[Return to the main page](Main_Page).")); + $"Something went wrong when trying to fetch page \"{originalPath}\".\n\n[Return to the main page](Main_Page).")); } private void showParentPage() diff --git a/osu.Game/PerformFromMenuRunner.cs b/osu.Game/PerformFromMenuRunner.cs index ae9879fb5a..21beadf366 100644 --- a/osu.Game/PerformFromMenuRunner.cs +++ b/osu.Game/PerformFromMenuRunner.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -16,7 +18,7 @@ using osu.Game.Screens.Menu; namespace osu.Game { - internal class PerformFromMenuRunner : Component + internal partial class PerformFromMenuRunner : Component { private readonly Action finalAction; private readonly Type[] validScreens; @@ -87,6 +89,10 @@ namespace osu.Game // check if we are already at a valid target screen. if (validScreens.Any(t => t.IsAssignableFrom(type))) { + if (!((Drawable)current).IsLoaded) + // wait until screen is loaded before invoking action. + return true; + finalAction(current); Cancel(); return true; diff --git a/osu.Game/Performance/HighPerformanceSession.cs b/osu.Game/Performance/HighPerformanceSession.cs index 3ef0e0bf93..c113e7a342 100644 --- a/osu.Game/Performance/HighPerformanceSession.cs +++ b/osu.Game/Performance/HighPerformanceSession.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -8,7 +10,7 @@ using osu.Game.Screens.Play; namespace osu.Game.Performance { - public class HighPerformanceSession : Component + public partial class HighPerformanceSession : Component { private readonly IBindable localUserPlaying = new Bindable(); diff --git a/osu.Game/Properties/AssemblyInfo.cs b/osu.Game/Properties/AssemblyInfo.cs index 1b77e45891..dde1af6461 100644 --- a/osu.Game/Properties/AssemblyInfo.cs +++ b/osu.Game/Properties/AssemblyInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Runtime.CompilerServices; // We publish our internal attributes to other sub-projects of the framework. diff --git a/osu.Game/Replays/Legacy/LegacyReplayFrame.cs b/osu.Game/Replays/Legacy/LegacyReplayFrame.cs index f6abf259e8..f345504ca1 100644 --- a/osu.Game/Replays/Legacy/LegacyReplayFrame.cs +++ b/osu.Game/Replays/Legacy/LegacyReplayFrame.cs @@ -46,6 +46,10 @@ namespace osu.Game.Replays.Legacy [IgnoreMember] public bool MouseRight2 => ButtonState.HasFlagFast(ReplayButtonState.Right2); + [JsonIgnore] + [IgnoreMember] + public bool Smoke => ButtonState.HasFlagFast(ReplayButtonState.Smoke); + [Key(3)] public ReplayButtonState ButtonState; diff --git a/osu.Game/Rulesets/AssemblyRulesetStore.cs b/osu.Game/Rulesets/AssemblyRulesetStore.cs index 7313a77aa5..03554ef2db 100644 --- a/osu.Game/Rulesets/AssemblyRulesetStore.cs +++ b/osu.Game/Rulesets/AssemblyRulesetStore.cs @@ -7,8 +7,6 @@ using System.Linq; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Platform; -#nullable enable - namespace osu.Game.Rulesets { /// diff --git a/osu.Game/Rulesets/Configuration/IRulesetConfigManager.cs b/osu.Game/Rulesets/Configuration/IRulesetConfigManager.cs index 5a3ad5e786..af315bfb28 100644 --- a/osu.Game/Rulesets/Configuration/IRulesetConfigManager.cs +++ b/osu.Game/Rulesets/Configuration/IRulesetConfigManager.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Configuration.Tracking; diff --git a/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs b/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs index 30bb95ba72..0eea1ff215 100644 --- a/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs +++ b/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs @@ -1,11 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Configuration; +using osu.Framework.Extensions; using osu.Game.Configuration; using osu.Game.Database; @@ -56,13 +60,16 @@ namespace osu.Game.Rulesets.Configuration pendingWrites.Clear(); } + if (!changed.Any()) + return true; + realm?.Write(r => { foreach (var c in changed) { var setting = r.All().First(s => s.RulesetName == rulesetName && s.Variant == variant && s.Key == c.ToString()); - setting.Value = ConfigStore[c].ToString(); + setting.Value = ConfigStore[c].ToString(CultureInfo.InvariantCulture); } }); @@ -84,7 +91,7 @@ namespace osu.Game.Rulesets.Configuration setting = new RealmRulesetSetting { Key = lookup.ToString(), - Value = bindable.Value.ToString(), + Value = bindable.ToString(CultureInfo.InvariantCulture), RulesetName = rulesetName, Variant = variant, }; @@ -94,7 +101,7 @@ namespace osu.Game.Rulesets.Configuration databasedSettings.Add(setting); } - bindable.ValueChanged += b => + bindable.ValueChanged += _ => { lock (pendingWrites) pendingWrites.Add(lookup); diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs index ec3d22b67a..bd45482235 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -1,9 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Difficulty @@ -24,11 +26,12 @@ namespace osu.Game.Rulesets.Difficulty protected const int ATTRIB_ID_SCORE_MULTIPLIER = 15; protected const int ATTRIB_ID_FLASHLIGHT = 17; protected const int ATTRIB_ID_SLIDER_FACTOR = 19; + protected const int ATTRIB_ID_SPEED_NOTE_COUNT = 21; /// /// The mods which were applied to the beatmap. /// - public Mod[] Mods { get; set; } + public Mod[] Mods { get; set; } = Array.Empty(); /// /// The combined star rating of all skills. @@ -72,7 +75,8 @@ namespace osu.Game.Rulesets.Difficulty /// Reads osu-web database attribute mappings into this object. /// /// The attribute mappings. - public virtual void FromDatabaseAttributes(IReadOnlyDictionary values) + /// The where more information about the beatmap may be extracted from (such as AR/CS/OD/etc). + public virtual void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo) { } } diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index b5aec0d659..8dd1b51cae 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -32,6 +34,11 @@ namespace osu.Game.Rulesets.Difficulty private readonly IRulesetInfo ruleset; private readonly IWorkingBeatmap beatmap; + /// + /// A yymmdd version which is used to discern when reprocessing is required. + /// + public virtual int Version => 0; + protected DifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) { this.ruleset = ruleset; @@ -67,7 +74,7 @@ namespace osu.Game.Rulesets.Difficulty foreach (var skill in skills) { cancellationToken.ThrowIfCancellationRequested(); - skill.ProcessInternal(hitObject); + skill.Process(hitObject); } } @@ -108,7 +115,7 @@ namespace osu.Game.Rulesets.Difficulty foreach (var skill in skills) { cancellationToken.ThrowIfCancellationRequested(); - skill.ProcessInternal(hitObject); + skill.Process(hitObject); } attribs.Add(new TimedDifficultyAttributes(hitObject.EndTime * clockRate, CreateDifficultyAttributes(progressiveBeatmap, playableMods, skills, clockRate))); diff --git a/osu.Game/Rulesets/Difficulty/PerformanceAttributes.cs b/osu.Game/Rulesets/Difficulty/PerformanceAttributes.cs index e8c4c71913..15b90e5147 100644 --- a/osu.Game/Rulesets/Difficulty/PerformanceAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/PerformanceAttributes.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using Newtonsoft.Json; diff --git a/osu.Game/Rulesets/Difficulty/PerformanceBreakdown.cs b/osu.Game/Rulesets/Difficulty/PerformanceBreakdown.cs index 273d8613c5..bd971db476 100644 --- a/osu.Game/Rulesets/Difficulty/PerformanceBreakdown.cs +++ b/osu.Game/Rulesets/Difficulty/PerformanceBreakdown.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Difficulty { /// diff --git a/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs b/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs index 1e5dda253f..4f802a22a1 100644 --- a/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using System.Threading; @@ -64,7 +66,7 @@ namespace osu.Game.Rulesets.Difficulty // calculate total score ScoreProcessor scoreProcessor = ruleset.CreateScoreProcessor(); scoreProcessor.Mods.Value = perfectPlay.Mods; - perfectPlay.TotalScore = (long)scoreProcessor.ComputeFinalScore(ScoringMode.Standardised, perfectPlay); + perfectPlay.TotalScore = scoreProcessor.ComputeScore(ScoringMode.Standardised, perfectPlay); // compute rank achieved // default to SS, then adjust the rank with mods diff --git a/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs b/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs index 4c55249661..38a35ddb3b 100644 --- a/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Beatmaps; using osu.Game.Scoring; diff --git a/osu.Game/Rulesets/Difficulty/PerformanceDisplayAttribute.cs b/osu.Game/Rulesets/Difficulty/PerformanceDisplayAttribute.cs index 7958bc174e..76dfca3db7 100644 --- a/osu.Game/Rulesets/Difficulty/PerformanceDisplayAttribute.cs +++ b/osu.Game/Rulesets/Difficulty/PerformanceDisplayAttribute.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Difficulty { /// diff --git a/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs index 5edfb2207b..9ce0906dea 100644 --- a/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs +++ b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs @@ -1,6 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System.Collections.Generic; +using System.Linq; using osu.Game.Rulesets.Objects; namespace osu.Game.Rulesets.Difficulty.Preprocessing @@ -10,6 +14,13 @@ namespace osu.Game.Rulesets.Difficulty.Preprocessing /// public class DifficultyHitObject { + private readonly IReadOnlyList difficultyHitObjects; + + /// + /// The index of this in the list of all s. + /// + public int Index; + /// /// The this wraps. /// @@ -41,13 +52,21 @@ namespace osu.Game.Rulesets.Difficulty.Preprocessing /// The which this wraps. /// The last which occurs before in the beatmap. /// The rate at which the gameplay clock is run at. - public DifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate) + /// The list of s in the current beatmap. + /// The index of this in list. + public DifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List objects, int index) { + difficultyHitObjects = objects; + Index = index; BaseObject = hitObject; LastObject = lastObject; DeltaTime = (hitObject.StartTime - lastObject.StartTime) / clockRate; StartTime = hitObject.StartTime / clockRate; EndTime = hitObject.GetEndTime() / clockRate; } + + public DifficultyHitObject Previous(int backwardsIndex) => difficultyHitObjects.ElementAtOrDefault(Index - (backwardsIndex + 1)); + + public DifficultyHitObject Next(int forwardsIndex) => difficultyHitObjects.ElementAtOrDefault(Index + (forwardsIndex + 1)); } } diff --git a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs index b5c94ad806..44abbaaf41 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs @@ -1,9 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Game.Rulesets.Difficulty.Preprocessing; -using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Difficulty.Skills @@ -12,21 +13,10 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// A bare minimal abstract skill for fully custom skill implementations. /// /// - /// This class should be considered a "processing" class and not persisted, as it keeps references to - /// gameplay objects after processing is run (see ). + /// This class should be considered a "processing" class and not persisted. /// public abstract class Skill { - /// - /// s that were processed previously. They can affect the strain values of the following objects. - /// - protected readonly ReverseQueue Previous; - - /// - /// Number of previous s to keep inside the queue. - /// - protected virtual int HistoryLength => 1; - /// /// Mods for use in skill calculations. /// @@ -37,24 +27,13 @@ namespace osu.Game.Rulesets.Difficulty.Skills protected Skill(Mod[] mods) { this.mods = mods; - Previous = new ReverseQueue(HistoryLength + 1); - } - - internal void ProcessInternal(DifficultyHitObject current) - { - while (Previous.Count > HistoryLength) - Previous.Dequeue(); - - Process(current); - - Previous.Enqueue(current); } /// /// Process a . /// /// The to process. - protected abstract void Process(DifficultyHitObject current); + public abstract void Process(DifficultyHitObject current); /// /// Returns the calculated difficulty value representing all s that have been processed up to this point. diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs index d8babf2f32..6abde64eb7 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/StrainDecaySkill.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Mods; @@ -34,7 +36,7 @@ namespace osu.Game.Rulesets.Difficulty.Skills { } - protected override double CalculateInitialStrain(double time) => CurrentStrain * strainDecay(time - Previous[0].StartTime); + protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => CurrentStrain * strainDecay(time - current.Previous(0).StartTime); protected override double StrainValueAt(DifficultyHitObject current) { diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs index 97266562e4..4beba22e05 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -44,16 +46,16 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// /// Process a and update current strain values accordingly. /// - protected sealed override void Process(DifficultyHitObject current) + public sealed override void Process(DifficultyHitObject current) { // The first object doesn't generate a strain, so we begin with an incremented section end - if (Previous.Count == 0) + if (current.Index == 0) currentSectionEnd = Math.Ceiling(current.StartTime / SectionLength) * SectionLength; while (current.StartTime > currentSectionEnd) { saveCurrentPeak(); - startNewSectionFrom(currentSectionEnd); + startNewSectionFrom(currentSectionEnd, current); currentSectionEnd += SectionLength; } @@ -72,19 +74,21 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// Sets the initial strain level for a new section. /// /// The beginning of the new section in milliseconds. - private void startNewSectionFrom(double time) + /// The current hit object. + private void startNewSectionFrom(double time, DifficultyHitObject current) { // The maximum strain of the new section is not zero by default // This means we need to capture the strain level at the beginning of the new section, and use that as the initial peak level. - currentSectionPeak = CalculateInitialStrain(time); + currentSectionPeak = CalculateInitialStrain(time, current); } /// /// Retrieves the peak strain at a point in time. /// /// The time to retrieve the peak strain at. + /// The current hit object. /// The peak strain. - protected abstract double CalculateInitialStrain(double time); + protected abstract double CalculateInitialStrain(double time, DifficultyHitObject current); /// /// Returns a live enumerable of the peak strains for each section of the beatmap, diff --git a/osu.Game/Rulesets/Difficulty/TimedDifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/TimedDifficultyAttributes.cs index c07d1cd46e..a07827d50b 100644 --- a/osu.Game/Rulesets/Difficulty/TimedDifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/TimedDifficultyAttributes.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; namespace osu.Game.Rulesets.Difficulty diff --git a/osu.Game/Rulesets/Difficulty/Utils/ReverseQueue.cs b/osu.Game/Rulesets/Difficulty/Utils/ReverseQueue.cs index 57db9df3ca..3dc2d133ba 100644 --- a/osu.Game/Rulesets/Difficulty/Utils/ReverseQueue.cs +++ b/osu.Game/Rulesets/Difficulty/Utils/ReverseQueue.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections; using System.Collections.Generic; diff --git a/osu.Game/Rulesets/EFRulesetInfo.cs b/osu.Game/Rulesets/EFRulesetInfo.cs deleted file mode 100644 index 4174aa773c..0000000000 --- a/osu.Game/Rulesets/EFRulesetInfo.cs +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.ComponentModel.DataAnnotations.Schema; -using System.Diagnostics.CodeAnalysis; -using Newtonsoft.Json; -using osu.Framework.Testing; - -namespace osu.Game.Rulesets -{ - [ExcludeFromDynamicCompile] - [Table(@"RulesetInfo")] - public sealed class EFRulesetInfo : IEquatable, IComparable, IRulesetInfo - { - public int? ID { get; set; } - - public string Name { get; set; } - - public string ShortName { get; set; } - - public string InstantiationInfo { get; set; } - - [JsonIgnore] - public bool Available { get; set; } - - // TODO: this should probably be moved to RulesetStore. - public Ruleset CreateInstance() - { - if (!Available) - return null; - - var type = Type.GetType(InstantiationInfo); - - if (type == null) - return null; - - var ruleset = Activator.CreateInstance(type) as Ruleset; - - return ruleset; - } - - public bool Equals(EFRulesetInfo other) => other != null && ID == other.ID && Available == other.Available && Name == other.Name && InstantiationInfo == other.InstantiationInfo; - - public int CompareTo(EFRulesetInfo other) => OnlineID.CompareTo(other.OnlineID); - - public int CompareTo(IRulesetInfo other) - { - if (!(other is EFRulesetInfo ruleset)) - throw new ArgumentException($@"Object is not of type {nameof(EFRulesetInfo)}.", nameof(other)); - - return CompareTo(ruleset); - } - - public override bool Equals(object obj) => obj is EFRulesetInfo rulesetInfo && Equals(rulesetInfo); - - public bool Equals(IRulesetInfo other) => other is RulesetInfo b && Equals(b); - - [SuppressMessage("ReSharper", "NonReadonlyMemberInGetHashCode")] - public override int GetHashCode() - { - unchecked - { - int hashCode = ID.HasValue ? ID.GetHashCode() : 0; - hashCode = (hashCode * 397) ^ (InstantiationInfo != null ? InstantiationInfo.GetHashCode() : 0); - hashCode = (hashCode * 397) ^ (Name != null ? Name.GetHashCode() : 0); - hashCode = (hashCode * 397) ^ Available.GetHashCode(); - return hashCode; - } - } - - public override string ToString() => Name ?? $"{Name} ({ShortName}) ID: {ID}"; - - #region Implementation of IHasOnlineID - - [NotMapped] - public int OnlineID - { - get => ID ?? -1; - set => ID = value >= 0 ? value : (int?)null; - } - - #endregion - } -} diff --git a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs index 6ed91e983a..5f5aba26bb 100644 --- a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs +++ b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Game.Rulesets.Edit.Checks; @@ -34,6 +36,9 @@ namespace osu.Game.Rulesets.Edit new CheckUnsnappedObjects(), new CheckConcurrentObjects(), new CheckZeroLengthObjects(), + + // Timing + new CheckPreviewTime(), }; public IEnumerable Run(BeatmapVerifierContext context) diff --git a/osu.Game/Rulesets/Edit/BeatmapVerifierContext.cs b/osu.Game/Rulesets/Edit/BeatmapVerifierContext.cs index 6feee82bda..53bdf3140c 100644 --- a/osu.Game/Rulesets/Edit/BeatmapVerifierContext.cs +++ b/osu.Game/Rulesets/Edit/BeatmapVerifierContext.cs @@ -3,8 +3,6 @@ using osu.Game.Beatmaps; -#nullable enable - namespace osu.Game.Rulesets.Edit { /// diff --git a/osu.Game/Rulesets/Edit/Checks/CheckAudioInVideo.cs b/osu.Game/Rulesets/Edit/Checks/CheckAudioInVideo.cs index a285979fd2..f712a7867d 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckAudioInVideo.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckAudioInVideo.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; +using osu.Game.Beatmaps; using osu.Game.IO.FileAbstraction; using osu.Game.Rulesets.Edit.Checks.Components; using osu.Game.Storyboards; @@ -42,7 +43,7 @@ namespace osu.Game.Rulesets.Edit.Checks foreach (string filename in videoPaths) { - string storagePath = beatmapSet?.GetPathForFile(filename); + string? storagePath = beatmapSet?.GetPathForFile(filename); if (storagePath == null) { diff --git a/osu.Game/Rulesets/Edit/Checks/CheckAudioPresence.cs b/osu.Game/Rulesets/Edit/Checks/CheckAudioPresence.cs index 94c48c300a..e922ddf023 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckAudioPresence.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckAudioPresence.cs @@ -10,6 +10,6 @@ namespace osu.Game.Rulesets.Edit.Checks { protected override CheckCategory Category => CheckCategory.Audio; protected override string TypeOfFile => "audio"; - protected override string GetFilename(IBeatmap beatmap) => beatmap.Metadata?.AudioFile; + protected override string? GetFilename(IBeatmap beatmap) => beatmap.Metadata?.AudioFile; } } diff --git a/osu.Game/Rulesets/Edit/Checks/CheckAudioQuality.cs b/osu.Game/Rulesets/Edit/Checks/CheckAudioQuality.cs index ec2ff68aad..daa33fb0da 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckAudioQuality.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckAudioQuality.cs @@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - string audioFile = context.Beatmap.Metadata?.AudioFile; + string? audioFile = context.Beatmap.Metadata?.AudioFile; if (string.IsNullOrEmpty(audioFile)) yield break; diff --git a/osu.Game/Rulesets/Edit/Checks/CheckBackgroundPresence.cs b/osu.Game/Rulesets/Edit/Checks/CheckBackgroundPresence.cs index 067800b409..4ca93a9807 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckBackgroundPresence.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckBackgroundPresence.cs @@ -10,6 +10,6 @@ namespace osu.Game.Rulesets.Edit.Checks { protected override CheckCategory Category => CheckCategory.Resources; protected override string TypeOfFile => "background"; - protected override string GetFilename(IBeatmap beatmap) => beatmap.Metadata?.BackgroundFile; + protected override string? GetFilename(IBeatmap beatmap) => beatmap.Metadata?.BackgroundFile; } } diff --git a/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs b/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs index 1f65752fa6..23fa28e7bc 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit.Checks.Components; namespace osu.Game.Rulesets.Edit.Checks @@ -32,7 +33,7 @@ namespace osu.Game.Rulesets.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - string backgroundFile = context.Beatmap.Metadata?.BackgroundFile; + string? backgroundFile = context.Beatmap.Metadata?.BackgroundFile; if (backgroundFile == null) yield break; @@ -48,7 +49,7 @@ namespace osu.Game.Rulesets.Edit.Checks else if (texture.Width < low_width || texture.Height < low_height) yield return new IssueTemplateLowResolution(this).Create(texture.Width, texture.Height); - string storagePath = context.Beatmap.BeatmapInfo.BeatmapSet?.GetPathForFile(backgroundFile); + string? storagePath = context.Beatmap.BeatmapInfo.BeatmapSet?.GetPathForFile(backgroundFile); using (Stream stream = context.WorkingBeatmap.GetStream(storagePath)) { diff --git a/osu.Game/Rulesets/Edit/Checks/CheckFilePresence.cs b/osu.Game/Rulesets/Edit/Checks/CheckFilePresence.cs index a1605a11d0..9a921ba808 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckFilePresence.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckFilePresence.cs @@ -11,7 +11,7 @@ namespace osu.Game.Rulesets.Edit.Checks { protected abstract CheckCategory Category { get; } protected abstract string TypeOfFile { get; } - protected abstract string GetFilename(IBeatmap beatmap); + protected abstract string? GetFilename(IBeatmap beatmap); public CheckMetadata Metadata => new CheckMetadata(Category, $"Missing {TypeOfFile}"); @@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - string filename = GetFilename(context.Beatmap); + string? filename = GetFilename(context.Beatmap); if (string.IsNullOrEmpty(filename)) { @@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Edit.Checks } // If the file is set, also make sure it still exists. - string storagePath = context.Beatmap.BeatmapInfo.BeatmapSet?.GetPathForFile(filename); + string? storagePath = context.Beatmap.BeatmapInfo.BeatmapSet?.GetPathForFile(filename); if (storagePath != null) yield break; diff --git a/osu.Game/Rulesets/Edit/Checks/CheckMutedObjects.cs b/osu.Game/Rulesets/Edit/Checks/CheckMutedObjects.cs index a4ff921b7e..5b59a81f91 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckMutedObjects.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckMutedObjects.cs @@ -57,7 +57,7 @@ namespace osu.Game.Rulesets.Edit.Checks } } - private IEnumerable getVolumeIssues(HitObject hitObject, HitObject sampledHitObject = null) + private IEnumerable getVolumeIssues(HitObject hitObject, HitObject? sampledHitObject = null) { sampledHitObject ??= hitObject; if (!sampledHitObject.Samples.Any()) @@ -72,7 +72,7 @@ namespace osu.Game.Rulesets.Edit.Checks if (edgeType == EdgeType.None) yield break; - string postfix = hitObject is IHasDuration ? edgeType.ToString().ToLower() : null; + string postfix = hitObject is IHasDuration ? edgeType.ToString().ToLowerInvariant() : string.Empty; if (maxVolume <= muted_threshold) { diff --git a/osu.Game/Rulesets/Edit/Checks/CheckPreviewTime.cs b/osu.Game/Rulesets/Edit/Checks/CheckPreviewTime.cs new file mode 100644 index 0000000000..d4f9c1feaf --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckPreviewTime.cs @@ -0,0 +1,62 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckPreviewTime : ICheck + { + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Timing, "Inconsistent or unset preview time"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplatePreviewTimeConflict(this), + new IssueTemplateHasNoPreviewTime(this), + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + var diffList = context.Beatmap.BeatmapInfo.BeatmapSet?.Beatmaps ?? new List(); + int previewTime = context.Beatmap.BeatmapInfo.Metadata.PreviewTime; + + if (previewTime == -1) + yield return new IssueTemplateHasNoPreviewTime(this).Create(); + + foreach (var diff in diffList) + { + if (diff.Equals(context.Beatmap.BeatmapInfo)) + continue; + + if (diff.Metadata.PreviewTime != previewTime) + yield return new IssueTemplatePreviewTimeConflict(this).Create(diff.DifficultyName, previewTime, diff.Metadata.PreviewTime); + } + } + + public class IssueTemplatePreviewTimeConflict : IssueTemplate + { + public IssueTemplatePreviewTimeConflict(ICheck check) + : base(check, IssueType.Problem, "Audio preview time ({1}) doesn't match the time specified in \"{0}\" ({2})") + { + } + + public Issue Create(string diffName, int originalTime, int conflictTime) => + // preview time should show (not set) when it is not set. + new Issue(this, diffName, + originalTime != -1 ? $"{originalTime:N0} ms" : "not set", + conflictTime != -1 ? $"{conflictTime:N0} ms" : "not set"); + } + + public class IssueTemplateHasNoPreviewTime : IssueTemplate + { + public IssueTemplateHasNoPreviewTime(ICheck check) + : base(check, IssueType.Problem, "A preview point for this map is not set. Consider setting one from the Timing menu.") + { + } + + public Issue Create() => new Issue(this); + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckTooShortAudioFiles.cs b/osu.Game/Rulesets/Edit/Checks/CheckTooShortAudioFiles.cs index 6015d0a1b2..1c2ea36948 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckTooShortAudioFiles.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckTooShortAudioFiles.cs @@ -63,7 +63,7 @@ namespace osu.Game.Rulesets.Edit.Checks } } - private bool hasAudioExtension(string filename) => audioExtensions.Any(filename.ToLower().EndsWith); + private bool hasAudioExtension(string filename) => audioExtensions.Any(filename.ToLowerInvariant().EndsWith); private bool probablyHasAudioData(Stream data) => data.Length > min_bytes_threshold; public class IssueTemplateTooShort : IssueTemplate diff --git a/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs b/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs index aaee15eae8..aa47b4f424 100644 --- a/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs @@ -1,14 +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.Collections.Generic; +using System.Diagnostics; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; @@ -16,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.Screens.Edit.Components.TernaryButtons; namespace osu.Game.Rulesets.Edit { @@ -23,12 +31,12 @@ namespace osu.Game.Rulesets.Edit /// Represents a for rulesets with the concept of distances between objects. /// /// The base type of supported objects. - public abstract class DistancedHitObjectComposer : HitObjectComposer, IDistanceSnapProvider, IScrollBindingHandler + public abstract partial class DistancedHitObjectComposer : HitObjectComposer, IDistanceSnapProvider, IScrollBindingHandler where TObject : HitObject { private const float adjust_step = 0.1f; - public Bindable DistanceSpacingMultiplier { get; } = new BindableDouble(1.0) + public BindableDouble DistanceSpacingMultiplier { get; } = new BindableDouble(1.0) { MinValue = 0.1, MaxValue = 6.0, @@ -37,38 +45,97 @@ namespace osu.Game.Rulesets.Edit IBindable IDistanceSnapProvider.DistanceSpacingMultiplier => DistanceSpacingMultiplier; - protected ExpandingToolboxContainer RightSideToolboxContainer { get; private set; } - private ExpandableSlider> distanceSpacingSlider; + private ExpandableButton currentDistanceSpacingButton; [Resolved(canBeNull: true)] private OnScreenDisplay onScreenDisplay { get; set; } + protected readonly Bindable DistanceSnapToggle = new Bindable(); + + private bool distanceSnapMomentary; + protected DistancedHitObjectComposer(Ruleset ruleset) : base(ruleset) { } [BackgroundDependencyLoader] - private void load() + private void load(OverlayColourProvider colourProvider) { - AddInternal(RightSideToolboxContainer = new ExpandingToolboxContainer(130, 250) + RightToolbox.Add(new EditorToolboxGroup("snapping") { - Padding = new MarginPadding(10), Alpha = DistanceSpacingMultiplier.Disabled ? 0 : 1, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Child = new EditorToolboxGroup("snapping") + Children = new Drawable[] { - Child = distanceSpacingSlider = new ExpandableSlider> + distanceSpacingSlider = new ExpandableSlider> { - Current = { BindTarget = DistanceSpacingMultiplier }, 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(); + + Debug.Assert(objects != null); + + DistanceSpacingMultiplier.Value = ReadCurrentDistanceSnap(objects.Value.before, objects.Value.after); + DistanceSnapToggle.Value = TernaryState.True; + }, + RelativeSizeAxes = Axes.X, } } }); } + private (HitObject before, HitObject after)? getObjectsOnEitherSideOfCurrentTime() + { + HitObject lastBefore = Playfield.HitObjectContainer.AliveObjects.LastOrDefault(h => h.HitObject.StartTime <= EditorClock.CurrentTime)?.HitObject; + + if (lastBefore == null) + return null; + + HitObject firstAfter = Playfield.HitObjectContainer.AliveObjects.FirstOrDefault(h => h.HitObject.StartTime >= EditorClock.CurrentTime)?.HitObject; + + if (firstAfter == null) + return null; + + if (lastBefore == firstAfter) + return null; + + return (lastBefore, firstAfter); + } + + protected abstract double ReadCurrentDistanceSnap(HitObject before, HitObject after); + + protected override void Update() + { + base.Update(); + + (HitObject before, HitObject after)? objects = getObjectsOnEitherSideOfCurrentTime(); + + double currentSnap = objects == null + ? 0 + : ReadCurrentDistanceSnap(objects.Value.before, objects.Value.after); + + if (currentSnap > DistanceSpacingMultiplier.MinValue) + { + currentDistanceSpacingButton.Enabled.Value = currentDistanceSpacingButton.Expanded.Value + && !Precision.AlmostEquals(currentSnap, DistanceSpacingMultiplier.Value, DistanceSpacingMultiplier.Precision / 2); + currentDistanceSpacingButton.ContractedLabelText = $"current {currentSnap:N2}x"; + currentDistanceSpacingButton.ExpandedLabelText = $"Use current ({currentSnap:N2}x)"; + } + else + { + currentDistanceSpacingButton.Enabled.Value = false; + currentDistanceSpacingButton.ContractedLabelText = string.Empty; + currentDistanceSpacingButton.ExpandedLabelText = "Use current (unavailable)"; + } + } + protected override void LoadComplete() { base.LoadComplete(); @@ -86,22 +153,61 @@ namespace osu.Game.Rulesets.Edit EditorBeatmap.BeatmapInfo.DistanceSpacing = multiplier.NewValue; }, true); + + // Manual binding to handle enabling distance spacing when the slider is interacted with. + distanceSpacingSlider.Current.BindValueChanged(spacing => + { + DistanceSpacingMultiplier.Value = spacing.NewValue; + DistanceSnapToggle.Value = TernaryState.True; + }); + DistanceSpacingMultiplier.BindValueChanged(spacing => distanceSpacingSlider.Current.Value = spacing.NewValue); } } - public bool OnPressed(KeyBindingPressEvent e) + protected override IEnumerable CreateTernaryButtons() => base.CreateTernaryButtons().Concat(new[] + { + new TernaryButton(DistanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler }) + }); + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Repeat) + return false; + + handleToggleViaKey(e); + return base.OnKeyDown(e); + } + + protected override void OnKeyUp(KeyUpEvent e) + { + handleToggleViaKey(e); + base.OnKeyUp(e); + } + + private void handleToggleViaKey(KeyboardEvent key) + { + bool altPressed = key.AltPressed; + + if (altPressed != distanceSnapMomentary) + { + distanceSnapMomentary = altPressed; + DistanceSnapToggle.Value = DistanceSnapToggle.Value == TernaryState.False ? TernaryState.True : TernaryState.False; + } + } + + public virtual bool OnPressed(KeyBindingPressEvent e) { switch (e.Action) { case GlobalAction.EditorIncreaseDistanceSpacing: case GlobalAction.EditorDecreaseDistanceSpacing: - return adjustDistanceSpacing(e.Action, adjust_step); + return AdjustDistanceSpacing(e.Action, adjust_step); } return false; } - public void OnReleased(KeyBindingReleaseEvent e) + public virtual void OnReleased(KeyBindingReleaseEvent e) { } @@ -111,13 +217,13 @@ namespace osu.Game.Rulesets.Edit { case GlobalAction.EditorIncreaseDistanceSpacing: case GlobalAction.EditorDecreaseDistanceSpacing: - return adjustDistanceSpacing(e.Action, e.ScrollAmount * adjust_step); + return AdjustDistanceSpacing(e.Action, e.ScrollAmount * adjust_step); } return false; } - private bool adjustDistanceSpacing(GlobalAction action, float amount) + protected virtual bool AdjustDistanceSpacing(GlobalAction action, float amount) { if (DistanceSpacingMultiplier.Disabled) return false; @@ -127,12 +233,14 @@ namespace osu.Game.Rulesets.Edit else if (action == GlobalAction.EditorDecreaseDistanceSpacing) DistanceSpacingMultiplier.Value -= amount; + DistanceSnapToggle.Value = TernaryState.True; return true; } - public virtual float GetBeatSnapDistanceAt(HitObject referenceObject) + public virtual float GetBeatSnapDistanceAt(HitObject referenceObject, bool useReferenceSliderVelocity = true) { - return (float)(100 * EditorBeatmap.Difficulty.SliderMultiplier * referenceObject.DifficultyControlPoint.SliderVelocity / BeatSnapProvider.BeatDivisor); + return (float)(100 * (useReferenceSliderVelocity ? referenceObject.DifficultyControlPoint.SliderVelocity : 1) * EditorBeatmap.Difficulty.SliderMultiplier * 1 + / BeatSnapProvider.BeatDivisor); } public virtual float DurationToDistance(HitObject referenceObject, double duration) @@ -168,7 +276,7 @@ namespace osu.Game.Rulesets.Edit return DurationToDistance(referenceObject, snappedEndTime - startTime); } - private class DistanceSpacingToast : Toast + private partial class DistanceSpacingToast : Toast { private readonly ValueChangedEvent change; diff --git a/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs b/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs index 392a5db9da..20ee409937 100644 --- a/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs +++ b/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -15,7 +17,7 @@ namespace osu.Game.Rulesets.Edit /// /// A wrapper for a . Handles adding visual representations of s to the underlying . /// - internal class DrawableEditorRulesetWrapper : CompositeDrawable + internal partial class DrawableEditorRulesetWrapper : CompositeDrawable where TObject : HitObject { public Playfield Playfield => drawableRuleset.Playfield; diff --git a/osu.Game/Rulesets/Edit/EditorToolboxGroup.cs b/osu.Game/Rulesets/Edit/EditorToolboxGroup.cs index bde426f56a..312ba62b61 100644 --- a/osu.Game/Rulesets/Edit/EditorToolboxGroup.cs +++ b/osu.Game/Rulesets/Edit/EditorToolboxGroup.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Overlays; namespace osu.Game.Rulesets.Edit { - public class EditorToolboxGroup : SettingsToolboxGroup + public partial class EditorToolboxGroup : SettingsToolboxGroup { public EditorToolboxGroup(string title) : base(title) diff --git a/osu.Game/Rulesets/Edit/ExpandableButton.cs b/osu.Game/Rulesets/Edit/ExpandableButton.cs new file mode 100644 index 0000000000..a708f76845 --- /dev/null +++ b/osu.Game/Rulesets/Edit/ExpandableButton.cs @@ -0,0 +1,103 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Rulesets.Edit +{ + internal partial class ExpandableButton : RoundedButton, IExpandable + { + private float actualHeight; + + public override float Height + { + get => base.Height; + set => base.Height = actualHeight = value; + } + + private LocalisableString contractedLabelText; + + /// + /// The label text to display when this button is in a contracted state. + /// + public LocalisableString ContractedLabelText + { + get => contractedLabelText; + set + { + if (value == contractedLabelText) + return; + + contractedLabelText = value; + + if (!Expanded.Value) + Text = value; + } + } + + private LocalisableString expandedLabelText; + + /// + /// The label text to display when this button is in an expanded state. + /// + public LocalisableString ExpandedLabelText + { + get => expandedLabelText; + set + { + if (value == expandedLabelText) + return; + + expandedLabelText = value; + + if (Expanded.Value) + Text = value; + } + } + + public BindableBool Expanded { get; } = new BindableBool(); + + [Resolved(canBeNull: true)] + private IExpandingContainer? expandingContainer { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + expandingContainer?.Expanded.BindValueChanged(containerExpanded => + { + Expanded.Value = containerExpanded.NewValue; + }, true); + + Expanded.BindValueChanged(expanded => + { + Text = expanded.NewValue ? expandedLabelText : contractedLabelText; + + if (expanded.NewValue) + { + SpriteText.Anchor = Anchor.Centre; + SpriteText.Origin = Anchor.Centre; + SpriteText.Font = OsuFont.GetFont(weight: FontWeight.Bold); + base.Height = actualHeight; + Background.Show(); + Triangles?.Show(); + } + else + { + SpriteText.Anchor = Anchor.CentreLeft; + SpriteText.Origin = Anchor.CentreLeft; + SpriteText.Font = OsuFont.GetFont(weight: FontWeight.Regular); + base.Height = actualHeight / 2; + Background.Hide(); + Triangles?.Hide(); + } + }, true); + } + } +} diff --git a/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs index c6cc09a16c..7bf10f6beb 100644 --- a/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs +++ b/osu.Game/Rulesets/Edit/ExpandingToolboxContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; @@ -8,7 +10,7 @@ using osuTK; namespace osu.Game.Rulesets.Edit { - public class ExpandingToolboxContainer : ExpandingContainer + public partial class ExpandingToolboxContainer : ExpandingContainer { protected override double HoverExpansionDelay => 250; @@ -17,7 +19,8 @@ namespace osu.Game.Rulesets.Edit { RelativeSizeAxes = Axes.Y; - FillFlow.Spacing = new Vector2(10); + FillFlow.Spacing = new Vector2(5); + Padding = new MarginPadding { Vertical = 5 }; } protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => base.ReceivePositionalInputAtSubTree(screenSpacePos) && anyToolboxHovered(screenSpacePos); diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index f6fdb228ce..653861c11c 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Collections.Specialized; @@ -10,10 +12,13 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Overlays; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mods; @@ -36,13 +41,11 @@ namespace osu.Game.Rulesets.Edit /// Responsible for providing snapping and generally gluing components together. /// /// The base type of supported objects. - public abstract class HitObjectComposer : HitObjectComposer, IPlacementHandler + public abstract partial class HitObjectComposer : HitObjectComposer, IPlacementHandler where TObject : HitObject { protected IRulesetConfigManager Config { get; private set; } - protected readonly Ruleset Ruleset; - // Provides `Playfield` private DependencyContainer dependencies; @@ -55,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 }; @@ -68,18 +78,21 @@ namespace osu.Game.Rulesets.Edit private FillFlowContainer togglesCollection; private IBindable hasTiming; + private Bindable autoSeekOnPlacement; protected HitObjectComposer(Ruleset ruleset) + : base(ruleset) { - Ruleset = ruleset; } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); [BackgroundDependencyLoader] - private void load() + private void load(OsuConfigManager config) { + autoSeekOnPlacement = config.GetBindable(OsuSetting.EditorAutoSeekOnPlacement); + Config = Dependencies.Get().GetConfigFor(Ruleset); try @@ -100,7 +113,7 @@ namespace osu.Game.Rulesets.Edit InternalChildren = new Drawable[] { - new Container + PlayfieldContentContainer = new Container { Name = "Content", RelativeSizeAxes = Axes.Both, @@ -114,27 +127,61 @@ namespace osu.Game.Rulesets.Edit .WithChild(BlueprintContainer = CreateBlueprintContainer()) } }, - new ExpandingToolboxContainer(90, 200) + new Container { - Padding = new MarginPadding(10), + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, Children = new Drawable[] { - new EditorToolboxGroup("toolbox (1-9)") + new Box { - Child = toolboxCollection = new EditorRadioButtonCollection { RelativeSizeAxes = Axes.X } + Colour = colourProvider.Background5, + RelativeSizeAxes = Axes.Both, }, - new EditorToolboxGroup("toggles (Q~P)") + LeftToolbox = new ExpandingToolboxContainer(60, 200) { - Child = togglesCollection = new FillFlowContainer + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 5), + new EditorToolboxGroup("toolbox (1-9)") + { + Child = toolboxCollection = new EditorRadioButtonCollection { RelativeSizeAxes = Axes.X } + }, + new EditorToolboxGroup("toggles (Q~P)") + { + Child = togglesCollection = 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(130, 250) + { + Child = new EditorToolboxGroup("inspector") + { + Child = new HitObjectInspector() }, } } - }, + } }; toolboxCollection.Items = CompositionTools @@ -150,6 +197,15 @@ namespace osu.Game.Rulesets.Edit EditorBeatmap.SelectedHitObjects.CollectionChanged += selectionChanged; } + /// + /// Houses all content relevant to the playfield. + /// + /// + /// Generally implementations should not be adding to this directly. + /// Use or instead. + /// + protected Container PlayfieldContentContainer { get; private set; } + protected override void LoadComplete() { base.LoadComplete(); @@ -213,7 +269,7 @@ namespace osu.Game.Rulesets.Edit protected override bool OnKeyDown(KeyDownEvent e) { - if (e.ControlPressed || e.AltPressed || e.SuperPressed) + if (e.ControlPressed || e.AltPressed || e.SuperPressed || e.ShiftPressed) return false; if (checkLeftToggleFromKey(e.Key, out int leftIndex)) @@ -342,7 +398,7 @@ namespace osu.Game.Rulesets.Edit { EditorBeatmap.Add(hitObject); - if (EditorClock.CurrentTime < hitObject.StartTime) + if (autoSeekOnPlacement.Value && EditorClock.CurrentTime < hitObject.StartTime) EditorClock.SeekSmoothlyTo(hitObject.StartTime); } } @@ -392,10 +448,13 @@ namespace osu.Game.Rulesets.Edit /// Generally used to access certain methods without requiring a generic type for . /// [Cached] - public abstract class HitObjectComposer : CompositeDrawable, IPositionSnapProvider + public abstract partial class HitObjectComposer : CompositeDrawable, IPositionSnapProvider { - protected HitObjectComposer() + public readonly Ruleset Ruleset; + + protected HitObjectComposer(Ruleset ruleset) { + Ruleset = ruleset; RelativeSizeAxes = Axes.Both; } diff --git a/osu.Game/Rulesets/Edit/HitObjectInspector.cs b/osu.Game/Rulesets/Edit/HitObjectInspector.cs new file mode 100644 index 0000000000..977d00ede2 --- /dev/null +++ b/osu.Game/Rulesets/Edit/HitObjectInspector.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 osu.Framework.Allocation; +using osu.Framework.Extensions.TypeExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Threading; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Overlays; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Screens.Edit; + +namespace osu.Game.Rulesets.Edit +{ + internal partial class HitObjectInspector : CompositeDrawable + { + private 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 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 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; + } + + 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; + }); + + 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/Rulesets/Edit/HitObjectSelectionBlueprint.cs b/osu.Game/Rulesets/Edit/HitObjectSelectionBlueprint.cs index 77dc55c6ef..93b889792b 100644 --- a/osu.Game/Rulesets/Edit/HitObjectSelectionBlueprint.cs +++ b/osu.Game/Rulesets/Edit/HitObjectSelectionBlueprint.cs @@ -1,7 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics.Primitives; +using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -9,7 +14,7 @@ using osuTK; namespace osu.Game.Rulesets.Edit { - public abstract class HitObjectSelectionBlueprint : SelectionBlueprint + public abstract partial class HitObjectSelectionBlueprint : SelectionBlueprint { /// /// The which this applies to. @@ -21,6 +26,11 @@ namespace osu.Game.Rulesets.Edit /// protected virtual bool AlwaysShowWhenSelected => false; + /// + /// Whether extra animations should be shown to convey hit position / state in addition to gameplay animations. + /// + protected Bindable ShowHitMarkers { get; private set; } + protected override bool ShouldBeAlive => (DrawableObject?.IsAlive == true && DrawableObject.IsPresent) || (AlwaysShowWhenSelected && State == SelectionState.Selected); protected HitObjectSelectionBlueprint(HitObject hitObject) @@ -28,6 +38,12 @@ namespace osu.Game.Rulesets.Edit { } + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + ShowHitMarkers = config.GetBindable(OsuSetting.EditorShowHitMarkers); + } + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => DrawableObject.ReceivePositionalInputAt(screenSpacePos); public override Vector2 ScreenSpaceSelectionPoint => DrawableObject.ScreenSpaceDrawQuad.Centre; @@ -35,7 +51,7 @@ namespace osu.Game.Rulesets.Edit public override Quad SelectionQuad => DrawableObject.ScreenSpaceDrawQuad; } - public abstract class HitObjectSelectionBlueprint : HitObjectSelectionBlueprint + public abstract partial class HitObjectSelectionBlueprint : HitObjectSelectionBlueprint where T : HitObject { public T HitObject => (T)Item; diff --git a/osu.Game/Rulesets/Edit/IBeatSnapProvider.cs b/osu.Game/Rulesets/Edit/IBeatSnapProvider.cs index 616f854cd7..5e45cefe8c 100644 --- a/osu.Game/Rulesets/Edit/IBeatSnapProvider.cs +++ b/osu.Game/Rulesets/Edit/IBeatSnapProvider.cs @@ -6,7 +6,7 @@ namespace osu.Game.Rulesets.Edit public interface IBeatSnapProvider { /// - /// Snaps a duration to the closest beat of a timing point applicable at the reference time. + /// Snaps a duration to the closest beat of a timing point applicable at the reference time, factoring in the current . /// /// The time to snap. /// An optional reference point to use for timing point lookup. @@ -14,10 +14,10 @@ namespace osu.Game.Rulesets.Edit double SnapTime(double time, double? referenceTime = null); /// - /// Get the most appropriate beat length at a given time. + /// Get the most appropriate beat length at a given time, pre-divided by . /// /// A reference time used for lookup. - /// The most appropriate beat length. + /// The most appropriate beat length, divided by . double GetBeatLengthAtTime(double referenceTime); /// diff --git a/osu.Game/Rulesets/Edit/IBeatmapVerifier.cs b/osu.Game/Rulesets/Edit/IBeatmapVerifier.cs index 06f0abedb0..826bffef5f 100644 --- a/osu.Game/Rulesets/Edit/IBeatmapVerifier.cs +++ b/osu.Game/Rulesets/Edit/IBeatmapVerifier.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Game.Rulesets.Edit.Checks.Components; diff --git a/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs b/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs index b12e1437dc..6fbd994e23 100644 --- a/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs +++ b/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Beatmaps; @@ -25,8 +27,9 @@ namespace osu.Game.Rulesets.Edit /// Retrieves the distance between two points within a timing point that are one beat length apart. /// /// An object to be used as a reference point for this operation. + /// Whether the 's slider velocity should be factored into the returned distance. /// The distance between two points residing in the timing point that are one beat length apart. - float GetBeatSnapDistanceAt(HitObject referenceObject); + float GetBeatSnapDistanceAt(HitObject referenceObject, bool useReferenceSliderVelocity = true); /// /// Converts a duration to a distance without applying any snapping. diff --git a/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs b/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs index a6a6e39e23..ad129e068d 100644 --- a/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs +++ b/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osuTK; diff --git a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs index 0c0c5990d1..f810f51027 100644 --- a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs +++ b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs @@ -1,6 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System.Linq; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -21,7 +24,7 @@ namespace osu.Game.Rulesets.Edit /// /// A blueprint which governs the creation of a new to actualisation. /// - public abstract class PlacementBlueprint : CompositeDrawable + public abstract partial class PlacementBlueprint : CompositeDrawable { /// /// Whether the is currently mid-placement, but has not necessarily finished being placed. @@ -71,6 +74,10 @@ 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; @@ -114,7 +121,7 @@ namespace osu.Game.Rulesets.Edit /// protected void ApplyDefaultsToHitObject() => HitObject.ApplyDefaults(beatmap.ControlPointInfo, beatmap.Difficulty); - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parent?.ReceivePositionalInputAt(screenSpacePos) ?? false; + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parent?.ReceivePositionalInputAt(screenSpacePos) == true; protected override bool Handle(UIEvent e) { @@ -122,10 +129,10 @@ namespace osu.Game.Rulesets.Edit switch (e) { - case ScrollEvent _: + case ScrollEvent: return false; - case DoubleClickEvent _: + case DoubleClickEvent: return false; case MouseButtonEvent mouse: diff --git a/osu.Game/Rulesets/Edit/SelectionBlueprint.cs b/osu.Game/Rulesets/Edit/SelectionBlueprint.cs index 5e5bc9036d..3c878ffd33 100644 --- a/osu.Game/Rulesets/Edit/SelectionBlueprint.cs +++ b/osu.Game/Rulesets/Edit/SelectionBlueprint.cs @@ -1,7 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; +using System.Linq; using osu.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -15,7 +18,7 @@ namespace osu.Game.Rulesets.Edit /// /// A blueprint placed above a displaying item adding editing functionality. /// - public abstract class SelectionBlueprint : CompositeDrawable, IStateful + public abstract partial class SelectionBlueprint : CompositeDrawable, IStateful { public readonly T Item; @@ -123,10 +126,21 @@ namespace osu.Game.Rulesets.Edit public virtual MenuItem[] ContextMenuItems => Array.Empty(); /// - /// The screen-space point that causes this to be selected via a drag. + /// The screen-space main point that causes this to be selected via a drag. /// public virtual Vector2 ScreenSpaceSelectionPoint => ScreenSpaceDrawQuad.Centre; + /// + /// Any points that should be used for snapping purposes in addition to . Exposed via . + /// + protected virtual Vector2[] ScreenSpaceAdditionalNodes => Array.Empty(); + + /// + /// The screen-space collection of base points on this that other objects can be snapped to. + /// The first element of this collection is + /// + public Vector2[] ScreenSpaceSnapPoints => ScreenSpaceAdditionalNodes.Prepend(ScreenSpaceSelectionPoint).ToArray(); + /// /// The screen-space quad that outlines this for selections. /// diff --git a/osu.Game/Rulesets/Edit/SnapResult.cs b/osu.Game/Rulesets/Edit/SnapResult.cs index 31dd2b9496..9a1405b97c 100644 --- a/osu.Game/Rulesets/Edit/SnapResult.cs +++ b/osu.Game/Rulesets/Edit/SnapResult.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.UI; using osuTK; diff --git a/osu.Game/Rulesets/Edit/SnapType.cs b/osu.Game/Rulesets/Edit/SnapType.cs index 6761356331..6eb46457c8 100644 --- a/osu.Game/Rulesets/Edit/SnapType.cs +++ b/osu.Game/Rulesets/Edit/SnapType.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; namespace osu.Game.Rulesets.Edit diff --git a/osu.Game/Rulesets/Edit/Tools/HitObjectCompositionTool.cs b/osu.Game/Rulesets/Edit/Tools/HitObjectCompositionTool.cs index 0a01ac4320..707645edeb 100644 --- a/osu.Game/Rulesets/Edit/Tools/HitObjectCompositionTool.cs +++ b/osu.Game/Rulesets/Edit/Tools/HitObjectCompositionTool.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; namespace osu.Game.Rulesets.Edit.Tools diff --git a/osu.Game/Rulesets/Edit/Tools/SelectTool.cs b/osu.Game/Rulesets/Edit/Tools/SelectTool.cs index c050766b23..9640830a09 100644 --- a/osu.Game/Rulesets/Edit/Tools/SelectTool.cs +++ b/osu.Game/Rulesets/Edit/Tools/SelectTool.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; diff --git a/osu.Game/Rulesets/IRulesetConfigCache.cs b/osu.Game/Rulesets/IRulesetConfigCache.cs index b946b43905..3943a62e59 100644 --- a/osu.Game/Rulesets/IRulesetConfigCache.cs +++ b/osu.Game/Rulesets/IRulesetConfigCache.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 enable - using osu.Game.Rulesets.Configuration; namespace osu.Game.Rulesets diff --git a/osu.Game/Rulesets/IRulesetInfo.cs b/osu.Game/Rulesets/IRulesetInfo.cs index 60a02212fc..5af6ef07cf 100644 --- a/osu.Game/Rulesets/IRulesetInfo.cs +++ b/osu.Game/Rulesets/IRulesetInfo.cs @@ -4,8 +4,6 @@ using System; using osu.Game.Database; -#nullable enable - namespace osu.Game.Rulesets { /// diff --git a/osu.Game/Rulesets/IRulesetStore.cs b/osu.Game/Rulesets/IRulesetStore.cs index 08d907810b..f4895118e3 100644 --- a/osu.Game/Rulesets/IRulesetStore.cs +++ b/osu.Game/Rulesets/IRulesetStore.cs @@ -3,8 +3,6 @@ using System.Collections.Generic; -#nullable enable - namespace osu.Game.Rulesets { public interface IRulesetStore diff --git a/osu.Game/Rulesets/Judgements/DefaultJudgementPiece.cs b/osu.Game/Rulesets/Judgements/DefaultJudgementPiece.cs index 29b771a81d..d5f586dc35 100644 --- a/osu.Game/Rulesets/Judgements/DefaultJudgementPiece.cs +++ b/osu.Game/Rulesets/Judgements/DefaultJudgementPiece.cs @@ -1,10 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using 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.Sprites; @@ -13,40 +10,25 @@ using osuTK; namespace osu.Game.Rulesets.Judgements { - public class DefaultJudgementPiece : CompositeDrawable, IAnimatableJudgement + public partial class DefaultJudgementPiece : JudgementPiece, IAnimatableJudgement { - protected readonly HitResult Result; - - protected SpriteText JudgementText { get; private set; } - - [Resolved] - private OsuColour colours { get; set; } - public DefaultJudgementPiece(HitResult result) - { - Result = result; - Origin = Anchor.Centre; - } - - [BackgroundDependencyLoader] - private void load() + : base(result) { AutoSizeAxes = Axes.Both; - InternalChildren = new Drawable[] - { - JudgementText = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Text = Result.GetDescription().ToUpperInvariant(), - Colour = colours.ForHitResult(Result), - Font = OsuFont.Numeric.With(size: 20), - Scale = new Vector2(0.85f, 1), - } - }; + Origin = Anchor.Centre; } + protected override SpriteText CreateJudgementText() => + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Numeric.With(size: 20), + Scale = new Vector2(0.85f, 1), + }; + /// /// Plays the default animation for this judgement piece. /// @@ -73,6 +55,6 @@ namespace osu.Game.Rulesets.Judgements this.FadeOutFromOne(800); } - public Drawable GetAboveHitObjectsProxiedContent() => null; + public Drawable? GetAboveHitObjectsProxiedContent() => null; } } diff --git a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs index d25d46c6e2..15434fcc04 100644 --- a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs +++ b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using JetBrains.Annotations; @@ -18,7 +20,7 @@ namespace osu.Game.Rulesets.Judgements /// /// A drawable object which visualises the hit result of a . /// - public class DrawableJudgement : PoolableDrawable + public partial class DrawableJudgement : PoolableDrawable { private const float judgement_size = 128; @@ -163,14 +165,10 @@ namespace osu.Game.Rulesets.Judgements // sub-classes might have added their own children that would be removed here if .InternalChild was used. if (JudgementBody != null) - RemoveInternal(JudgementBody); + RemoveInternal(JudgementBody, true); - AddInternal(JudgementBody = new SkinnableDrawable(new GameplaySkinComponent(type), _ => - CreateDefaultJudgement(type), confineMode: ConfineMode.NoScaling) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }); + AddInternal(JudgementBody = new SkinnableDrawable(new GameplaySkinComponentLookup(type), _ => + CreateDefaultJudgement(type), confineMode: ConfineMode.NoScaling)); JudgementBody.OnSkinChanged += () => { diff --git a/osu.Game/Rulesets/Judgements/IAnimatableJudgement.cs b/osu.Game/Rulesets/Judgements/IAnimatableJudgement.cs index b38b83b534..0aa337bc20 100644 --- a/osu.Game/Rulesets/Judgements/IAnimatableJudgement.cs +++ b/osu.Game/Rulesets/Judgements/IAnimatableJudgement.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 JetBrains.Annotations; using osu.Framework.Graphics; namespace osu.Game.Rulesets.Judgements @@ -19,7 +18,6 @@ namespace osu.Game.Rulesets.Judgements /// /// Get proxied content which should be displayed above all hitobjects. /// - [CanBeNull] - Drawable GetAboveHitObjectsProxiedContent(); + Drawable? GetAboveHitObjectsProxiedContent(); } } diff --git a/osu.Game/Rulesets/Judgements/IgnoreJudgement.cs b/osu.Game/Rulesets/Judgements/IgnoreJudgement.cs index d2a434058d..f08b43e72a 100644 --- a/osu.Game/Rulesets/Judgements/IgnoreJudgement.cs +++ b/osu.Game/Rulesets/Judgements/IgnoreJudgement.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Judgements diff --git a/osu.Game/Rulesets/Judgements/Judgement.cs b/osu.Game/Rulesets/Judgements/Judgement.cs index 99dce82ec2..770f656e8f 100644 --- a/osu.Game/Rulesets/Judgements/Judgement.cs +++ b/osu.Game/Rulesets/Judgements/Judgement.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; diff --git a/osu.Game/Rulesets/Judgements/JudgementPiece.cs b/osu.Game/Rulesets/Judgements/JudgementPiece.cs new file mode 100644 index 0000000000..03f211c318 --- /dev/null +++ b/osu.Game/Rulesets/Judgements/JudgementPiece.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Judgements +{ + public abstract partial class JudgementPiece : CompositeDrawable + { + protected readonly HitResult Result; + + protected SpriteText JudgementText { get; private set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + protected JudgementPiece(HitResult result) + { + Result = result; + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(JudgementText = CreateJudgementText()); + + JudgementText.Colour = colours.ForHitResult(Result); + JudgementText.Text = Result.GetDescription().ToUpperInvariant(); + } + + protected abstract SpriteText CreateJudgementText(); + } +} diff --git a/osu.Game/Rulesets/Judgements/JudgementResult.cs b/osu.Game/Rulesets/Judgements/JudgementResult.cs index e3b2501cdc..bf29919e34 100644 --- a/osu.Game/Rulesets/Judgements/JudgementResult.cs +++ b/osu.Game/Rulesets/Judgements/JudgementResult.cs @@ -1,6 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System; using JetBrains.Annotations; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -31,16 +34,30 @@ namespace osu.Game.Rulesets.Judgements public readonly Judgement Judgement; /// - /// The offset from a perfect hit at which this occurred. + /// The time at which this occurred. /// Populated when this is applied via . /// - public double TimeOffset { get; internal set; } + /// + /// This is used instead of to check whether this should be reverted. + /// + internal double? RawTime { get; set; } /// - /// The absolute time at which this occurred. - /// Equal to the (end) time of the + . + /// The offset of from the end time of , clamped by . /// - public double TimeAbsolute => HitObject.GetEndTime() + TimeOffset; + public double TimeOffset + { + get => RawTime != null ? Math.Min(RawTime.Value - HitObject.GetEndTime(), HitObject.MaximumJudgementOffset) : 0; + internal set => RawTime = HitObject.GetEndTime() + value; + } + + /// + /// The absolute time at which this occurred, clamped by the end time of plus . + /// + /// + /// The end time of is returned if this result is not populated yet. + /// + public double TimeAbsolute => RawTime != null ? Math.Min(RawTime.Value, HitObject.GetEndTime() + HitObject.MaximumJudgementOffset) : HitObject.GetEndTime(); /// /// The combo prior to this occurring. @@ -81,6 +98,13 @@ namespace osu.Game.Rulesets.Judgements { HitObject = hitObject; Judgement = judgement; + Reset(); + } + + internal void Reset() + { + Type = HitResult.None; + RawTime = null; } public override string ToString() => $"{Type} (Score:{Judgement.NumericResultFor(this)} HP:{Judgement.HealthIncreaseFor(this)} {Judgement})"; diff --git a/osu.Game/Rulesets/Mods/DifficultyAdjustSettingsControl.cs b/osu.Game/Rulesets/Mods/DifficultyAdjustSettingsControl.cs index c8e7284f5d..a941c0a1db 100644 --- a/osu.Game/Rulesets/Mods/DifficultyAdjustSettingsControl.cs +++ b/osu.Game/Rulesets/Mods/DifficultyAdjustSettingsControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -8,11 +10,12 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; namespace osu.Game.Rulesets.Mods { - public class DifficultyAdjustSettingsControl : SettingsItem + public partial class DifficultyAdjustSettingsControl : SettingsItem { [Resolved] private IBindable beatmap { get; set; } @@ -53,8 +56,8 @@ namespace osu.Game.Rulesets.Mods { base.LoadComplete(); - Current.BindValueChanged(current => updateCurrentFromSlider()); - beatmap.BindValueChanged(b => updateCurrentFromSlider(), true); + Current.BindValueChanged(_ => updateCurrentFromSlider()); + beatmap.BindValueChanged(_ => updateCurrentFromSlider(), true); sliderDisplayCurrent.BindValueChanged(number => { @@ -85,7 +88,7 @@ namespace osu.Game.Rulesets.Mods isInternalChange = false; } - private class SliderControl : CompositeDrawable, IHasCurrentValue + private partial class SliderControl : CompositeDrawable, IHasCurrentValue { // This is required as SettingsItem relies heavily on this bindable for internal use. // The actual update flow is done via the bindable provided in the constructor. @@ -101,9 +104,9 @@ namespace osu.Game.Rulesets.Mods { InternalChildren = new Drawable[] { - new SettingsSlider + new RoundedSliderBar { - ShowsDefaultIndicator = false, + RelativeSizeAxes = Axes.X, Current = currentNumber, KeyboardStep = 0.1f, } @@ -123,8 +126,7 @@ namespace osu.Game.Rulesets.Mods get => this; set { - if (value == null) - throw new ArgumentNullException(nameof(value)); + ArgumentNullException.ThrowIfNull(value); if (currentBound != null) UnbindFrom(currentBound); BindTo(currentBound = value); diff --git a/osu.Game/Rulesets/Mods/DifficultyBindable.cs b/osu.Game/Rulesets/Mods/DifficultyBindable.cs index 6cfae0b085..c21ce756c9 100644 --- a/osu.Game/Rulesets/Mods/DifficultyBindable.cs +++ b/osu.Game/Rulesets/Mods/DifficultyBindable.cs @@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Mods /// /// A function that can extract the current value of this setting from a beatmap difficulty for display purposes. /// - public Func ReadCurrentFromDifficulty; + public Func? ReadCurrentFromDifficulty; public float Precision { @@ -99,21 +99,37 @@ namespace osu.Game.Rulesets.Mods CurrentNumber.MaxValue = ExtendedLimits.Value && extendedMaxValue != null ? extendedMaxValue.Value : maxValue; } + public override void CopyTo(Bindable them) + { + if (!(them is DifficultyBindable otherDifficultyBindable)) + throw new InvalidOperationException($"Cannot copy to a non-{nameof(DifficultyBindable)}."); + + base.CopyTo(them); + + otherDifficultyBindable.ReadCurrentFromDifficulty = ReadCurrentFromDifficulty; + + // the following max value copies are only safe as long as these values are effectively constants. + otherDifficultyBindable.MaxValue = maxValue; + otherDifficultyBindable.ExtendedMaxValue = extendedMaxValue; + } + public override void BindTo(Bindable them) { if (!(them is DifficultyBindable otherDifficultyBindable)) throw new InvalidOperationException($"Cannot bind to a non-{nameof(DifficultyBindable)}."); - ReadCurrentFromDifficulty = otherDifficultyBindable.ReadCurrentFromDifficulty; - - // the following max value copies are only safe as long as these values are effectively constants. - MaxValue = otherDifficultyBindable.maxValue; - ExtendedMaxValue = otherDifficultyBindable.extendedMaxValue; + // ensure that MaxValue and ExtendedMaxValue are copied across first before continuing. + // not doing so may cause the value of CurrentNumber to be truncated to 10. + otherDifficultyBindable.CopyTo(this); + // set up mutual binding for ExtendedLimits to correctly set the upper bound of CurrentNumber. ExtendedLimits.BindTarget = otherDifficultyBindable.ExtendedLimits; - // the actual values need to be copied after the max value constraints. + // set up mutual binding for CurrentNumber. this must happen after all of the above. CurrentNumber.BindTarget = otherDifficultyBindable.CurrentNumber; + + // finish up the binding by setting up weak references via the base call. + // unfortunately this will call `.CopyTo()` again, but fixing that is problematic and messy. base.BindTo(them); } diff --git a/osu.Game/Rulesets/Mods/IApplicableToDrawableHitObjects.cs b/osu.Game/Rulesets/Mods/IApplicableToDrawableHitObjects.cs deleted file mode 100644 index 7f926dd8b8..0000000000 --- a/osu.Game/Rulesets/Mods/IApplicableToDrawableHitObjects.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Game.Rulesets.Objects.Drawables; - -namespace osu.Game.Rulesets.Mods -{ - [Obsolete(@"Use the singular version IApplicableToDrawableHitObject instead.")] // Can be removed 20211216 - public interface IApplicableToDrawableHitObjects : IApplicableToDrawableHitObject - { - void ApplyToDrawableHitObjects(IEnumerable drawables); - - void IApplicableToDrawableHitObject.ApplyToDrawableHitObject(DrawableHitObject drawable) => ApplyToDrawableHitObjects(drawable.Yield()); - } -} diff --git a/osu.Game/Rulesets/Mods/ICreateReplay.cs b/osu.Game/Rulesets/Mods/ICreateReplay.cs deleted file mode 100644 index 1e5eeca92c..0000000000 --- a/osu.Game/Rulesets/Mods/ICreateReplay.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using osu.Game.Beatmaps; -using osu.Game.Scoring; - -namespace osu.Game.Rulesets.Mods -{ - [Obsolete("Use ICreateReplayData instead")] // Can be removed 20220929 - public interface ICreateReplay : ICreateReplayData - { - public Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods); - - ModReplayData ICreateReplayData.CreateReplayData(IBeatmap beatmap, IReadOnlyList mods) - { - var replayScore = CreateReplayScore(beatmap, mods); - return new ModReplayData(replayScore.Replay, new ModCreatedUser { Username = replayScore.ScoreInfo.User.Username }); - } - } -} diff --git a/osu.Game/Rulesets/Mods/ICreateReplayData.cs b/osu.Game/Rulesets/Mods/ICreateReplayData.cs index 7d208e9000..c13e65c7b8 100644 --- a/osu.Game/Rulesets/Mods/ICreateReplayData.cs +++ b/osu.Game/Rulesets/Mods/ICreateReplayData.cs @@ -43,7 +43,7 @@ namespace osu.Game.Rulesets.Mods /// public readonly ModCreatedUser User; - public ModReplayData(Replay replay, ModCreatedUser user = null) + public ModReplayData(Replay replay, ModCreatedUser? user = null) { Replay = replay; User = user ?? new ModCreatedUser(); @@ -56,6 +56,7 @@ namespace osu.Game.Rulesets.Mods public class ModCreatedUser : IUser { public int OnlineID => APIUser.SYSTEM_USER_ID; + public CountryCode CountryCode => default; public bool IsBot => true; public string Username { get; set; } = string.Empty; diff --git a/osu.Game/Rulesets/Mods/IMod.cs b/osu.Game/Rulesets/Mods/IMod.cs index 30fa1ea8cb..05b2510e53 100644 --- a/osu.Game/Rulesets/Mods/IMod.cs +++ b/osu.Game/Rulesets/Mods/IMod.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mods { @@ -21,7 +22,7 @@ namespace osu.Game.Rulesets.Mods /// /// The user readable description of this mod. /// - string Description { get; } + LocalisableString Description { get; } /// /// The type of this mod. @@ -54,6 +55,6 @@ namespace osu.Game.Rulesets.Mods /// /// Create a fresh instance based on this mod. /// - Mod CreateInstance() => (Mod)Activator.CreateInstance(GetType()); + Mod CreateInstance() => (Mod)Activator.CreateInstance(GetType())!; } } diff --git a/osu.Game/Rulesets/Mods/MetronomeBeat.cs b/osu.Game/Rulesets/Mods/MetronomeBeat.cs index 149af1e30a..265970ea46 100644 --- a/osu.Game/Rulesets/Mods/MetronomeBeat.cs +++ b/osu.Game/Rulesets/Mods/MetronomeBeat.cs @@ -11,7 +11,7 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Mods { - public class MetronomeBeat : BeatSyncedContainer, IAdjustableAudioComponent + public partial class MetronomeBeat : BeatSyncedContainer, IAdjustableAudioComponent { private readonly double firstHitTime; diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index af1550f8a9..04d55bc5fe 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -9,6 +9,7 @@ using Newtonsoft.Json; 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.Rulesets.UI; @@ -34,7 +35,7 @@ namespace osu.Game.Rulesets.Mods public virtual ModType Type => ModType.Fun; [JsonIgnore] - public abstract string Description { get; } + public abstract LocalisableString Description { get; } /// /// The tooltip to display for this mod when used in a . @@ -69,7 +70,7 @@ namespace osu.Game.Rulesets.Mods foreach ((SettingSourceAttribute attr, PropertyInfo property) in this.GetOrderedSettingsSourceProperties()) { - var bindable = (IBindable)property.GetValue(this); + var bindable = (IBindable)property.GetValue(this)!; if (!bindable.IsDefault) tooltipTexts.Add($"{attr.Label} {bindable}"); @@ -100,9 +101,6 @@ namespace osu.Game.Rulesets.Mods [JsonIgnore] public virtual bool ValidForMultiplayerAsFreeMod => true; - [Obsolete("Going forward, the concept of \"ranked\" doesn't exist. The only exceptions are automation mods, which should now override and set UserPlayable to false.")] // Can be removed 20211009 - public virtual bool Ranked => false; - /// /// Whether this mod requires configuration to apply changes to the game. /// @@ -115,7 +113,7 @@ namespace osu.Game.Rulesets.Mods [JsonIgnore] public virtual Type[] IncompatibleMods => Array.Empty(); - private IReadOnlyList settingsBacking; + private IReadOnlyList? settingsBacking; /// /// A list of the all settings within this mod. @@ -126,12 +124,17 @@ namespace osu.Game.Rulesets.Mods .Cast() .ToList(); + /// + /// Whether all settings in this mod are set to their default state. + /// + protected virtual bool UsesDefaultConfiguration => Settings.All(s => s.IsDefault); + /// /// Creates a copy of this initialised to a default state. /// public virtual Mod DeepClone() { - var result = (Mod)Activator.CreateInstance(GetType()); + var result = (Mod)Activator.CreateInstance(GetType())!; result.CopyFrom(this); return result; } @@ -147,8 +150,8 @@ namespace osu.Game.Rulesets.Mods foreach (var (_, prop) in this.GetSettingsSourceProperties()) { - var targetBindable = (IBindable)prop.GetValue(this); - var sourceBindable = (IBindable)prop.GetValue(source); + var targetBindable = (IBindable)prop.GetValue(this)!; + var sourceBindable = (IBindable)prop.GetValue(source)!; CopyAdjustedSetting(targetBindable, sourceBindable); } @@ -180,9 +183,9 @@ namespace osu.Game.Rulesets.Mods } } - public bool Equals(IMod other) => other is Mod them && Equals(them); + public bool Equals(IMod? other) => other is Mod them && Equals(them); - public bool Equals(Mod other) + public bool Equals(Mod? other) { if (ReferenceEquals(null, other)) return false; if (ReferenceEquals(this, other)) return true; @@ -206,16 +209,16 @@ namespace osu.Game.Rulesets.Mods /// /// Reset all custom settings for this mod back to their defaults. /// - public virtual void ResetSettingsToDefaults() => CopyFrom((Mod)Activator.CreateInstance(GetType())); + public virtual void ResetSettingsToDefaults() => CopyFrom((Mod)Activator.CreateInstance(GetType())!); private class ModSettingsEqualityComparer : IEqualityComparer { public static ModSettingsEqualityComparer Default { get; } = new ModSettingsEqualityComparer(); - public bool Equals(IBindable x, IBindable y) + public bool Equals(IBindable? x, IBindable? y) { - object xValue = x?.GetUnderlyingSettingValue(); - object yValue = y?.GetUnderlyingSettingValue(); + object? xValue = x?.GetUnderlyingSettingValue(); + object? yValue = y?.GetUnderlyingSettingValue(); return EqualityComparer.Default.Equals(xValue, yValue); } diff --git a/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs b/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs new file mode 100644 index 0000000000..d4223a80c2 --- /dev/null +++ b/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs @@ -0,0 +1,71 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Globalization; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Judgements; +using osu.Game.Scoring; + +namespace osu.Game.Rulesets.Mods +{ + public class ModAccuracyChallenge : ModFailCondition, IApplicableToScoreProcessor + { + public override string Name => "Accuracy Challenge"; + + public override string Acronym => "AC"; + + public override LocalisableString Description => "Fail if your accuracy drops too low!"; + + public override ModType Type => ModType.DifficultyIncrease; + + public override double ScoreMultiplier => 1.0; + + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(ModEasyWithExtraLives), typeof(ModPerfect) }).ToArray(); + + public override bool RequiresConfiguration => false; + + public override string SettingDescription => base.SettingDescription.Replace(MinimumAccuracy.ToString(), MinimumAccuracy.Value.ToString("##%", NumberFormatInfo.InvariantInfo)); + + [SettingSource("Minimum accuracy", "Trigger a failure if your accuracy goes below this value.", SettingControlType = typeof(SettingsPercentageSlider))] + public BindableNumber MinimumAccuracy { get; } = new BindableDouble + { + MinValue = 0.60, + MaxValue = 0.99, + Precision = 0.01, + Default = 0.9, + Value = 0.9, + }; + + private ScoreProcessor scoreProcessor = null!; + + public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) => this.scoreProcessor = scoreProcessor; + + public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; + + protected override bool FailCondition(HealthProcessor healthProcessor, JudgementResult result) + { + if (!result.Type.AffectsAccuracy()) + return false; + + 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); + } + } +} diff --git a/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs b/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs index fb291fe10f..e7127abcf0 100644 --- a/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs +++ b/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Audio; using osu.Framework.Bindables; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -23,44 +24,36 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "AS"; - public override string Description => "Let track speed adapt to you."; + public override LocalisableString Description => "Let track speed adapt to you."; public override ModType Type => ModType.Fun; - public override double ScoreMultiplier => 1; + public override double ScoreMultiplier => 0.5; public override bool ValidForMultiplayer => false; public override bool ValidForMultiplayerAsFreeMod => false; - public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust), typeof(ModTimeRamp) }; + public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust), typeof(ModTimeRamp), typeof(ModAutoplay) }; [SettingSource("Initial rate", "The starting speed of the track")] - public BindableNumber InitialRate { get; } = new BindableDouble + public BindableNumber InitialRate { get; } = new BindableDouble(1) { MinValue = 0.5, MaxValue = 2, - Default = 1, - Value = 1, Precision = 0.01 }; [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] - public BindableBool AdjustPitch { get; } = new BindableBool - { - Default = true, - Value = true - }; + public BindableBool AdjustPitch { get; } = new BindableBool(true); /// /// The instantaneous rate of the track. /// Every frame this mod will attempt to smoothly adjust this to meet . /// - public BindableNumber SpeedChange { get; } = new BindableDouble + public BindableNumber SpeedChange { get; } = new BindableDouble(1) { MinValue = min_allowable_rate, MaxValue = max_allowable_rate, - Default = 1, - Value = 1 }; // The two constants below denote the maximum allowable range of rates that `SpeedChange` can take. @@ -77,7 +70,7 @@ namespace osu.Game.Rulesets.Mods // Apply a fixed rate change when missing, allowing the player to catch up when the rate is too fast. private const double rate_change_on_miss = 0.95d; - private IAdjustableAudioComponent track; + private IAdjustableAudioComponent? track; private double targetRate = 1d; /// @@ -163,7 +156,7 @@ namespace osu.Game.Rulesets.Mods public void ApplyToDrawableHitObject(DrawableHitObject drawable) { - drawable.OnNewResult += (o, result) => + drawable.OnNewResult += (_, result) => { if (ratesForRewinding.ContainsKey(result.HitObject)) return; if (!shouldProcessResult(result)) return; @@ -175,7 +168,7 @@ namespace osu.Game.Rulesets.Mods updateTargetRate(); }; - drawable.OnRevertResult += (o, result) => + drawable.OnRevertResult += (_, result) => { if (!ratesForRewinding.ContainsKey(result.HitObject)) return; if (!shouldProcessResult(result)) return; diff --git a/osu.Game/Rulesets/Mods/ModAutoplay.cs b/osu.Game/Rulesets/Mods/ModAutoplay.cs index 0ebe11b393..83afda3a28 100644 --- a/osu.Game/Rulesets/Mods/ModAutoplay.cs +++ b/osu.Game/Rulesets/Mods/ModAutoplay.cs @@ -4,10 +4,10 @@ using System; using System.Collections.Generic; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Replays; -using osu.Game.Scoring; namespace osu.Game.Rulesets.Mods { @@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "AT"; public override IconUsage? Icon => OsuIcon.ModAuto; public override ModType Type => ModType.Automation; - public override string Description => "Watch a perfect automated play through the song."; + public override LocalisableString Description => "Watch a perfect automated play through the song."; public override double ScoreMultiplier => 1; public bool PerformFail() => false; @@ -28,20 +28,10 @@ namespace osu.Game.Rulesets.Mods public override bool ValidForMultiplayer => false; public override bool ValidForMultiplayerAsFreeMod => false; - public override Type[] IncompatibleMods => new[] { typeof(ModCinema), typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail) }; + public override Type[] IncompatibleMods => new[] { typeof(ModCinema), typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail), typeof(ModAdaptiveSpeed) }; public override bool HasImplementation => GetType().GenericTypeArguments.Length == 0; - [Obsolete("Override CreateReplayData(IBeatmap, IReadOnlyList) instead")] // Can be removed 20220929 - public virtual Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { Replay = new Replay() }; - - public virtual ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList mods) - { -#pragma warning disable CS0618 - var replayScore = CreateReplayScore(beatmap, mods); -#pragma warning restore CS0618 - - return new ModReplayData(replayScore.Replay, new ModCreatedUser { Username = replayScore.ScoreInfo.User.Username }); - } + public virtual ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList mods) => new ModReplayData(new Replay(), new ModCreatedUser { Username = @"autoplay" }); } } diff --git a/osu.Game/Rulesets/Mods/ModBarrelRoll.cs b/osu.Game/Rulesets/Mods/ModBarrelRoll.cs index bacb953f76..0c301d293f 100644 --- a/osu.Game/Rulesets/Mods/ModBarrelRoll.cs +++ b/osu.Game/Rulesets/Mods/ModBarrelRoll.cs @@ -5,6 +5,7 @@ using System; using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; @@ -34,7 +35,7 @@ namespace osu.Game.Rulesets.Mods public override string Name => "Barrel Roll"; public override string Acronym => "BR"; - public override string Description => "The whole playfield is on a wheel!"; + public override LocalisableString Description => "The whole playfield is on a wheel!"; public override double ScoreMultiplier => 1; public override string SettingDescription => $"{SpinSpeed.Value:N2} rpm {Direction.Value.GetDescription().ToLowerInvariant()}"; diff --git a/osu.Game/Rulesets/Mods/ModBlockFail.cs b/osu.Game/Rulesets/Mods/ModBlockFail.cs index 8a9b0cddc8..cdfb36ebbc 100644 --- a/osu.Game/Rulesets/Mods/ModBlockFail.cs +++ b/osu.Game/Rulesets/Mods/ModBlockFail.cs @@ -9,7 +9,7 @@ namespace osu.Game.Rulesets.Mods { public abstract class ModBlockFail : Mod, IApplicableFailOverride, IApplicableToHUD, IReadFromConfig { - private Bindable showHealthBar; + private readonly Bindable showHealthBar = new Bindable(); /// /// We never fail, 'yo. @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Mods public void ReadFromConfig(OsuConfigManager config) { - showHealthBar = config.GetBindable(OsuSetting.ShowHealthDisplayWhenCantFail); + config.BindWith(OsuSetting.ShowHealthDisplayWhenCantFail, showHealthBar); } public void ApplyToHUD(HUDOverlay overlay) diff --git a/osu.Game/Rulesets/Mods/ModCinema.cs b/osu.Game/Rulesets/Mods/ModCinema.cs index 99c4e71d1f..ae661c5f25 100644 --- a/osu.Game/Rulesets/Mods/ModCinema.cs +++ b/osu.Game/Rulesets/Mods/ModCinema.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; @@ -27,7 +28,7 @@ namespace osu.Game.Rulesets.Mods public override string Name => "Cinema"; public override string Acronym => "CN"; public override IconUsage? Icon => OsuIcon.ModCinema; - public override string Description => "Watch the video without visual distractions."; + public override LocalisableString Description => "Watch the video without visual distractions."; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModAutoplay)).ToArray(); diff --git a/osu.Game/Rulesets/Mods/ModClassic.cs b/osu.Game/Rulesets/Mods/ModClassic.cs index 1159955e11..55b16297e2 100644 --- a/osu.Game/Rulesets/Mods/ModClassic.cs +++ b/osu.Game/Rulesets/Mods/ModClassic.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mods { @@ -15,7 +16,7 @@ namespace osu.Game.Rulesets.Mods public override IconUsage? Icon => FontAwesome.Solid.History; - public override string Description => "Feeling nostalgic?"; + public override LocalisableString Description => "Feeling nostalgic?"; public override ModType Type => ModType.Conversion; } diff --git a/osu.Game/Rulesets/Mods/ModDaycore.cs b/osu.Game/Rulesets/Mods/ModDaycore.cs index 9e8e44229e..de1a5ab56c 100644 --- a/osu.Game/Rulesets/Mods/ModDaycore.cs +++ b/osu.Game/Rulesets/Mods/ModDaycore.cs @@ -4,6 +4,7 @@ using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mods { @@ -12,7 +13,7 @@ namespace osu.Game.Rulesets.Mods public override string Name => "Daycore"; public override string Acronym => "DC"; public override IconUsage? Icon => null; - public override string Description => "Whoaaaaa..."; + public override LocalisableString Description => "Whoaaaaa..."; private readonly BindableNumber tempoAdjust = new BindableDouble(1); private readonly BindableNumber freqAdjust = new BindableDouble(1); diff --git a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs index eefa1531c4..f4c6be4f77 100644 --- a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs @@ -5,6 +5,7 @@ using System; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => @"Difficulty Adjust"; - public override string Description => @"Override a beatmap's difficulty settings."; + public override LocalisableString Description => @"Override a beatmap's difficulty settings."; public override string Acronym => "DA"; @@ -22,7 +23,7 @@ namespace osu.Game.Rulesets.Mods public override IconUsage? Icon => FontAwesome.Solid.Hammer; - public override double ScoreMultiplier => 1.0; + public override double ScoreMultiplier => 0.5; public override bool RequiresConfiguration => true; diff --git a/osu.Game/Rulesets/Mods/ModDoubleTime.cs b/osu.Game/Rulesets/Mods/ModDoubleTime.cs index 1c71f5d055..733610c040 100644 --- a/osu.Game/Rulesets/Mods/ModDoubleTime.cs +++ b/osu.Game/Rulesets/Mods/ModDoubleTime.cs @@ -3,6 +3,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics; @@ -14,16 +15,31 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "DT"; public override IconUsage? Icon => OsuIcon.ModDoubleTime; public override ModType Type => ModType.DifficultyIncrease; - public override string Description => "Zoooooooooom..."; + public override LocalisableString Description => "Zoooooooooom..."; [SettingSource("Speed increase", "The actual increase to apply")] - public override BindableNumber SpeedChange { get; } = new BindableDouble + public override BindableNumber SpeedChange { get; } = new BindableDouble(1.5) { MinValue = 1.01, MaxValue = 2, - Default = 1.5, - Value = 1.5, 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/ModEasyWithExtraLives.cs b/osu.Game/Rulesets/Mods/ModEasyWithExtraLives.cs index 2ac0f59d84..e101ac440e 100644 --- a/osu.Game/Rulesets/Mods/ModEasyWithExtraLives.cs +++ b/osu.Game/Rulesets/Mods/ModEasyWithExtraLives.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Linq; using Humanizer; using osu.Framework.Bindables; using osu.Game.Beatmaps; @@ -19,10 +21,11 @@ namespace osu.Game.Rulesets.Mods }; public override string SettingDescription => Retries.IsDefault ? string.Empty : $"{"lives".ToQuantity(Retries.Value)}"; + public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModAccuracyChallenge)).ToArray(); private int retries; - private BindableNumber health; + private readonly BindableNumber health = new BindableDouble(); public override void ApplyToDifficulty(BeatmapDifficulty difficulty) { @@ -44,7 +47,7 @@ namespace osu.Game.Rulesets.Mods public void ApplyToHealthProcessor(HealthProcessor healthProcessor) { - health = healthProcessor.Health.GetBoundCopy(); + health.BindTo(healthProcessor.Health); } } } diff --git a/osu.Game/Rulesets/Mods/ModFlashlight.cs b/osu.Game/Rulesets/Mods/ModFlashlight.cs index b449f3f64d..f8c3a730f2 100644 --- a/osu.Game/Rulesets/Mods/ModFlashlight.cs +++ b/osu.Game/Rulesets/Mods/ModFlashlight.cs @@ -2,17 +2,17 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; +using System.Runtime.InteropServices; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Batches; -using osu.Framework.Graphics.OpenGL.Vertices; using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Rendering.Vertices; using osu.Framework.Graphics.Shaders; +using osu.Framework.Graphics.Shaders.Types; using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; -using osu.Game.Beatmaps.Timing; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.OpenGL.Vertices; @@ -20,6 +20,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Scoring; +using osu.Game.Screens.Play; using osuTK; using osuTK.Graphics; @@ -31,7 +32,7 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "FL"; public override IconUsage? Icon => OsuIcon.ModFlashlight; public override ModType Type => ModType.DifficultyIncrease; - public override string Description => "Restricted view area."; + public override LocalisableString Description => "Restricted view area."; [SettingSource("Flashlight size", "Multiplier applied to the default flashlight size.")] public abstract BindableFloat SizeMultiplier { get; } @@ -46,7 +47,7 @@ namespace osu.Game.Rulesets.Mods public abstract float DefaultFlashlightSize { get; } } - public abstract class ModFlashlight : ModFlashlight, IApplicableToDrawableRuleset, IApplicableToScoreProcessor + public abstract partial class ModFlashlight : ModFlashlight, IApplicableToDrawableRuleset, IApplicableToScoreProcessor where T : HitObject { public const double FLASHLIGHT_FADE_DURATION = 800; @@ -83,25 +84,21 @@ namespace osu.Game.Rulesets.Mods flashlight.Colour = Color4.Black; flashlight.Combo.BindTo(Combo); - drawableRuleset.KeyBindingInputManager.Add(flashlight); - - flashlight.Breaks = drawableRuleset.Beatmap.Breaks; + drawableRuleset.Overlays.Add(flashlight); } protected abstract Flashlight CreateFlashlight(); - public abstract class Flashlight : Drawable + public abstract partial class Flashlight : Drawable { public readonly BindableInt Combo = new BindableInt(); - private IShader shader; + private IShader shader = null!; protected override DrawNode CreateDrawNode() => new FlashlightDrawNode(this); public override bool RemoveCompletedTransforms => false; - public List Breaks; - private readonly float defaultFlashlightSize; private readonly float sizeMultiplier; private readonly bool comboBasedSize; @@ -119,46 +116,50 @@ namespace osu.Game.Rulesets.Mods shader = shaderManager.Load("PositionAndColour", FragmentShader); } + [Resolved] + private Player? player { get; set; } + + private readonly IBindable isBreakTime = new BindableBool(); + protected override void LoadComplete() { base.LoadComplete(); - Combo.ValueChanged += OnComboChange; + Combo.ValueChanged += _ => UpdateFlashlightSize(GetSize()); - using (BeginAbsoluteSequence(0)) + if (player != null) { - foreach (var breakPeriod in Breaks) - { - if (!breakPeriod.HasEffect) - continue; - - if (breakPeriod.Duration < FLASHLIGHT_FADE_DURATION * 2) continue; - - this.Delay(breakPeriod.StartTime + FLASHLIGHT_FADE_DURATION).FadeOutFromOne(FLASHLIGHT_FADE_DURATION); - this.Delay(breakPeriod.EndTime - FLASHLIGHT_FADE_DURATION).FadeInFromZero(FLASHLIGHT_FADE_DURATION); - } + isBreakTime.BindTo(player.IsBreakTime); + isBreakTime.BindValueChanged(_ => UpdateFlashlightSize(GetSize()), true); } } - protected abstract void OnComboChange(ValueChangedEvent e); + protected abstract void UpdateFlashlightSize(float size); protected abstract string FragmentShader { get; } - protected float GetSizeFor(int combo) + protected float GetSize() { float size = defaultFlashlightSize * sizeMultiplier; - if (comboBasedSize) - { - if (combo > 200) - size *= 0.8f; - else if (combo > 100) - size *= 0.9f; - } + if (isBreakTime.Value) + size *= 2.5f; + else if (comboBasedSize) + size *= GetComboScaleFor(Combo.Value); return size; } + protected virtual float GetComboScaleFor(int combo) + { + if (combo >= 200) + return 0.625f; + if (combo >= 100) + return 0.8125f; + + return 1.0f; + } + private Vector2 flashlightPosition; protected Vector2 FlashlightPosition @@ -201,27 +202,37 @@ namespace osu.Game.Rulesets.Mods } } + private float flashlightSmoothness = 1.1f; + + public float FlashlightSmoothness + { + get => flashlightSmoothness; + set + { + if (flashlightSmoothness == value) return; + + flashlightSmoothness = value; + Invalidate(Invalidation.DrawNode); + } + } + private class FlashlightDrawNode : DrawNode { protected new Flashlight Source => (Flashlight)base.Source; - private IShader shader; + private IShader shader = null!; private Quad screenSpaceDrawQuad; private Vector2 flashlightPosition; private Vector2 flashlightSize; private float flashlightDim; + private float flashlightSmoothness; - private readonly VertexBatch quadBatch = new QuadBatch(1, 1); - private readonly Action addAction; + private IVertexBatch? quadBatch; + private Action? addAction; public FlashlightDrawNode(Flashlight source) : base(source) { - addAction = v => quadBatch.Add(new PositionAndColourVertex - { - Position = v.Position, - Colour = v.Colour - }); } public override void ApplyState() @@ -233,19 +244,38 @@ namespace osu.Game.Rulesets.Mods flashlightPosition = Vector2Extensions.Transform(Source.FlashlightPosition, DrawInfo.Matrix); flashlightSize = Source.FlashlightSize * DrawInfo.Matrix.ExtractScale().Xy; flashlightDim = Source.FlashlightDim; + flashlightSmoothness = Source.flashlightSmoothness; } - public override void Draw(Action vertexAction) + private IUniformBuffer? flashlightParametersBuffer; + + public override void Draw(IRenderer renderer) { - base.Draw(vertexAction); + base.Draw(renderer); + + if (quadBatch == null) + { + quadBatch = renderer.CreateQuadBatch(1, 1); + addAction = v => quadBatch.Add(new PositionAndColourVertex + { + Position = v.Position, + Colour = v.Colour + }); + } + + flashlightParametersBuffer ??= renderer.CreateUniformBuffer(); + flashlightParametersBuffer.Data = flashlightParametersBuffer.Data with + { + Position = flashlightPosition, + Size = flashlightSize, + Dim = flashlightDim, + Smoothness = flashlightSmoothness + }; shader.Bind(); + shader.BindUniformBlock(@"m_FlashlightParameters", flashlightParametersBuffer); - shader.GetUniform("flashlightPos").UpdateValue(ref flashlightPosition); - shader.GetUniform("flashlightSize").UpdateValue(ref flashlightSize); - shader.GetUniform("flashlightDim").UpdateValue(ref flashlightDim); - - DrawQuad(Texture.WhitePixel, screenSpaceDrawQuad, DrawColourInfo.Colour, vertexAction: addAction); + renderer.DrawQuad(renderer.WhitePixel, screenSpaceDrawQuad, DrawColourInfo.Colour, vertexAction: addAction); shader.Unbind(); } @@ -254,6 +284,17 @@ namespace osu.Game.Rulesets.Mods { base.Dispose(isDisposing); quadBatch?.Dispose(); + flashlightParametersBuffer?.Dispose(); + } + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + private record struct FlashlightParameters + { + public UniformVector2 Position; + public UniformVector2 Size; + public UniformFloat Dim; + public UniformFloat Smoothness; + private readonly UniformPadding8 pad1; } } } diff --git a/osu.Game/Rulesets/Mods/ModHalfTime.cs b/osu.Game/Rulesets/Mods/ModHalfTime.cs index 13d89e30d6..06c7750035 100644 --- a/osu.Game/Rulesets/Mods/ModHalfTime.cs +++ b/osu.Game/Rulesets/Mods/ModHalfTime.cs @@ -3,6 +3,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics; @@ -14,16 +15,28 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "HT"; public override IconUsage? Icon => OsuIcon.ModHalftime; public override ModType Type => ModType.DifficultyReduction; - public override string Description => "Less zoom..."; + public override LocalisableString Description => "Less zoom..."; [SettingSource("Speed decrease", "The actual decrease to apply")] - public override BindableNumber SpeedChange { get; } = new BindableDouble + public override BindableNumber SpeedChange { get; } = new BindableDouble(0.75) { MinValue = 0.5, MaxValue = 0.99, - Default = 0.75, - Value = 0.75, 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/Mods/ModHardRock.cs b/osu.Game/Rulesets/Mods/ModHardRock.cs index 0a5348a8cf..2886e59c54 100644 --- a/osu.Game/Rulesets/Mods/ModHardRock.cs +++ b/osu.Game/Rulesets/Mods/ModHardRock.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; @@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "HR"; public override IconUsage? Icon => OsuIcon.ModHardRock; public override ModType Type => ModType.DifficultyIncrease; - public override string Description => "Everything just got a bit harder..."; + public override LocalisableString Description => "Everything just got a bit harder..."; public override Type[] IncompatibleMods => new[] { typeof(ModEasy), typeof(ModDifficultyAdjust) }; public void ReadFromDifficulty(IBeatmapDifficultyInfo difficulty) diff --git a/osu.Game/Rulesets/Mods/ModMuted.cs b/osu.Game/Rulesets/Mods/ModMuted.cs index 84341faab7..131f501630 100644 --- a/osu.Game/Rulesets/Mods/ModMuted.cs +++ b/osu.Game/Rulesets/Mods/ModMuted.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Mods public override string Name => "Muted"; public override string Acronym => "MU"; public override IconUsage? Icon => FontAwesome.Solid.VolumeMute; - public override string Description => "Can you still feel the rhythm without music?"; + public override LocalisableString Description => "Can you still feel the rhythm without music?"; public override ModType Type => ModType.Fun; public override double ScoreMultiplier => 1; } @@ -33,37 +33,23 @@ namespace osu.Game.Rulesets.Mods private readonly BindableNumber mainVolumeAdjust = new BindableDouble(0.5); private readonly BindableNumber metronomeVolumeAdjust = new BindableDouble(0.5); - private BindableNumber currentCombo; + private readonly BindableNumber currentCombo = new BindableInt(); + + [SettingSource("Start muted", "Increase volume as combo builds.")] + public BindableBool InverseMuting { get; } = new BindableBool(); [SettingSource("Enable metronome", "Add a metronome beat to help you keep track of the rhythm.")] - public BindableBool EnableMetronome { get; } = new BindableBool - { - Default = true, - Value = true - }; + public BindableBool EnableMetronome { get; } = new BindableBool(true); [SettingSource("Final volume at combo", "The combo count at which point the track reaches its final volume.", SettingControlType = typeof(SettingsSlider))] - public BindableInt MuteComboCount { get; } = new BindableInt + public BindableInt MuteComboCount { get; } = new BindableInt(100) { - Default = 100, - Value = 100, MinValue = 0, MaxValue = 500, }; - [SettingSource("Start muted", "Increase volume as combo builds.")] - public BindableBool InverseMuting { get; } = new BindableBool - { - Default = false, - Value = false - }; - [SettingSource("Mute hit sounds", "Hit sounds are also muted alongside the track.")] - public BindableBool AffectsHitSounds { get; } = new BindableBool - { - Default = true, - Value = true - }; + public BindableBool AffectsHitSounds { get; } = new BindableBool(true); protected ModMuted() { @@ -81,7 +67,8 @@ namespace osu.Game.Rulesets.Mods { MetronomeBeat metronomeBeat; - drawableRuleset.Overlays.Add(metronomeBeat = new MetronomeBeat(drawableRuleset.Beatmap.HitObjects.First().StartTime)); + // Importantly, this is added to FrameStableComponents and not Overlays as the latter would cause it to be self-muted by the mod's volume adjustment. + drawableRuleset.FrameStableComponents.Add(metronomeBeat = new MetronomeBeat(drawableRuleset.Beatmap.HitObjects.First().StartTime)); metronomeBeat.AddAdjustment(AdjustableProperty.Volume, metronomeVolumeAdjust); } @@ -92,7 +79,7 @@ namespace osu.Game.Rulesets.Mods public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) { - currentCombo = scoreProcessor.Combo.GetBoundCopy(); + currentCombo.BindTo(scoreProcessor.Combo); currentCombo.BindValueChanged(combo => { double dimFactor = MuteComboCount.Value == 0 ? 1 : (double)combo.NewValue / MuteComboCount.Value; @@ -108,7 +95,7 @@ namespace osu.Game.Rulesets.Mods public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; } - public class MuteComboSlider : OsuSliderBar + public partial class MuteComboSlider : RoundedSliderBar { public override LocalisableString TooltipText => Current.Value == 0 ? "always muted" : base.TooltipText; } diff --git a/osu.Game/Rulesets/Mods/ModNightcore.cs b/osu.Game/Rulesets/Mods/ModNightcore.cs index 7997204450..9b1f7d5cf7 100644 --- a/osu.Game/Rulesets/Mods/ModNightcore.cs +++ b/osu.Game/Rulesets/Mods/ModNightcore.cs @@ -7,6 +7,7 @@ using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Audio; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Timing; @@ -23,10 +24,10 @@ namespace osu.Game.Rulesets.Mods public override string Name => "Nightcore"; public override string Acronym => "NC"; public override IconUsage? Icon => OsuIcon.ModNightcore; - public override string Description => "Uguuuuuuuu..."; + public override LocalisableString Description => "Uguuuuuuuu..."; } - public abstract class ModNightcore : ModNightcore, IApplicableToDrawableRuleset + public abstract partial class ModNightcore : ModNightcore, IApplicableToDrawableRuleset where TObject : HitObject { private readonly BindableNumber tempoAdjust = new BindableDouble(1); @@ -53,12 +54,12 @@ namespace osu.Game.Rulesets.Mods drawableRuleset.Overlays.Add(new NightcoreBeatContainer()); } - public class NightcoreBeatContainer : BeatSyncedContainer + public partial class NightcoreBeatContainer : BeatSyncedContainer { - private PausableSkinnableSound hatSample; - private PausableSkinnableSound clapSample; - private PausableSkinnableSound kickSample; - private PausableSkinnableSound finishSample; + private PausableSkinnableSound? hatSample; + private PausableSkinnableSound? clapSample; + private PausableSkinnableSound? kickSample; + private PausableSkinnableSound? finishSample; private int? firstBeat; diff --git a/osu.Game/Rulesets/Mods/ModNoFail.cs b/osu.Game/Rulesets/Mods/ModNoFail.cs index 5ebae17228..31bb4338b3 100644 --- a/osu.Game/Rulesets/Mods/ModNoFail.cs +++ b/osu.Game/Rulesets/Mods/ModNoFail.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics; namespace osu.Game.Rulesets.Mods @@ -13,7 +14,7 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "NF"; public override IconUsage? Icon => OsuIcon.ModNoFail; public override ModType Type => ModType.DifficultyReduction; - public override string Description => "You can't fail, no matter what."; + public override LocalisableString Description => "You can't fail, no matter what."; public override double ScoreMultiplier => 0.5; public override Type[] IncompatibleMods => new[] { typeof(ModRelax), typeof(ModFailCondition), typeof(ModAutoplay) }; } diff --git a/osu.Game/Rulesets/Mods/ModNoMod.cs b/osu.Game/Rulesets/Mods/ModNoMod.cs index 1009c5bc42..5dd4b317e7 100644 --- a/osu.Game/Rulesets/Mods/ModNoMod.cs +++ b/osu.Game/Rulesets/Mods/ModNoMod.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mods { @@ -12,7 +13,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "No Mod"; public override string Acronym => "NM"; - public override string Description => "No mods applied."; + public override LocalisableString Description => "No mods applied."; public override double ScoreMultiplier => 1; public override IconUsage? Icon => FontAwesome.Solid.Ban; public override ModType Type => ModType.System; diff --git a/osu.Game/Rulesets/Mods/ModNoScope.cs b/osu.Game/Rulesets/Mods/ModNoScope.cs index 7a935eb38f..5b9dfc0430 100644 --- a/osu.Game/Rulesets/Mods/ModNoScope.cs +++ b/osu.Game/Rulesets/Mods/ModNoScope.cs @@ -6,7 +6,9 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play; @@ -28,26 +30,31 @@ namespace osu.Game.Rulesets.Mods protected const float TRANSITION_DURATION = 100; - protected BindableNumber CurrentCombo; + protected readonly BindableNumber CurrentCombo = new BindableInt(); - protected IBindable IsBreakTime; + protected readonly IBindable IsBreakTime = new Bindable(); protected float ComboBasedAlpha; + [SettingSource( + "Hidden at combo", + "The combo count at which the cursor becomes completely hidden", + SettingControlType = typeof(SettingsSlider) + )] public abstract BindableInt HiddenComboCount { get; } public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; public void ApplyToPlayer(Player player) { - IsBreakTime = player.IsBreakTime.GetBoundCopy(); + IsBreakTime.BindTo(player.IsBreakTime); } public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) { if (HiddenComboCount.Value == 0) return; - CurrentCombo = scoreProcessor.Combo.GetBoundCopy(); + CurrentCombo.BindTo(scoreProcessor.Combo); CurrentCombo.BindValueChanged(combo => { ComboBasedAlpha = Math.Max(MIN_ALPHA, 1 - (float)combo.NewValue / HiddenComboCount.Value); @@ -55,7 +62,7 @@ namespace osu.Game.Rulesets.Mods } } - public class HiddenComboSlider : OsuSliderBar + public partial class HiddenComboSlider : RoundedSliderBar { public override LocalisableString TooltipText => Current.Value == 0 ? "always hidden" : base.TooltipText; } diff --git a/osu.Game/Rulesets/Mods/ModPerfect.cs b/osu.Game/Rulesets/Mods/ModPerfect.cs index 9016a24f8d..6f0bb7ad3b 100644 --- a/osu.Game/Rulesets/Mods/ModPerfect.cs +++ b/osu.Game/Rulesets/Mods/ModPerfect.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; @@ -17,9 +18,9 @@ namespace osu.Game.Rulesets.Mods public override IconUsage? Icon => OsuIcon.ModPerfect; public override ModType Type => ModType.DifficultyIncrease; public override double ScoreMultiplier => 1; - public override string Description => "SS or quit."; + public override LocalisableString Description => "SS or quit."; - public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModSuddenDeath)).ToArray(); + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(ModSuddenDeath), typeof(ModAccuracyChallenge) }).ToArray(); protected ModPerfect() { diff --git a/osu.Game/Rulesets/Mods/ModPreset.cs b/osu.Game/Rulesets/Mods/ModPreset.cs new file mode 100644 index 0000000000..2c3f574d47 --- /dev/null +++ b/osu.Game/Rulesets/Mods/ModPreset.cs @@ -0,0 +1,75 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Game.Database; +using osu.Game.Online.API; +using Realms; + +namespace osu.Game.Rulesets.Mods +{ + /// + /// A mod preset is a named collection of configured mods. + /// Presets are presented to the user in the mod select overlay for convenience. + /// + public class ModPreset : RealmObject, IHasGuidPrimaryKey, ISoftDelete + { + /// + /// The internal database ID of the preset. + /// + [PrimaryKey] + public Guid ID { get; set; } = Guid.NewGuid(); + + /// + /// The ruleset that the preset is valid for. + /// + public RulesetInfo Ruleset { get; set; } = null!; + + /// + /// The name of the mod preset. + /// + public string Name { get; set; } = string.Empty; + + /// + /// The description of the mod preset. + /// + public string Description { get; set; } = string.Empty; + + /// + /// The set of configured mods that are part of the preset. + /// + [Ignored] + public ICollection Mods + { + get + { + if (string.IsNullOrEmpty(ModsJson)) + return Array.Empty(); + + var apiMods = JsonConvert.DeserializeObject>(ModsJson); + var ruleset = Ruleset.CreateInstance(); + return apiMods.AsNonNull().Select(mod => mod.ToMod(ruleset)).ToArray(); + } + set + { + var apiMods = value.Select(mod => new APIMod(mod)).ToArray(); + ModsJson = JsonConvert.SerializeObject(apiMods); + } + } + + /// + /// The set of configured mods that are part of the preset, serialised as a JSON blob. + /// + [MapTo("Mods")] + public string ModsJson { get; set; } = string.Empty; + + /// + /// Whether the preset has been soft-deleted by the user. + /// + public bool DeletePending { get; set; } + } +} diff --git a/osu.Game/Rulesets/Mods/ModRandom.cs b/osu.Game/Rulesets/Mods/ModRandom.cs index 1f7742b075..178b9fb619 100644 --- a/osu.Game/Rulesets/Mods/ModRandom.cs +++ b/osu.Game/Rulesets/Mods/ModRandom.cs @@ -18,10 +18,6 @@ namespace osu.Game.Rulesets.Mods public override double ScoreMultiplier => 1; [SettingSource("Seed", "Use a custom seed instead of a random one", SettingControlType = typeof(SettingsNumberBox))] - public Bindable Seed { get; } = new Bindable - { - Default = null, - Value = null - }; + public Bindable Seed { get; } = new Bindable(); } } diff --git a/osu.Game/Rulesets/Mods/ModRelax.cs b/osu.Game/Rulesets/Mods/ModRelax.cs index e5995ff180..49c10339ee 100644 --- a/osu.Game/Rulesets/Mods/ModRelax.cs +++ b/osu.Game/Rulesets/Mods/ModRelax.cs @@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "RX"; public override IconUsage? Icon => OsuIcon.ModRelax; public override ModType Type => ModType.Automation; - public override double ScoreMultiplier => 1; + public override double ScoreMultiplier => 0.1; public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModNoFail), typeof(ModFailCondition) }; } } diff --git a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs index c8b835f78a..4e4e8662e8 100644 --- a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs +++ b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; @@ -16,7 +17,7 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "SD"; public override IconUsage? Icon => OsuIcon.ModSuddenDeath; public override ModType Type => ModType.DifficultyIncrease; - public override string Description => "Miss and fail."; + public override LocalisableString Description => "Miss and fail."; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModPerfect)).ToArray(); diff --git a/osu.Game/Rulesets/Mods/ModTimeRamp.cs b/osu.Game/Rulesets/Mods/ModTimeRamp.cs index 98abda872b..7285315c3b 100644 --- a/osu.Game/Rulesets/Mods/ModTimeRamp.cs +++ b/osu.Game/Rulesets/Mods/ModTimeRamp.cs @@ -7,7 +7,6 @@ using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Configuration; -using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Mods @@ -19,6 +18,8 @@ namespace osu.Game.Rulesets.Mods /// public const double FINAL_RATE_PROGRESS = 0.75f; + public override double ScoreMultiplier => 0.5; + [SettingSource("Initial rate", "The starting speed of the track")] public abstract BindableNumber InitialRate { get; } @@ -37,19 +38,17 @@ namespace osu.Game.Rulesets.Mods private double finalRateTime; private double beginRampTime; - public BindableNumber SpeedChange { get; } = new BindableDouble + public BindableNumber SpeedChange { get; } = new BindableDouble(1) { - Default = 1, - Value = 1, Precision = 0.01, }; - private IAdjustableAudioComponent track; + private IAdjustableAudioComponent? track; protected ModTimeRamp() { // for preview purpose at song select. eventually we'll want to be able to update every frame. - FinalRate.BindValueChanged(val => applyRateAdjustment(double.PositiveInfinity), true); + FinalRate.BindValueChanged(_ => applyRateAdjustment(double.PositiveInfinity), true); AdjustPitch.BindValueChanged(applyPitchAdjustment); } @@ -71,7 +70,7 @@ namespace osu.Game.Rulesets.Mods SpeedChange.SetDefault(); double firstObjectStart = beatmap.HitObjects.FirstOrDefault()?.StartTime ?? 0; - double lastObjectEnd = beatmap.HitObjects.LastOrDefault()?.GetEndTime() ?? 0; + double lastObjectEnd = beatmap.HitObjects.Any() ? beatmap.GetLastObjectTime() : 0; beginRampTime = firstObjectStart; finalRateTime = firstObjectStart + FINAL_RATE_PROGRESS * (lastObjectEnd - firstObjectStart); diff --git a/osu.Game/Rulesets/Mods/ModWindDown.cs b/osu.Game/Rulesets/Mods/ModWindDown.cs index 08bd44f7bd..35a673093b 100644 --- a/osu.Game/Rulesets/Mods/ModWindDown.cs +++ b/osu.Game/Rulesets/Mods/ModWindDown.cs @@ -5,7 +5,7 @@ using System; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; -using osu.Game.Configuration; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mods { @@ -13,36 +13,24 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Wind Down"; public override string Acronym => "WD"; - public override string Description => "Sloooow doooown..."; + public override LocalisableString Description => "Sloooow doooown..."; public override IconUsage? Icon => FontAwesome.Solid.ChevronCircleDown; - public override double ScoreMultiplier => 1.0; - [SettingSource("Initial rate", "The starting speed of the track")] - public override BindableNumber InitialRate { get; } = new BindableDouble + public override BindableNumber InitialRate { get; } = new BindableDouble(1) { MinValue = 0.51, MaxValue = 2, - Default = 1, - Value = 1, Precision = 0.01, }; - [SettingSource("Final rate", "The speed increase to ramp towards")] - public override BindableNumber FinalRate { get; } = new BindableDouble + public override BindableNumber FinalRate { get; } = new BindableDouble(0.75) { MinValue = 0.5, MaxValue = 1.99, - Default = 0.75, - Value = 0.75, Precision = 0.01, }; - [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] - public override BindableBool AdjustPitch { get; } = new BindableBool - { - Default = true, - Value = true - }; + public override BindableBool AdjustPitch { get; } = new BindableBool(true); public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModWindUp)).ToArray(); diff --git a/osu.Game/Rulesets/Mods/ModWindUp.cs b/osu.Game/Rulesets/Mods/ModWindUp.cs index df8f781148..bbc8382055 100644 --- a/osu.Game/Rulesets/Mods/ModWindUp.cs +++ b/osu.Game/Rulesets/Mods/ModWindUp.cs @@ -5,7 +5,7 @@ using System; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; -using osu.Game.Configuration; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mods { @@ -13,36 +13,24 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Wind Up"; public override string Acronym => "WU"; - public override string Description => "Can you keep up?"; + public override LocalisableString Description => "Can you keep up?"; public override IconUsage? Icon => FontAwesome.Solid.ChevronCircleUp; - public override double ScoreMultiplier => 1.0; - [SettingSource("Initial rate", "The starting speed of the track")] - public override BindableNumber InitialRate { get; } = new BindableDouble + public override BindableNumber InitialRate { get; } = new BindableDouble(1) { MinValue = 0.5, MaxValue = 1.99, - Default = 1, - Value = 1, Precision = 0.01, }; - [SettingSource("Final rate", "The speed increase to ramp towards")] - public override BindableNumber FinalRate { get; } = new BindableDouble + public override BindableNumber FinalRate { get; } = new BindableDouble(1.5) { MinValue = 0.51, MaxValue = 2, - Default = 1.5, - Value = 1.5, Precision = 0.01, }; - [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] - public override BindableBool AdjustPitch { get; } = new BindableBool - { - Default = true, - Value = true - }; + public override BindableBool AdjustPitch { get; } = new BindableBool(true); public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModWindDown)).ToArray(); diff --git a/osu.Game/Rulesets/Mods/ModWithVisibilityAdjustment.cs b/osu.Game/Rulesets/Mods/ModWithVisibilityAdjustment.cs index b58ee5ff36..2e3619ec63 100644 --- a/osu.Game/Rulesets/Mods/ModWithVisibilityAdjustment.cs +++ b/osu.Game/Rulesets/Mods/ModWithVisibilityAdjustment.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Mods /// /// The first adjustable object. /// - protected HitObject FirstObject { get; private set; } + protected HitObject? FirstObject { get; private set; } /// /// Whether the visibility of should be increased. @@ -57,7 +57,7 @@ namespace osu.Game.Rulesets.Mods { FirstObject = getFirstAdjustableObjectRecursive(beatmap.HitObjects); - HitObject getFirstAdjustableObjectRecursive(IReadOnlyList hitObjects) + HitObject? getFirstAdjustableObjectRecursive(IReadOnlyList hitObjects) { foreach (var h in hitObjects) { @@ -91,7 +91,7 @@ namespace osu.Game.Rulesets.Mods /// The to check. /// The which may be equal to or contain as a nested object. /// Whether is equal to or nested within . - private bool isObjectEqualToOrNestedIn(HitObject toCheck, HitObject target) + private bool isObjectEqualToOrNestedIn(HitObject toCheck, HitObject? target) { if (target == null) return false; diff --git a/osu.Game/Rulesets/Mods/MultiMod.cs b/osu.Game/Rulesets/Mods/MultiMod.cs index 1c41c6b8b3..9fbc0ddd97 100644 --- a/osu.Game/Rulesets/Mods/MultiMod.cs +++ b/osu.Game/Rulesets/Mods/MultiMod.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mods { @@ -10,7 +11,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => string.Empty; public override string Acronym => string.Empty; - public override string Description => string.Empty; + public override LocalisableString Description => string.Empty; public override double ScoreMultiplier => 0; public Mod[] Mods { get; } diff --git a/osu.Game/Rulesets/Mods/UnknownMod.cs b/osu.Game/Rulesets/Mods/UnknownMod.cs index 72de0ad653..abe05996ff 100644 --- a/osu.Game/Rulesets/Mods/UnknownMod.cs +++ b/osu.Game/Rulesets/Mods/UnknownMod.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; + namespace osu.Game.Rulesets.Mods { public class UnknownMod : Mod @@ -12,7 +14,7 @@ namespace osu.Game.Rulesets.Mods public override string Name => $"Unknown mod ({OriginalAcronym})"; public override string Acronym => $"{OriginalAcronym}??"; - public override string Description => "This mod could not be resolved by the game."; + public override LocalisableString Description => "This mod could not be resolved by the game."; public override double ScoreMultiplier => 0; public override bool UserPlayable => false; diff --git a/osu.Game/Rulesets/Objects/BarLineGenerator.cs b/osu.Game/Rulesets/Objects/BarLineGenerator.cs index d71a499119..affbcbd878 100644 --- a/osu.Game/Rulesets/Objects/BarLineGenerator.cs +++ b/osu.Game/Rulesets/Objects/BarLineGenerator.cs @@ -27,8 +27,8 @@ namespace osu.Game.Rulesets.Objects if (beatmap.HitObjects.Count == 0) return; - HitObject lastObject = beatmap.HitObjects.Last(); - double lastHitTime = 1 + lastObject.GetEndTime(); + double firstHitTime = beatmap.HitObjects.First().StartTime; + double lastHitTime = 1 + beatmap.GetLastObjectTime(); var timingPoints = beatmap.ControlPointInfo.TimingPoints; @@ -40,12 +40,37 @@ namespace osu.Game.Rulesets.Objects TimingControlPoint currentTimingPoint = timingPoints[i]; int currentBeat = 0; - // Stop on the beat before the next timing point, or if there is no next timing point stop slightly past the last object - double endTime = i < timingPoints.Count - 1 ? timingPoints[i + 1].Time - currentTimingPoint.BeatLength : lastHitTime + currentTimingPoint.BeatLength * currentTimingPoint.TimeSignature.Numerator; + // Don't generate barlines before the hit object or t=0 (whichever is earliest). Some beatmaps use very unrealistic values here (although none are ranked). + // I'm not sure we ever want barlines to appear before the first hitobject, but let's keep some degree of compatibility for now. + // Of note, this will still differ from stable if the first timing control point is t<0 and is not near the first hitobject. + double generationStartTime = Math.Min(0, firstHitTime); + + // Stop on the next timing point, or if there is no next timing point stop slightly past the last object + double endTime = i < timingPoints.Count - 1 ? timingPoints[i + 1].Time : lastHitTime + currentTimingPoint.BeatLength * currentTimingPoint.TimeSignature.Numerator; double barLength = currentTimingPoint.BeatLength * currentTimingPoint.TimeSignature.Numerator; - for (double t = currentTimingPoint.Time; Precision.DefinitelyBigger(endTime, t); t += barLength, currentBeat++) + double startTime; + + if (currentTimingPoint.Time > generationStartTime) + { + startTime = currentTimingPoint.Time; + } + else + { + // If the timing point starts before the minimum allowable time for bar lines, + // we still need to compute a start time for generation that is actually properly aligned with the timing point. + int barCount = (int)Math.Ceiling((generationStartTime - currentTimingPoint.Time) / barLength); + + startTime = currentTimingPoint.Time + barCount * barLength; + } + + if (currentTimingPoint.OmitFirstBarLine) + { + startTime += barLength; + } + + for (double t = startTime; Precision.AlmostBigger(endTime, t); t += barLength, currentBeat++) { double roundedTime = Math.Round(t, MidpointRounding.AwayFromZero); diff --git a/osu.Game/Rulesets/Objects/BezierConverter.cs b/osu.Game/Rulesets/Objects/BezierConverter.cs new file mode 100644 index 0000000000..ebee36a7db --- /dev/null +++ b/osu.Game/Rulesets/Objects/BezierConverter.cs @@ -0,0 +1,287 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Utils; +using osu.Game.Rulesets.Objects.Types; +using osuTK; + +namespace osu.Game.Rulesets.Objects +{ + public static class BezierConverter + { + private struct CircleBezierPreset + { + public readonly double ArcLength; + public readonly Vector2d[] ControlPoints; + + public CircleBezierPreset(double arcLength, Vector2d[] controlPoints) + { + ArcLength = arcLength; + ControlPoints = controlPoints; + } + } + + // Extremely accurate a bezier anchor positions for approximating circles of several arc lengths + private static readonly CircleBezierPreset[] circle_presets = + { + new CircleBezierPreset(0.4993379862754501, + new[] { new Vector2d(1, 0), new Vector2d(1, 0.2549893626632736f), new Vector2d(0.8778997558480327f, 0.47884446188920726f) }), + new CircleBezierPreset(1.7579419829169447, + new[] { new Vector2d(1, 0), new Vector2d(1, 0.6263026f), new Vector2d(0.42931178f, 1.0990661f), new Vector2d(-0.18605515f, 0.9825393f) }), + new CircleBezierPreset(3.1385246920140215, + new[] { new Vector2d(1, 0), new Vector2d(1, 0.87084764f), new Vector2d(0.002304826f, 1.5033062f), new Vector2d(-0.9973236f, 0.8739115f), new Vector2d(-0.9999953f, 0.0030679568f) }), + new CircleBezierPreset(5.69720464620727, + new[] { new Vector2d(1, 0), new Vector2d(1, 1.4137783f), new Vector2d(-1.4305235f, 2.0779421f), new Vector2d(-2.3410065f, -0.94017583f), new Vector2d(0.05132711f, -1.7309346f), new Vector2d(0.8331702f, -0.5530167f) }), + new CircleBezierPreset(2 * Math.PI, + new[] { new Vector2d(1, 0), new Vector2d(1, 1.2447058f), new Vector2d(-0.8526471f, 2.118367f), new Vector2d(-2.6211002f, 7.854936e-06f), new Vector2d(-0.8526448f, -2.118357f), new Vector2d(1, -1.2447058f), new Vector2d(1, 0) }) + }; + + /// + /// Converts a slider path to bezier control point positions compatible with the legacy osu! client. + /// + /// The control points of the path. + /// The offset for the whole path. + /// The list of legacy bezier control point positions. + public static List ConvertToLegacyBezier(IList controlPoints, Vector2 position) + { + Vector2[] vertices = new Vector2[controlPoints.Count]; + for (int i = 0; i < controlPoints.Count; i++) + vertices[i] = controlPoints[i].Position; + + var result = new List(); + int start = 0; + + for (int i = 0; i < controlPoints.Count; i++) + { + if (controlPoints[i].Type == null && i < controlPoints.Count - 1) + continue; + + // The current vertex ends the segment + var segmentVertices = vertices.AsSpan().Slice(start, i - start + 1); + var segmentType = controlPoints[start].Type ?? PathType.Linear; + + switch (segmentType) + { + case PathType.Catmull: + result.AddRange(from segment in ConvertCatmullToBezierAnchors(segmentVertices) from v in segment select v + position); + + break; + + case PathType.Linear: + result.AddRange(from segment in ConvertLinearToBezierAnchors(segmentVertices) from v in segment select v + position); + + break; + + case PathType.PerfectCurve: + result.AddRange(ConvertCircleToBezierAnchors(segmentVertices).Select(v => v + position)); + + break; + + default: + foreach (Vector2 v in segmentVertices) + { + result.Add(v + position); + } + + break; + } + + // Start the new segment at the current vertex + start = i; + } + + return result; + } + + /// + /// Converts a path of control points to an identical path using only Bezier type control points. + /// + /// The control points of the path. + /// The list of bezier control points. + public static List ConvertToModernBezier(IList controlPoints) + { + Vector2[] vertices = new Vector2[controlPoints.Count]; + for (int i = 0; i < controlPoints.Count; i++) + vertices[i] = controlPoints[i].Position; + + var result = new List(); + int start = 0; + + for (int i = 0; i < controlPoints.Count; i++) + { + if (controlPoints[i].Type == null && i < controlPoints.Count - 1) + continue; + + // The current vertex ends the segment + var segmentVertices = vertices.AsSpan().Slice(start, i - start + 1); + var segmentType = controlPoints[start].Type ?? PathType.Linear; + + switch (segmentType) + { + case PathType.Catmull: + foreach (var segment in ConvertCatmullToBezierAnchors(segmentVertices)) + { + for (int j = 0; j < segment.Length - 1; j++) + { + result.Add(new PathControlPoint(segment[j], j == 0 ? PathType.Bezier : null)); + } + } + + break; + + case PathType.Linear: + foreach (var segment in ConvertLinearToBezierAnchors(segmentVertices)) + { + for (int j = 0; j < segment.Length - 1; j++) + { + result.Add(new PathControlPoint(segment[j], j == 0 ? PathType.Bezier : null)); + } + } + + break; + + case PathType.PerfectCurve: + var circleResult = ConvertCircleToBezierAnchors(segmentVertices); + + for (int j = 0; j < circleResult.Length - 1; j++) + { + result.Add(new PathControlPoint(circleResult[j], j == 0 ? PathType.Bezier : null)); + } + + break; + + default: + for (int j = 0; j < segmentVertices.Length - 1; j++) + { + result.Add(new PathControlPoint(segmentVertices[j], j == 0 ? PathType.Bezier : null)); + } + + break; + } + + // Start the new segment at the current vertex + start = i; + } + + result.Add(new PathControlPoint(controlPoints[^1].Position)); + + return result; + } + + /// + /// Converts perfect curve anchors to bezier anchors. + /// + /// The control point positions to convert. + public static Vector2[] ConvertCircleToBezierAnchors(ReadOnlySpan controlPoints) + { + if (controlPoints.Length != 3) + return controlPoints.ToArray(); + + var pr = new CircularArcProperties(controlPoints); + if (!pr.IsValid) + return controlPoints.ToArray(); + + CircleBezierPreset preset = circle_presets.Last(); + + foreach (CircleBezierPreset cbp in circle_presets) + { + if (cbp.ArcLength < pr.ThetaRange) continue; + + preset = cbp; + break; + } + + double arcLength = preset.ArcLength; + var arc = new Vector2d[preset.ControlPoints.Length]; + preset.ControlPoints.CopyTo(arc, 0); + + // Converge on arcLength of thetaRange + int n = arc.Length - 1; + double tf = pr.ThetaRange / arcLength; + + while (Math.Abs(tf - 1) > 1E-7) + { + for (int j = 0; j < n; j++) + { + for (int i = n; i > j; i--) + { + arc[i] = arc[i] * tf + arc[i - 1] * (1 - tf); + } + } + + arcLength = Math.Atan2(arc.Last()[1], arc.Last()[0]); + + if (arcLength < 0) + { + arcLength += 2 * Math.PI; + } + + tf = pr.ThetaRange / arcLength; + } + + // Adjust rotation, radius, and position + var result = new Vector2[arc.Length]; + + for (int i = 0; i < arc.Length; i++) + { + result[i] = new Vector2( + (float)((Math.Cos(pr.ThetaStart) * arc[i].X + -Math.Sin(pr.ThetaStart) * pr.Direction * arc[i].Y) * pr.Radius + pr.Centre.X), + (float)((Math.Sin(pr.ThetaStart) * arc[i].X + Math.Cos(pr.ThetaStart) * pr.Direction * arc[i].Y) * pr.Radius + pr.Centre.Y)); + } + + return result; + } + + /// + /// Converts catmull anchors to bezier anchors. + /// + /// The control point positions to convert. + public static Vector2[][] ConvertCatmullToBezierAnchors(ReadOnlySpan controlPoints) + { + int iLen = controlPoints.Length; + var bezier = new Vector2[iLen - 1][]; + + for (int i = 0; i < iLen - 1; i++) + { + var v1 = i > 0 ? controlPoints[i - 1] : controlPoints[i]; + var v2 = controlPoints[i]; + var v3 = i < iLen - 1 ? controlPoints[i + 1] : v2 + v2 - v1; + var v4 = i < iLen - 2 ? controlPoints[i + 2] : v3 + v3 - v2; + + bezier[i] = new[] + { + v2, + (-v1 + 6 * v2 + v3) / 6, + (-v4 + 6 * v3 + v2) / 6, + v3 + }; + } + + return bezier; + } + + /// + /// Converts linear anchors to bezier anchors. + /// + /// The control point positions to convert. + public static Vector2[][] ConvertLinearToBezierAnchors(ReadOnlySpan controlPoints) + { + int iLen = controlPoints.Length; + var bezier = new Vector2[iLen - 1][]; + + for (int i = 0; i < iLen - 1; i++) + { + bezier[i] = new[] + { + controlPoints[i], + controlPoints[i + 1] + }; + } + + return bezier; + } + } +} diff --git a/osu.Game/Rulesets/Objects/Drawables/ArmedState.cs b/osu.Game/Rulesets/Objects/Drawables/ArmedState.cs index b2d9f50602..4faf0920d1 100644 --- a/osu.Game/Rulesets/Objects/Drawables/ArmedState.cs +++ b/osu.Game/Rulesets/Objects/Drawables/ArmedState.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Objects.Drawables { public enum ArmedState diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index 2e573a7f85..f6c3452e48 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Collections.Specialized; @@ -13,8 +15,10 @@ using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; using osu.Framework.Threading; +using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Configuration; +using osu.Game.Graphics; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Pooling; using osu.Game.Rulesets.Objects.Types; @@ -26,7 +30,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Objects.Drawables { [Cached(typeof(DrawableHitObject))] - public abstract class DrawableHitObject : PoolableDrawableWithLifetime + public abstract partial class DrawableHitObject : PoolableDrawableWithLifetime { /// /// Invoked after this 's applied has had its defaults applied. @@ -78,6 +82,9 @@ namespace osu.Game.Rulesets.Objects.Drawables /// /// Invoked by this or a nested prior to a being reverted. /// + /// + /// This is only invoked if this is alive when the result is reverted. + /// public event Action OnRevertResult; /// @@ -125,7 +132,8 @@ namespace osu.Game.Rulesets.Objects.Drawables private readonly BindableList samplesBindable = new BindableList(); private readonly Bindable comboIndexBindable = new Bindable(); - private readonly Bindable positionalHitsoundsLevel = new Bindable(); + private readonly IBindable positionalHitsoundsLevel = new Bindable(); + private readonly IBindable comboColourBrightness = new Bindable(); private readonly Bindable comboIndexWithOffsetsBindable = new Bindable(); protected override bool RequiresChildrenUpdate => true; @@ -166,11 +174,12 @@ namespace osu.Game.Rulesets.Objects.Drawables } [BackgroundDependencyLoader] - private void load(OsuConfigManager config, ISkinSource skinSource) + private void load(IGameplaySettings gameplaySettings, ISkinSource skinSource) { - config.BindWith(OsuSetting.PositionalHitsoundsLevel, positionalHitsoundsLevel); + positionalHitsoundsLevel.BindTo(gameplaySettings.PositionalHitsoundsLevel); + comboColourBrightness.BindTo(gameplaySettings.ComboColourNormalisationAmount); - // Explicit non-virtual function call. + // Explicit non-virtual function call in case a DrawableHitObject overrides AddInternal. base.AddInternal(Samples = new PausableSkinnableSound()); CurrentSkin = skinSource; @@ -190,20 +199,10 @@ namespace osu.Game.Rulesets.Objects.Drawables comboIndexBindable.BindValueChanged(_ => UpdateComboColour()); comboIndexWithOffsetsBindable.BindValueChanged(_ => UpdateComboColour(), true); - // Apply transforms - updateState(State.Value, true); - } + comboColourBrightness.BindValueChanged(_ => UpdateComboColour()); - /// - /// Applies a hit object to be represented by this . - /// - [Obsolete("Use either overload of Apply that takes a single argument of type HitObject or HitObjectLifetimeEntry")] // Can be removed 20211021. - public void Apply([NotNull] HitObject hitObject, [CanBeNull] HitObjectLifetimeEntry lifetimeEntry) - { - if (lifetimeEntry != null) - Apply(lifetimeEntry); - else - Apply(hitObject); + // Apply transforms + updateStateFromResult(); } /// @@ -212,8 +211,7 @@ namespace osu.Game.Rulesets.Objects.Drawables /// public void Apply([NotNull] HitObject hitObject) { - if (hitObject == null) - throw new ArgumentNullException($"Cannot apply a null {nameof(HitObject)}."); + ArgumentNullException.ThrowIfNull(hitObject); Apply(new SyntheticHitObjectEntry(hitObject)); } @@ -227,6 +225,8 @@ namespace osu.Game.Rulesets.Objects.Drawables ensureEntryHasResult(); + entry.RevertResult += onRevertResult; + foreach (var h in HitObject.NestedHitObjects) { var pooledDrawableNested = pooledObjectProvider?.GetPooledDrawableRepresentation(h, this); @@ -239,7 +239,7 @@ namespace osu.Game.Rulesets.Objects.Drawables OnNestedDrawableCreated?.Invoke(drawableNested); drawableNested.OnNewResult += onNewResult; - drawableNested.OnRevertResult += onRevertResult; + drawableNested.OnRevertResult += onNestedRevertResult; drawableNested.ApplyCustomUpdateState += onApplyCustomUpdateState; // This is only necessary for non-pooled DHOs. For pooled DHOs, this is handled inside GetPooledDrawableRepresentation(). @@ -270,15 +270,24 @@ namespace osu.Game.Rulesets.Objects.Drawables // If not loaded, the state update happens in LoadComplete(). if (IsLoaded) { - if (Result.IsHit) - updateState(ArmedState.Hit, true); - else if (Result.HasResult) - updateState(ArmedState.Miss, true); - else - updateState(ArmedState.Idle, true); + updateStateFromResult(); + + // Combo colour may have been applied via a bindable flow while no object entry was attached. + // Update here to ensure we're in a good state. + UpdateComboColour(); } } + private void updateStateFromResult() + { + if (Result.IsHit) + updateState(ArmedState.Hit, true); + else if (Result.HasResult) + updateState(ArmedState.Miss, true); + else + updateState(ArmedState.Idle, true); + } + protected sealed override void OnFree(HitObjectLifetimeEntry entry) { StartTimeBindable.UnbindFrom(HitObject.StartTimeBindable); @@ -299,13 +308,12 @@ namespace osu.Game.Rulesets.Objects.Drawables samplesBindable.CollectionChanged -= onSamplesChanged; // Release the samples for other hitobjects to use. - if (Samples != null) - Samples.Samples = null; + Samples?.ClearSamples(); foreach (var obj in nestedHitObjects) { obj.OnNewResult -= onNewResult; - obj.OnRevertResult -= onRevertResult; + obj.OnRevertResult -= onNestedRevertResult; obj.ApplyCustomUpdateState -= onApplyCustomUpdateState; } @@ -314,6 +322,8 @@ namespace osu.Game.Rulesets.Objects.Drawables HitObject.DefaultsApplied -= onDefaultsApplied; + entry.RevertResult -= onRevertResult; + OnFree(); ParentHitObject = null; @@ -362,7 +372,13 @@ namespace osu.Game.Rulesets.Objects.Drawables private void onNewResult(DrawableHitObject drawableHitObject, JudgementResult result) => OnNewResult?.Invoke(drawableHitObject, result); - private void onRevertResult(DrawableHitObject drawableHitObject, JudgementResult result) => OnRevertResult?.Invoke(drawableHitObject, result); + private void onRevertResult() + { + updateState(ArmedState.Idle); + 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); @@ -403,7 +419,10 @@ namespace osu.Game.Rulesets.Objects.Drawables /// public event Action ApplyCustomUpdateState; - protected override void ClearInternal(bool disposeChildren = true) => throw new InvalidOperationException($"Should never clear a {nameof(DrawableHitObject)}"); + protected override void ClearInternal(bool disposeChildren = true) => + // See sample addition in load method. + throw new InvalidOperationException( + $"Should never clear a {nameof(DrawableHitObject)} as the base implementation adds components. If attempting to use {nameof(InternalChild)} or {nameof(InternalChildren)}, using {nameof(AddInternal)} or {nameof(AddRangeInternal)} instead."); private void updateState(ArmedState newState, bool force = false) { @@ -514,7 +533,15 @@ namespace osu.Game.Rulesets.Objects.Drawables { if (!(HitObject is IHasComboInformation combo)) return; - AccentColour.Value = combo.GetComboColour(CurrentSkin); + Color4 colour = combo.GetComboColour(CurrentSkin); + + // Normalise the combo colour to the given brightness level. + if (comboColourBrightness.Value != 0) + { + colour = Interpolation.ValueAt(Math.Abs(comboColourBrightness.Value), colour, new HSPAColour(colour) { P = 0.6f }.ToColor4(), 0, 1); + } + + AccentColour.Value = colour; } /// @@ -563,26 +590,6 @@ namespace osu.Game.Rulesets.Objects.Drawables #endregion - protected override void Update() - { - base.Update(); - - if (Result != null && Result.HasResult) - { - double endTime = HitObject.GetEndTime(); - - if (Result.TimeOffset + endTime > Time.Current) - { - OnRevertResult?.Invoke(this, Result); - - Result.TimeOffset = 0; - Result.Type = HitResult.None; - - updateState(ArmedState.Idle); - } - } - } - public override bool UpdateSubTreeMasking(Drawable source, RectangleF maskingBounds) => false; protected override void UpdateAfterChildren() @@ -636,18 +643,6 @@ namespace osu.Game.Rulesets.Objects.Drawables UpdateResult(false); } - /// - /// The maximum offset from the end time of at which this can be judged. - /// The time offset of will be clamped to this value during . - /// - /// Defaults to the miss window of . - /// - /// - /// - /// This does not affect the time offset provided to invocations of . - /// - protected virtual double MaximumJudgementOffset => HitObject.HitWindows?.WindowFor(HitResult.Miss) ?? 0; - /// /// Applies the of this , notifying responders such as /// the of the . @@ -669,7 +664,7 @@ namespace osu.Game.Rulesets.Objects.Drawables $"{GetType().ReadableName()} applied an invalid hit result (was: {Result.Type}, expected: [{Result.Judgement.MinResult} ... {Result.Judgement.MaxResult}])."); } - Result.TimeOffset = Math.Min(MaximumJudgementOffset, Time.Current - HitObject.GetEndTime()); + Result.RawTime = Time.Current; if (Result.HasResult) updateState(Result.IsHit ? ArmedState.Hit : ArmedState.Miss); @@ -734,12 +729,12 @@ namespace osu.Game.Rulesets.Objects.Drawables } } - public abstract class DrawableHitObject : DrawableHitObject + public abstract partial class DrawableHitObject : DrawableHitObject where TObject : HitObject { public new TObject HitObject => (TObject)base.HitObject; - protected DrawableHitObject(TObject hitObject) + protected DrawableHitObject([CanBeNull] TObject hitObject) : base(hitObject) { } diff --git a/osu.Game/Rulesets/Objects/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs index 57b897e5b5..25f538d211 100644 --- a/osu.Game/Rulesets/Objects/HitObject.cs +++ b/osu.Game/Rulesets/Objects/HitObject.cs @@ -1,9 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Linq; using System.Threading; using JetBrains.Annotations; using Newtonsoft.Json; @@ -106,7 +109,7 @@ namespace osu.Game.Rulesets.Objects if (legacyInfo != null) DifficultyControlPoint = (DifficultyControlPoint)legacyInfo.DifficultyPointAt(StartTime).DeepClone(); - else if (DifficultyControlPoint == DifficultyControlPoint.DEFAULT) + else if (ReferenceEquals(DifficultyControlPoint, DifficultyControlPoint.DEFAULT)) DifficultyControlPoint = new DifficultyControlPoint(); DifficultyControlPoint.Time = StartTime; @@ -116,7 +119,7 @@ namespace osu.Game.Rulesets.Objects // 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 (SampleControlPoint == SampleControlPoint.DEFAULT) + else if (ReferenceEquals(SampleControlPoint, SampleControlPoint.DEFAULT)) SampleControlPoint = new SampleControlPoint(); SampleControlPoint.Time = this.GetEndTime() + control_point_leniency; @@ -196,6 +199,29 @@ namespace osu.Game.Rulesets.Objects /// [NotNull] protected virtual HitWindows CreateHitWindows() => new HitWindows(); + + /// + /// The maximum offset from the end time of at which this can be judged. + /// + /// Defaults to the miss window. + /// + /// + public virtual double MaximumJudgementOffset => HitWindows?.WindowFor(HitResult.Miss) ?? 0; + + public IList CreateSlidingSamples() + { + var slidingSamples = new List(); + + var normalSample = Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL); + if (normalSample != null) + slidingSamples.Add(normalSample.With("sliderslide")); + + var whistleSample = Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_WHISTLE); + if (whistleSample != null) + slidingSamples.Add(whistleSample.With("sliderwhistle")); + + return slidingSamples; + } } public static class HitObjectExtensions diff --git a/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.cs b/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.cs index 096dad88bd..b517f6b9e6 100644 --- a/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.cs +++ b/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Bindables; using osu.Framework.Graphics.Performance; using osu.Game.Rulesets.Judgements; @@ -22,10 +23,12 @@ namespace osu.Game.Rulesets.Objects /// The result that was judged with. /// This is set by the accompanying , and reused when required for rewinding. /// - internal JudgementResult Result; + internal JudgementResult? Result; private readonly IBindable startTimeBindable = new BindableDouble(); + internal event Action? RevertResult; + /// /// Creates a new . /// @@ -95,5 +98,7 @@ namespace osu.Game.Rulesets.Objects /// Set using . /// internal void SetInitialLifetime() => LifetimeStart = HitObject.StartTime - InitialLifetimeOffset; + + internal void OnRevertResult() => RevertResult?.Invoke(); } } diff --git a/osu.Game/Rulesets/Objects/HitObjectParser.cs b/osu.Game/Rulesets/Objects/HitObjectParser.cs index d3c29d90ce..9728a4393b 100644 --- a/osu.Game/Rulesets/Objects/HitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/HitObjectParser.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Objects { public abstract class HitObjectParser diff --git a/osu.Game/Rulesets/Objects/HitObjectProperty.cs b/osu.Game/Rulesets/Objects/HitObjectProperty.cs new file mode 100644 index 0000000000..f1df83f80c --- /dev/null +++ b/osu.Game/Rulesets/Objects/HitObjectProperty.cs @@ -0,0 +1,52 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using JetBrains.Annotations; +using osu.Framework.Bindables; + +namespace osu.Game.Rulesets.Objects +{ + /// + /// Represents a wrapper containing a lazily-initialised , backed by a temporary field used for storage until initialisation. + /// + public struct HitObjectProperty + { + [CanBeNull] + private Bindable backingBindable; + + /// + /// A temporary field to store the current value to, prior to 's initialisation. + /// + private T backingValue; + + /// + /// The underlying , only initialised on first access. + /// + public Bindable Bindable => backingBindable ??= new Bindable(defaultValue) { Value = backingValue }; + + /// + /// The current value, derived from and delegated to if initialised, or a temporary field otherwise. + /// + public T Value + { + get => backingBindable != null ? backingBindable.Value : backingValue; + set + { + if (backingBindable != null) + backingBindable.Value = value; + else + backingValue = value; + } + } + + private readonly T defaultValue; + + public HitObjectProperty(T value = default) + { + backingValue = defaultValue = value; + backingBindable = null; + } + } +} diff --git a/osu.Game/Rulesets/Objects/IBarLine.cs b/osu.Game/Rulesets/Objects/IBarLine.cs index 14df80e3b9..8cdead6776 100644 --- a/osu.Game/Rulesets/Objects/IBarLine.cs +++ b/osu.Game/Rulesets/Objects/IBarLine.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Objects { /// diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs index 12b4812824..9facfec96f 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Objects.Types; using osuTK; diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs index 2beb6bdbf2..4861e8b3f7 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK; using osu.Game.Audio; using System.Collections.Generic; diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSlider.cs index fb1afed3b4..62726019bb 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSlider.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Objects.Types; using osuTK; diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSpinner.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSpinner.cs index 014494ec54..cccb66d92b 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSpinner.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSpinner.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Objects.Legacy.Catch diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs index e3b0d8a498..d95f97624d 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index d3d1196eae..68ca6bc506 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK; using osu.Game.Rulesets.Objects.Types; using System; @@ -197,8 +199,8 @@ namespace osu.Game.Rulesets.Objects.Legacy if (stringAddBank == @"none") stringAddBank = null; - bankInfo.Normal = stringBank; - bankInfo.Add = string.IsNullOrEmpty(stringAddBank) ? stringBank : stringAddBank; + bankInfo.BankForNormal = stringBank; + bankInfo.BankForAdditions = string.IsNullOrEmpty(stringAddBank) ? stringBank : stringAddBank; if (split.Length > 2) bankInfo.CustomSampleBank = Parsing.ParseInt(split[2]); @@ -437,40 +439,63 @@ namespace osu.Game.Rulesets.Objects.Legacy private List convertSoundType(LegacyHitSoundType type, SampleBankInfo bankInfo) { - // Todo: This should return the normal SampleInfos if the specified sample file isn't found, but that's a pretty edge-case scenario - if (!string.IsNullOrEmpty(bankInfo.Filename)) + var soundTypes = new List(); + + if (string.IsNullOrEmpty(bankInfo.Filename)) { - return new List { new FileHitSampleInfo(bankInfo.Filename, bankInfo.Volume) }; + 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))); + } + else + { + // Todo: This should set the normal SampleInfo if the specified sample file isn't found, but that's a pretty edge-case scenario + soundTypes.Add(new FileHitSampleInfo(bankInfo.Filename, bankInfo.Volume)); } - var soundTypes = new List - { - new LegacyHitSampleInfo(HitSampleInfo.HIT_NORMAL, bankInfo.Normal, 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 (type.HasFlagFast(LegacyHitSoundType.Finish)) - soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_FINISH, bankInfo.Add, bankInfo.Volume, bankInfo.CustomSampleBank)); + soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_FINISH, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.CustomSampleBank)); if (type.HasFlagFast(LegacyHitSoundType.Whistle)) - soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_WHISTLE, bankInfo.Add, bankInfo.Volume, bankInfo.CustomSampleBank)); + soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_WHISTLE, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.CustomSampleBank)); if (type.HasFlagFast(LegacyHitSoundType.Clap)) - soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_CLAP, bankInfo.Add, bankInfo.Volume, bankInfo.CustomSampleBank)); + soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_CLAP, bankInfo.BankForAdditions, bankInfo.Volume, bankInfo.CustomSampleBank)); return soundTypes; } private class SampleBankInfo { + /// + /// An optional overriding filename which causes all bank/sample specifications to be ignored. + /// public string Filename; - public string Normal; - public string Add; + /// + /// The bank identifier to use for the base ("hitnormal") sample. + /// Transferred to when appropriate. + /// + public string BankForNormal; + + /// + /// The bank identifier to use for additions ("hitwhistle", "hitfinish", "hitclap"). + /// Transferred to when appropriate. + /// + public string BankForAdditions; + + /// + /// Hit sample volume (0-100). + /// See . + /// public int Volume; + /// + /// The index of the custom sample bank. Is only used if 2 or above for "reasons". + /// This will add a suffix to lookups, allowing extended bank lookups (ie. "normal-hitnormal-2"). + /// See . + /// public int CustomSampleBank; public SampleBankInfo Clone() => (SampleBankInfo)MemberwiseClone(); @@ -501,7 +526,8 @@ namespace osu.Game.Rulesets.Objects.Legacy 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, Optional newCustomSampleBank = 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)); @@ -535,7 +561,8 @@ namespace osu.Game.Rulesets.Objects.Legacy Path.ChangeExtension(Filename, null) }; - public sealed override LegacyHitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newVolume = default, Optional newCustomSampleBank = 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 9ff92fcc75..bd2713a7d1 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Objects.Types; using System.Collections.Generic; using Newtonsoft.Json; diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHit.cs index 0b69817c13..639cacb128 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHit.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHit.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Objects.Legacy.Mania diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHitObjectParser.cs index 386eb8d3ee..6f1968b41d 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHitObjectParser.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK; using osu.Game.Audio; using System.Collections.Generic; diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHold.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHold.cs index 2fa4766c1d..b6594d0206 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHold.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHold.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Objects.Legacy.Mania diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSlider.cs index 84cde5fa95..330ebf72c7 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSlider.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Objects.Legacy.Mania diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSpinner.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSpinner.cs index c05aaceb9c..dcbaf22c51 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSpinner.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSpinner.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Objects.Legacy.Mania diff --git a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHit.cs index 069366bad3..33b390e3ba 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHit.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHit.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Objects.Types; using osuTK; diff --git a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHitObjectParser.cs index cb98721be5..7a88a31bd5 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHitObjectParser.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK; using System.Collections.Generic; using osu.Game.Audio; diff --git a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSlider.cs index e947690668..2f8e9dd352 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSlider.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Objects.Types; using osuTK; diff --git a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSpinner.cs b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSpinner.cs index e9e5ca8c94..d49e9fe9db 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSpinner.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSpinner.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Objects.Types; using osuTK; diff --git a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHit.cs index cb5178ce48..980d37ccd5 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHit.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHit.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Objects.Legacy.Taiko { /// diff --git a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHitObjectParser.cs index 1eafc4e68b..d62e8cd04c 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHitObjectParser.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK; using System.Collections.Generic; using osu.Game.Audio; diff --git a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSlider.cs index 821554f7ee..a391c8cb43 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSlider.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Objects.Legacy.Taiko { /// diff --git a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSpinner.cs b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSpinner.cs index 1d5ecb1ef3..ec8d7971ec 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSpinner.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSpinner.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Objects.Legacy.Taiko diff --git a/osu.Game/Rulesets/Objects/PathControlPoint.cs b/osu.Game/Rulesets/Objects/PathControlPoint.cs index 53eb430fa3..ae9fa08085 100644 --- a/osu.Game/Rulesets/Objects/PathControlPoint.cs +++ b/osu.Game/Rulesets/Objects/PathControlPoint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using Newtonsoft.Json; using osu.Game.Rulesets.Objects.Types; diff --git a/osu.Game/Rulesets/Objects/Pooling/HitObjectEntryManager.cs b/osu.Game/Rulesets/Objects/Pooling/HitObjectEntryManager.cs new file mode 100644 index 0000000000..6c39ea44da --- /dev/null +++ b/osu.Game/Rulesets/Objects/Pooling/HitObjectEntryManager.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.Diagnostics.CodeAnalysis; + +namespace osu.Game.Rulesets.Objects.Pooling +{ + /// + /// Manages a mapping between and + /// + internal class HitObjectEntryManager + { + /// + /// All entries, including entries of the nested hit objects. + /// + public IEnumerable AllEntries => entryMap.Values; + + /// + /// Invoked when a new is added to this .. + /// The second parameter of the event is the parent hit object. + /// + public event Action? OnEntryAdded; + + /// + /// Invoked when a is removed from this . + /// The second parameter of the event is the parent hit object. + /// + public event Action? OnEntryRemoved; + + /// + /// Provides the reverse mapping of for each entry. + /// + private readonly Dictionary entryMap = new Dictionary(); + + /// + /// Stores the parent hit object for entries of the nested hit objects. + /// + /// + /// The parent hit object of a pooled hit object may be non-pooled. + /// In that case, no corresponding is stored in this . + /// + private readonly Dictionary parentMap = new Dictionary(); + + /// + /// Stores the list of child entries for each hit object managed by this . + /// + private readonly Dictionary> childrenMap = new Dictionary>(); + + public void Add(HitObjectLifetimeEntry entry, HitObject? parent) + { + HitObject hitObject = entry.HitObject; + + if (entryMap.ContainsKey(hitObject)) + throw new InvalidOperationException($@"The {nameof(HitObjectLifetimeEntry)} is already added to this {nameof(HitObjectEntryManager)}."); + + // Add the entry. + entryMap[hitObject] = entry; + childrenMap[hitObject] = new List(); + + // If the entry has a parent, set it and add the entry to the parent's children. + if (parent != null) + { + parentMap[entry] = parent; + if (childrenMap.TryGetValue(parent, out var parentChildEntries)) + parentChildEntries.Add(entry); + } + + hitObject.DefaultsApplied += onDefaultsApplied; + OnEntryAdded?.Invoke(entry, parent); + } + + public void Remove(HitObjectLifetimeEntry entry) + { + HitObject hitObject = entry.HitObject; + + if (!entryMap.ContainsKey(hitObject)) + throw new InvalidOperationException($@"The {nameof(HitObjectLifetimeEntry)} is not contained in this {nameof(HitObjectEntryManager)}."); + + entryMap.Remove(hitObject); + + // If the entry has a parent, unset it and remove the entry from the parents' children. + if (parentMap.Remove(entry, out var parent) && childrenMap.TryGetValue(parent, out var parentChildEntries)) + parentChildEntries.Remove(entry); + + // Remove all the entries' children. + if (childrenMap.Remove(hitObject, out var childEntries)) + { + foreach (var childEntry in childEntries) + Remove(childEntry); + } + + hitObject.DefaultsApplied -= onDefaultsApplied; + OnEntryRemoved?.Invoke(entry, parent); + } + + public bool TryGet(HitObject hitObject, [MaybeNullWhen(false)] out HitObjectLifetimeEntry entry) + { + return entryMap.TryGetValue(hitObject, out entry); + } + + /// + /// As nested hit objects are recreated, remove entries of the old nested hit objects. + /// + private void onDefaultsApplied(HitObject hitObject) + { + if (!childrenMap.Remove(hitObject, out var childEntries)) + return; + + // Remove all the entries' children. At this point the parents' (this entries') children list has been removed from the map, so this does not cause upwards traversal. + foreach (var entry in childEntries) + Remove(entry); + + // The removed children list needs to be added back to the map for the entry to potentially receive children. + childEntries.Clear(); + childrenMap[hitObject] = childEntries; + } + } +} diff --git a/osu.Game/Rulesets/Objects/Pooling/PoolableDrawableWithLifetime.cs b/osu.Game/Rulesets/Objects/Pooling/PoolableDrawableWithLifetime.cs index 9c6097a048..a06f810cfe 100644 --- a/osu.Game/Rulesets/Objects/Pooling/PoolableDrawableWithLifetime.cs +++ b/osu.Game/Rulesets/Objects/Pooling/PoolableDrawableWithLifetime.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 enable - using System; using System.Diagnostics; using osu.Framework.Graphics; @@ -15,7 +13,7 @@ namespace osu.Game.Rulesets.Objects.Pooling /// A that is controlled by to implement drawable pooling and replay rewinding. /// /// The type storing state and controlling this drawable. - public abstract class PoolableDrawableWithLifetime : PoolableDrawable where TEntry : LifetimeEntry + public abstract partial class PoolableDrawableWithLifetime : PoolableDrawable where TEntry : LifetimeEntry { private TEntry? entry; diff --git a/osu.Game/Rulesets/Objects/Pooling/PooledDrawableWithLifetimeContainer.cs b/osu.Game/Rulesets/Objects/Pooling/PooledDrawableWithLifetimeContainer.cs index d35933dba8..3b45acc7bb 100644 --- a/osu.Game/Rulesets/Objects/Pooling/PooledDrawableWithLifetimeContainer.cs +++ b/osu.Game/Rulesets/Objects/Pooling/PooledDrawableWithLifetimeContainer.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 enable - using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -19,7 +17,7 @@ namespace osu.Game.Rulesets.Objects.Pooling /// /// The type of entries managed by this container. /// The type of drawables corresponding to the entries. - public abstract class PooledDrawableWithLifetimeContainer : CompositeDrawable + public abstract partial class PooledDrawableWithLifetimeContainer : CompositeDrawable where TEntry : LifetimeEntry where TDrawable : Drawable { @@ -134,7 +132,7 @@ namespace osu.Game.Rulesets.Objects.Pooling /// /// Invoked when the entry became dead. /// - protected virtual void RemoveDrawable(TEntry entry, TDrawable drawable) => RemoveInternal(drawable); + protected virtual void RemoveDrawable(TEntry entry, TDrawable drawable) => RemoveInternal(drawable, false); private void entryCrossedBoundary(LifetimeEntry lifetimeEntry, LifetimeBoundaryKind kind, LifetimeBoundaryCrossingDirection direction) { diff --git a/osu.Game/Rulesets/Objects/SliderEventGenerator.cs b/osu.Game/Rulesets/Objects/SliderEventGenerator.cs index bae5a5e8d9..d32a7cb16d 100644 --- a/osu.Game/Rulesets/Objects/SliderEventGenerator.cs +++ b/osu.Game/Rulesets/Objects/SliderEventGenerator.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; diff --git a/osu.Game/Rulesets/Objects/SliderPath.cs b/osu.Game/Rulesets/Objects/SliderPath.cs index e0c62fe359..13cc6361cf 100644 --- a/osu.Game/Rulesets/Objects/SliderPath.cs +++ b/osu.Game/Rulesets/Objects/SliderPath.cs @@ -1,9 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Collections.Specialized; +using System.Diagnostics; using System.Linq; using Newtonsoft.Json; using osu.Framework.Bindables; @@ -39,6 +42,7 @@ namespace osu.Game.Rulesets.Objects private readonly List calculatedPath = new List(); private readonly List cumulativeLength = new List(); + private readonly List segmentEnds = new List(); private readonly Cached pathCache = new Cached(); private double calculatedLength; @@ -55,12 +59,16 @@ namespace osu.Game.Rulesets.Objects switch (args.Action) { case NotifyCollectionChangedAction.Add: + Debug.Assert(args.NewItems != null); + foreach (var c in args.NewItems.Cast()) c.Changed += invalidate; break; case NotifyCollectionChangedAction.Reset: case NotifyCollectionChangedAction.Remove: + Debug.Assert(args.OldItems != null); + foreach (var c in args.OldItems.Cast()) c.Changed -= invalidate; break; @@ -85,7 +93,7 @@ namespace osu.Game.Rulesets.Objects } public SliderPath(PathType type, Vector2[] controlPoints, double? expectedDistance = null) - : this(controlPoints.Select((c, i) => new PathControlPoint(c, i == 0 ? (PathType?)type : null)).ToArray(), expectedDistance) + : this(controlPoints.Select((c, i) => new PathControlPoint(c, i == 0 ? type : null)).ToArray(), expectedDistance) { } @@ -189,6 +197,16 @@ namespace osu.Game.Rulesets.Objects return pointsInCurrentSegment; } + /// + /// Returns the progress values at which segments of the path end. + /// + public IEnumerable GetSegmentEnds() + { + ensureValid(); + + return segmentEnds.Select(i => cumulativeLength[i] / calculatedLength); + } + private void invalidate() { pathCache.Invalidate(); @@ -209,6 +227,7 @@ namespace osu.Game.Rulesets.Objects private void calculatePath() { calculatedPath.Clear(); + segmentEnds.Clear(); if (ControlPoints.Count == 0) return; @@ -234,6 +253,9 @@ namespace osu.Game.Rulesets.Objects calculatedPath.Add(t); } + // Remember the index of the segment end + segmentEnds.Add(calculatedPath.Count - 1); + // Start the new segment at the current vertex start = i; } @@ -299,6 +321,10 @@ namespace osu.Game.Rulesets.Objects { cumulativeLength.RemoveAt(cumulativeLength.Count - 1); calculatedPath.RemoveAt(pathEndIndex--); + + // Shorten the last segment to the expected distance + if (segmentEnds.Count > 0) + segmentEnds[^1]--; } } diff --git a/osu.Game/Rulesets/Objects/SliderPathExtensions.cs b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs index dd418a1b7b..92a3b570fb 100644 --- a/osu.Game/Rulesets/Objects/SliderPathExtensions.cs +++ b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs @@ -6,8 +6,6 @@ using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects.Types; using osuTK; -#nullable enable - namespace osu.Game.Rulesets.Objects { public static class SliderPathExtensions diff --git a/osu.Game/Rulesets/Objects/SyntheticHitObjectEntry.cs b/osu.Game/Rulesets/Objects/SyntheticHitObjectEntry.cs index 76f9eaf25a..ee860e82e2 100644 --- a/osu.Game/Rulesets/Objects/SyntheticHitObjectEntry.cs +++ b/osu.Game/Rulesets/Objects/SyntheticHitObjectEntry.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Rulesets.Objects diff --git a/osu.Game/Rulesets/Objects/Types/IHasColumn.cs b/osu.Game/Rulesets/Objects/Types/IHasColumn.cs index dc07cfbb6a..3978a7e765 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasColumn.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasColumn.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Objects.Types { /// diff --git a/osu.Game/Rulesets/Objects/Types/IHasCombo.cs b/osu.Game/Rulesets/Objects/Types/IHasCombo.cs index d1a4683a1d..d02b97a3e4 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasCombo.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasCombo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Objects.Types { /// diff --git a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs index 29a56fc625..b45ea989f3 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . 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; diff --git a/osu.Game/Rulesets/Objects/Types/IHasDisplayColour.cs b/osu.Game/Rulesets/Objects/Types/IHasDisplayColour.cs index 8807b802d8..89ee5022bf 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasDisplayColour.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasDisplayColour.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osuTK.Graphics; diff --git a/osu.Game/Rulesets/Objects/Types/IHasDistance.cs b/osu.Game/Rulesets/Objects/Types/IHasDistance.cs index b497ca5da3..549abc046a 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasDistance.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasDistance.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Objects.Types { /// diff --git a/osu.Game/Rulesets/Objects/Types/IHasDuration.cs b/osu.Game/Rulesets/Objects/Types/IHasDuration.cs index ca734da5ad..06ed8eba76 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasDuration.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasDuration.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Objects.Types { /// diff --git a/osu.Game/Rulesets/Objects/Types/IHasHold.cs b/osu.Game/Rulesets/Objects/Types/IHasHold.cs index 469b8b7892..91b05dc3fd 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasHold.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasHold.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Objects.Types { /// diff --git a/osu.Game/Rulesets/Objects/Types/IHasLegacyLastTickOffset.cs b/osu.Game/Rulesets/Objects/Types/IHasLegacyLastTickOffset.cs index caf22c3023..dfc526383a 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasLegacyLastTickOffset.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasLegacyLastTickOffset.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Objects.Types { /// diff --git a/osu.Game/Rulesets/Objects/Types/IHasPath.cs b/osu.Game/Rulesets/Objects/Types/IHasPath.cs index 567c24a4a2..46834a55dd 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasPath.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasPath.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Objects.Types { public interface IHasPath : IHasDistance diff --git a/osu.Game/Rulesets/Objects/Types/IHasPathWithRepeats.cs b/osu.Game/Rulesets/Objects/Types/IHasPathWithRepeats.cs index 279946b44e..536707e95f 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasPathWithRepeats.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasPathWithRepeats.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK; namespace osu.Game.Rulesets.Objects.Types diff --git a/osu.Game/Rulesets/Objects/Types/IHasPosition.cs b/osu.Game/Rulesets/Objects/Types/IHasPosition.cs index 8948fe59a9..281f619ba5 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasPosition.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasPosition.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK; namespace osu.Game.Rulesets.Objects.Types diff --git a/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs b/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs index 2a4215b960..821a6de520 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Audio; using System.Collections.Generic; diff --git a/osu.Game/Rulesets/Objects/Types/IHasXPosition.cs b/osu.Game/Rulesets/Objects/Types/IHasXPosition.cs index 7e55b21050..f688c783e1 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasXPosition.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasXPosition.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Objects.Types { /// diff --git a/osu.Game/Rulesets/Objects/Types/IHasYPosition.cs b/osu.Game/Rulesets/Objects/Types/IHasYPosition.cs index d2561b10a7..3c0cc595fb 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasYPosition.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasYPosition.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Objects.Types { /// diff --git a/osu.Game/Rulesets/Objects/Types/PathType.cs b/osu.Game/Rulesets/Objects/Types/PathType.cs index 923ce9eba4..266a3de6ec 100644 --- a/osu.Game/Rulesets/Objects/Types/PathType.cs +++ b/osu.Game/Rulesets/Objects/Types/PathType.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Objects.Types { public enum PathType diff --git a/osu.Game/Rulesets/RealmRulesetStore.cs b/osu.Game/Rulesets/RealmRulesetStore.cs index f42bf06da4..456f6e399b 100644 --- a/osu.Game/Rulesets/RealmRulesetStore.cs +++ b/osu.Game/Rulesets/RealmRulesetStore.cs @@ -5,12 +5,10 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Extensions.ObjectExtensions; -using osu.Framework.Logging; using osu.Framework.Platform; +using osu.Game.Beatmaps; using osu.Game.Database; -#nullable enable - namespace osu.Game.Rulesets { public class RealmRulesetStore : RulesetStore @@ -70,12 +68,25 @@ namespace osu.Game.Rulesets { try { - var resolvedType = Type.GetType(r.InstantiationInfo) - ?? throw new RulesetLoadException(@"Type could not be resolved"); + var resolvedType = Type.GetType(r.InstantiationInfo); - var instanceInfo = (Activator.CreateInstance(resolvedType) as Ruleset)?.RulesetInfo + if (resolvedType == null) + { + // ruleset DLL was probably deleted. + r.Available = false; + continue; + } + + var instance = (Activator.CreateInstance(resolvedType) as Ruleset); + var instanceInfo = instance?.RulesetInfo ?? throw new RulesetLoadException(@"Instantiation failure"); + if (!checkRulesetUpToDate(instance)) + { + throw new ArgumentOutOfRangeException(nameof(instance.RulesetAPIVersionSupported), + $"Ruleset API version is too old (was {instance.RulesetAPIVersionSupported}, expected {Ruleset.CURRENT_RULESET_API_VERSION})"); + } + // If a ruleset isn't up-to-date with the API, it could cause a crash at an arbitrary point of execution. // To eagerly handle cases of missing implementations, enumerate all types here and mark as non-available on throw. resolvedType.Assembly.GetTypes(); @@ -85,17 +96,52 @@ namespace osu.Game.Rulesets r.InstantiationInfo = instanceInfo.InstantiationInfo; r.Available = true; + testRulesetCompatibility(r); + detachedRulesets.Add(r.Clone()); } catch (Exception ex) { r.Available = false; - Logger.Log($"Could not load ruleset {r}: {ex.Message}"); + LogFailedLoad(r.Name, ex); } } availableRulesets.AddRange(detachedRulesets.OrderBy(r => r)); }); } + + private bool checkRulesetUpToDate(Ruleset instance) + { + switch (instance.RulesetAPIVersionSupported) + { + // The default `virtual` implementation leaves the version string empty. + // Consider rulesets which haven't override the version as up-to-date for now. + // At some point (once ruleset devs add versioning), we'll probably want to disallow this for deployed builds. + case @"": + // Ruleset is up-to-date, all good. + case Ruleset.CURRENT_RULESET_API_VERSION: + return true; + + default: + return false; + } + } + + private void testRulesetCompatibility(RulesetInfo rulesetInfo) + { + // do various operations to ensure that we are in a good state. + // if we can avoid loading the ruleset at this point (rather than erroring later in runtime) then that is preferred. + var instance = rulesetInfo.CreateInstance(); + + instance.CreateAllMods(); + instance.CreateIcon(); + instance.CreateResourceStore(); + + var beatmap = new Beatmap(); + var converter = instance.CreateBeatmapConverter(beatmap); + + instance.CreateBeatmapProcessor(converter.Convert()); + } } } diff --git a/osu.Game/Rulesets/Replays/AutoGenerator.cs b/osu.Game/Rulesets/Replays/AutoGenerator.cs index 83e85146d4..f4b96b3884 100644 --- a/osu.Game/Rulesets/Replays/AutoGenerator.cs +++ b/osu.Game/Rulesets/Replays/AutoGenerator.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; -using JetBrains.Annotations; using osu.Game.Beatmaps; using osu.Game.Replays; using osu.Game.Rulesets.Objects; @@ -32,7 +31,7 @@ namespace osu.Game.Rulesets.Replays /// public abstract Replay Generate(); - protected virtual HitObject GetNextObject(int currentIndex) + protected virtual HitObject? GetNextObject(int currentIndex) { if (currentIndex >= Beatmap.HitObjects.Count - 1) return null; @@ -49,8 +48,7 @@ namespace osu.Game.Rulesets.Replays /// protected readonly List Frames = new List(); - [CanBeNull] - protected TFrame LastFrame => Frames.Count == 0 ? null : Frames[^1]; + protected TFrame? LastFrame => Frames.Count == 0 ? null : Frames[^1]; protected AutoGenerator(IBeatmap beatmap) : base(beatmap) diff --git a/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs b/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs index f889d15485..020bae1c42 100644 --- a/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs +++ b/osu.Game/Rulesets/Replays/FramedReplayInputHandler.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 enable - using System; using System.Collections.Generic; using System.Linq; @@ -163,7 +161,7 @@ namespace osu.Game.Rulesets.Replays CurrentTime = Math.Clamp(time, frameStart, frameEnd); // In an important section, a mid-frame time cannot be used and a null is returned instead. - return inImportantSection && frameStart < time && time < frameEnd ? null : (double?)CurrentTime; + return inImportantSection && frameStart < time && time < frameEnd ? null : CurrentTime; } private double getFrameTime(int index) diff --git a/osu.Game/Rulesets/Replays/ReplayFrame.cs b/osu.Game/Rulesets/Replays/ReplayFrame.cs index 2b67b60d8f..433be6e4b7 100644 --- a/osu.Game/Rulesets/Replays/ReplayFrame.cs +++ b/osu.Game/Rulesets/Replays/ReplayFrame.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 enable - using MessagePack; using osu.Game.Online.Spectator; diff --git a/osu.Game/Rulesets/Replays/Types/IConvertibleReplayFrame.cs b/osu.Game/Rulesets/Replays/Types/IConvertibleReplayFrame.cs index d9aa615c6e..9a4af9e4ee 100644 --- a/osu.Game/Rulesets/Replays/Types/IConvertibleReplayFrame.cs +++ b/osu.Game/Rulesets/Replays/Types/IConvertibleReplayFrame.cs @@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Replays.Types /// The to extract values from. /// The beatmap. /// The last post-conversion , used to fill in missing delta information. May be null. - void FromLegacy(LegacyReplayFrame currentFrame, IBeatmap beatmap, ReplayFrame lastFrame = null); + void FromLegacy(LegacyReplayFrame currentFrame, IBeatmap beatmap, ReplayFrame? lastFrame = null); /// /// Populates this using values from a . diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index b7c6132bdb..fcf7a78090 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -5,32 +5,32 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using osu.Framework.Extensions; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; 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.Overlays.Settings; -using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Replays.Types; -using osu.Game.Rulesets.UI; using osu.Game.Beatmaps.Legacy; using osu.Game.Configuration; +using osu.Game.Extensions; +using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Difficulty; -using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring; -using osu.Game.Skinning; -using osu.Game.Users; -using JetBrains.Annotations; -using osu.Framework.Extensions; -using osu.Framework.Extensions.EnumExtensions; -using osu.Framework.Testing; -using osu.Game.Extensions; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Filter; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Replays.Types; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Ranking.Statistics; +using osu.Game.Skinning; +using osu.Game.Users; namespace osu.Game.Rulesets { @@ -41,6 +41,24 @@ namespace osu.Game.Rulesets private static readonly ConcurrentDictionary mod_reference_cache = new ConcurrentDictionary(); + /// + /// Version history: + /// 2022.205.0 FramedReplayInputHandler.CollectPendingInputs renamed to FramedReplayHandler.CollectReplayInputs. + /// 2022.822.0 All strings return values have been converted to LocalisableString to allow for localisation support. + /// + public const string CURRENT_RULESET_API_VERSION = "2022.822.0"; + + /// + /// Define the ruleset API version supported by this ruleset. + /// Ruleset implementations should be updated to support the latest version to ensure they can still be loaded. + /// + /// + /// Generally, all ruleset implementations should point this directly to . + /// This will ensure that each time you compile a new release, it will pull in the most recent version. + /// See https://github.com/ppy/osu/wiki/Breaking-Changes for full details on required ongoing changes. + /// + public virtual string RulesetAPIVersionSupported => string.Empty; + /// /// A queryable source containing all available mods. /// Call for consumption purposes. @@ -67,10 +85,12 @@ namespace osu.Game.Rulesets /// This comes with considerable allocation overhead. If only accessing for reference purposes (ie. not changing bindables / settings) /// use instead. /// - public IEnumerable CreateAllMods() => Enum.GetValues(typeof(ModType)).Cast() + public IEnumerable CreateAllMods() => Enum.GetValues() // Confine all mods of each mod type into a single IEnumerable .SelectMany(GetModsFor) // Filter out all null mods + // This is to handle old rulesets which were doing mods bad. Can be removed at some point we are sure nulls will not appear here. + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract .Where(mod => mod != null) // Resolve MultiMods as their .Mods property .SelectMany(mod => (mod as MultiMod)?.Mods ?? new[] { mod }); @@ -79,7 +99,7 @@ namespace osu.Game.Rulesets /// Returns a fresh instance of the mod matching the specified acronym. /// /// The acronym to query for . - public Mod CreateModFromAcronym(string acronym) + public Mod? CreateModFromAcronym(string acronym) { return AllMods.FirstOrDefault(m => m.Acronym == acronym)?.CreateInstance(); } @@ -87,12 +107,20 @@ namespace osu.Game.Rulesets /// /// Returns a fresh instance of the mod matching the specified type. /// - public T CreateMod() + public T? CreateMod() where T : Mod { return AllMods.FirstOrDefault(m => m is T)?.CreateInstance() as T; } + /// + /// Creates an enumerable with mods that are supported by the ruleset for the supplied . + /// + /// + /// If there are no applicable mods from the given in this ruleset, + /// then the proper behaviour is to return an empty enumerable. + /// mods should not be present in the returned enumerable. + /// public abstract IEnumerable GetModsFor(ModType type); /// @@ -115,55 +143,55 @@ namespace osu.Game.Rulesets { switch (mod) { - case ModNoFail _: + case ModNoFail: value |= LegacyMods.NoFail; break; - case ModEasy _: + case ModEasy: value |= LegacyMods.Easy; break; - case ModHidden _: + case ModHidden: value |= LegacyMods.Hidden; break; - case ModHardRock _: + case ModHardRock: value |= LegacyMods.HardRock; break; - case ModPerfect _: - value |= LegacyMods.Perfect; + case ModPerfect: + value |= LegacyMods.Perfect | LegacyMods.SuddenDeath; break; - case ModSuddenDeath _: + case ModSuddenDeath: value |= LegacyMods.SuddenDeath; break; - case ModNightcore _: - value |= LegacyMods.Nightcore; + case ModNightcore: + value |= LegacyMods.Nightcore | LegacyMods.DoubleTime; break; - case ModDoubleTime _: + case ModDoubleTime: value |= LegacyMods.DoubleTime; break; - case ModRelax _: + case ModRelax: value |= LegacyMods.Relax; break; - case ModHalfTime _: + case ModHalfTime: value |= LegacyMods.HalfTime; break; - case ModFlashlight _: + case ModFlashlight: value |= LegacyMods.Flashlight; break; - case ModCinema _: - value |= LegacyMods.Cinema; + case ModCinema: + value |= LegacyMods.Cinema | LegacyMods.Autoplay; break; - case ModAutoplay _: + case ModAutoplay: value |= LegacyMods.Autoplay; break; } @@ -172,10 +200,15 @@ namespace osu.Game.Rulesets return value; } - [CanBeNull] - public ModAutoplay GetAutoplayMod() => CreateMod(); + public ModAutoplay? GetAutoplayMod() => CreateMod(); - public virtual ISkin CreateLegacySkinProvider([NotNull] ISkin skin, IBeatmap beatmap) => null; + /// + /// Create a transformer which adds lookups specific to a ruleset to skin sources. + /// + /// The source skin. + /// The current beatmap. + /// A skin with a transformer applied, or null if no transformation is provided by this ruleset. + public virtual ISkin? CreateSkinTransformer(ISkin skin, IBeatmap beatmap) => null; protected Ruleset() { @@ -195,7 +228,7 @@ namespace osu.Game.Rulesets /// The beatmap to create the hit renderer for. /// The s to apply. /// Unable to successfully load the beatmap to be usable with this ruleset. - public abstract DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null); + public abstract DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList? mods = null); /// /// Creates a for this . @@ -221,7 +254,7 @@ namespace osu.Game.Rulesets /// /// The to be processed. /// The . - public virtual IBeatmapProcessor CreateBeatmapProcessor(IBeatmap beatmap) => null; + public virtual IBeatmapProcessor? CreateBeatmapProcessor(IBeatmap beatmap) => null; public abstract DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap); @@ -229,12 +262,11 @@ namespace osu.Game.Rulesets /// Optionally creates a to generate performance data from the provided score. /// /// A performance calculator instance for the provided score. - [CanBeNull] - public virtual PerformanceCalculator CreatePerformanceCalculator() => null; + public virtual PerformanceCalculator? CreatePerformanceCalculator() => null; - public virtual HitObjectComposer CreateHitObjectComposer() => null; + public virtual HitObjectComposer? CreateHitObjectComposer() => null; - public virtual IBeatmapVerifier CreateBeatmapVerifier() => null; + public virtual IBeatmapVerifier? CreateBeatmapVerifier() => null; public virtual Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.Solid.QuestionCircle }; @@ -242,13 +274,13 @@ namespace osu.Game.Rulesets public abstract string Description { get; } - public virtual RulesetSettingsSubsection CreateSettings() => null; + public virtual RulesetSettingsSubsection? CreateSettings() => null; /// /// Creates the for this . /// /// The to store the settings. - public virtual IRulesetConfigManager CreateConfig(SettingsStore settings) => null; + public virtual IRulesetConfigManager? CreateConfig(SettingsStore? settings) => null; /// /// A unique short name to reference this ruleset in online requests. @@ -277,14 +309,14 @@ namespace osu.Game.Rulesets /// /// The variant. /// A descriptive name of the variant. - public virtual string GetVariantName(int variant) => string.Empty; + public virtual LocalisableString GetVariantName(int variant) => string.Empty; /// /// For rulesets which support legacy (osu-stable) replay conversion, this method will create an empty replay frame /// for conversion use. /// /// An empty frame for the current ruleset, or null if unsupported. - public virtual IConvertibleReplayFrame CreateConvertibleReplayFrame() => null; + public virtual IConvertibleReplayFrame? CreateConvertibleReplayFrame() => null; /// /// Creates the statistics for a to be displayed in the results screen. @@ -292,7 +324,6 @@ 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 . - [NotNull] public virtual StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => Array.Empty(); /// @@ -302,7 +333,7 @@ namespace osu.Game.Rulesets /// /// All valid s along with a display-friendly name. /// - public IEnumerable<(HitResult result, string displayName)> GetHitResults() + public IEnumerable<(HitResult result, LocalisableString displayName)> GetHitResults() { var validResults = GetValidHitResults(); @@ -340,18 +371,16 @@ namespace osu.Game.Rulesets /// /// The result type to get the name for. /// The display name. - public virtual string GetDisplayNameForHitResult(HitResult result) => result.GetDescription(); + public virtual LocalisableString GetDisplayNameForHitResult(HitResult result) => result.GetLocalisableDescription(); /// /// Creates ruleset-specific beatmap filter criteria to be used on the song select screen. /// - [CanBeNull] - public virtual IRulesetFilterCriteria CreateRulesetFilterCriteria() => null; + public virtual IRulesetFilterCriteria? CreateRulesetFilterCriteria() => null; /// /// Can be overridden to add a ruleset-specific section to the editor beatmap setup screen. /// - [CanBeNull] - public virtual RulesetSetupSection CreateEditorSetupSection() => null; + public virtual RulesetSetupSection? CreateEditorSetupSection() => null; } } diff --git a/osu.Game/Rulesets/RulesetConfigCache.cs b/osu.Game/Rulesets/RulesetConfigCache.cs index c4f1933cd8..79dff37280 100644 --- a/osu.Game/Rulesets/RulesetConfigCache.cs +++ b/osu.Game/Rulesets/RulesetConfigCache.cs @@ -11,12 +11,12 @@ using osu.Game.Rulesets.Configuration; namespace osu.Game.Rulesets { - public class RulesetConfigCache : Component, IRulesetConfigCache + public partial class RulesetConfigCache : Component, IRulesetConfigCache { private readonly RealmAccess realm; private readonly RulesetStore rulesets; - private readonly Dictionary configCache = new Dictionary(); + private readonly Dictionary configCache = new Dictionary(); public RulesetConfigCache(RealmAccess realm, RulesetStore rulesets) { @@ -40,7 +40,7 @@ namespace osu.Game.Rulesets } } - public IRulesetConfigManager GetConfigFor(Ruleset ruleset) + public IRulesetConfigManager? GetConfigFor(Ruleset ruleset) { if (!IsLoaded) throw new InvalidOperationException($@"Cannot retrieve {nameof(IRulesetConfigManager)} before {nameof(RulesetConfigCache)} has loaded"); diff --git a/osu.Game/Rulesets/RulesetInfo.cs b/osu.Game/Rulesets/RulesetInfo.cs index 88e3988431..6a4e0f0b48 100644 --- a/osu.Game/Rulesets/RulesetInfo.cs +++ b/osu.Game/Rulesets/RulesetInfo.cs @@ -4,10 +4,9 @@ using System; using JetBrains.Annotations; using osu.Framework.Testing; +using osu.Game.Rulesets.Difficulty; using Realms; -#nullable enable - namespace osu.Game.Rulesets { [ExcludeFromDynamicCompile] @@ -24,6 +23,11 @@ namespace osu.Game.Rulesets public string InstantiationInfo { get; set; } = string.Empty; + /// + /// Stores the last applied + /// + public int LastAppliedDifficultyVersion { get; set; } + public RulesetInfo(string shortName, string name, string instantiationInfo, int onlineID) { ShortName = shortName; @@ -49,21 +53,21 @@ namespace osu.Game.Rulesets public bool Equals(IRulesetInfo? other) => other is RulesetInfo r && Equals(r); - public int CompareTo(RulesetInfo other) + public int CompareTo(RulesetInfo? other) { - if (OnlineID >= 0 && other.OnlineID >= 0) + if (OnlineID >= 0 && other?.OnlineID >= 0) return OnlineID.CompareTo(other.OnlineID); // Official rulesets are always given precedence for the time being. if (OnlineID >= 0) return -1; - if (other.OnlineID >= 0) + if (other?.OnlineID >= 0) return 1; - return string.Compare(ShortName, other.ShortName, StringComparison.Ordinal); + return string.Compare(ShortName, other?.ShortName, StringComparison.Ordinal); } - public int CompareTo(IRulesetInfo other) + public int CompareTo(IRulesetInfo? other) { if (!(other is RulesetInfo ruleset)) throw new ArgumentException($@"Object is not of type {nameof(RulesetInfo)}.", nameof(other)); @@ -88,7 +92,8 @@ namespace osu.Game.Rulesets Name = Name, ShortName = ShortName, InstantiationInfo = InstantiationInfo, - Available = Available + Available = Available, + LastAppliedDifficultyVersion = LastAppliedDifficultyVersion, }; public Ruleset CreateInstance() diff --git a/osu.Game/Rulesets/RulesetLoadException.cs b/osu.Game/Rulesets/RulesetLoadException.cs index 7c3a4bb75d..6fee8f446b 100644 --- a/osu.Game/Rulesets/RulesetLoadException.cs +++ b/osu.Game/Rulesets/RulesetLoadException.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; namespace osu.Game.Rulesets diff --git a/osu.Game/Rulesets/RulesetSelector.cs b/osu.Game/Rulesets/RulesetSelector.cs index 8e6ec556d2..ba10033a98 100644 --- a/osu.Game/Rulesets/RulesetSelector.cs +++ b/osu.Game/Rulesets/RulesetSelector.cs @@ -1,23 +1,41 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.UserInterface; using osu.Framework.Allocation; +using osu.Framework.Logging; +using osu.Game.Extensions; namespace osu.Game.Rulesets { - public abstract class RulesetSelector : TabControl + public abstract partial class RulesetSelector : TabControl { [Resolved] protected RulesetStore Rulesets { get; private set; } protected override Dropdown CreateDropdown() => null; + protected virtual bool LegacyOnly => false; + [BackgroundDependencyLoader] private void load() { - foreach (var r in Rulesets.AvailableRulesets) - AddItem(r); + foreach (var ruleset in Rulesets.AvailableRulesets) + { + if (!ruleset.IsLegacyRuleset() && LegacyOnly) + continue; + + try + { + AddItem(ruleset); + } + catch + { + Logger.Log($"Could not create ruleset icon for {ruleset.Name}. Please check for an update from the developer.", level: LogLevel.Error); + } + } } } } diff --git a/osu.Game/Rulesets/RulesetStore.cs b/osu.Game/Rulesets/RulesetStore.cs index 6f88d97a58..881b09bd1b 100644 --- a/osu.Game/Rulesets/RulesetStore.cs +++ b/osu.Game/Rulesets/RulesetStore.cs @@ -10,8 +10,6 @@ using osu.Framework; using osu.Framework.Logging; using osu.Framework.Platform; -#nullable enable - namespace osu.Game.Rulesets { public abstract class RulesetStore : IDisposable, IRulesetStore @@ -77,10 +75,7 @@ namespace osu.Game.Rulesets return false; return args.Name.Contains(name, StringComparison.Ordinal); - }) - // Pick the greatest assembly version. - .OrderByDescending(a => a.GetName().Version) - .FirstOrDefault(); + }).MaxBy(a => a.GetName().Version); if (domainAssembly != null) return domainAssembly; @@ -116,7 +111,10 @@ namespace osu.Game.Rulesets { try { - string[] files = Directory.GetFiles(RuntimeInfo.StartupDirectory, @$"{ruleset_library_prefix}.*.dll"); + // On net6-android (Debug), StartupDirectory can be different from where assemblies are placed. + // Search sub-directories too. + + string[] files = Directory.GetFiles(RuntimeInfo.StartupDirectory, @$"{ruleset_library_prefix}.*.dll", SearchOption.AllDirectories); foreach (string file in files.Where(f => !Path.GetFileName(f).Contains("Tests"))) loadRulesetFromFile(file); @@ -129,7 +127,7 @@ namespace osu.Game.Rulesets private void loadRulesetFromFile(string file) { - string? filename = Path.GetFileNameWithoutExtension(file); + string filename = Path.GetFileNameWithoutExtension(file); if (LoadedAssemblies.Values.Any(t => Path.GetFileNameWithoutExtension(t.Assembly.Location) == filename)) return; @@ -140,7 +138,7 @@ namespace osu.Game.Rulesets } catch (Exception e) { - Logger.Error(e, $"Failed to load ruleset {filename}"); + LogFailedLoad(filename, e); } } @@ -160,7 +158,7 @@ namespace osu.Game.Rulesets } catch (Exception e) { - Logger.Error(e, $"Failed to add ruleset {assembly}"); + LogFailedLoad(assembly.GetName().Name!.Split('.').Last(), e); } } @@ -170,11 +168,17 @@ namespace osu.Game.Rulesets GC.SuppressFinalize(this); } - protected virtual void Dispose(bool disposing) + protected void Dispose(bool disposing) { AppDomain.CurrentDomain.AssemblyResolve -= resolveRulesetDependencyAssembly; } + protected void LogFailedLoad(string name, Exception exception) + { + Logger.Log($"Could not load ruleset \"{name}\". Please check for an update from the developer.", level: LogLevel.Error); + Logger.Log($"Ruleset load failed: {exception}"); + } + #region Implementation of IRulesetStore IRulesetInfo? IRulesetStore.GetRuleset(int id) => GetRuleset(id); diff --git a/osu.Game/Rulesets/Scoring/AccumulatingHealthProcessor.cs b/osu.Game/Rulesets/Scoring/AccumulatingHealthProcessor.cs index 5dfb5167f4..af6e825b06 100644 --- a/osu.Game/Rulesets/Scoring/AccumulatingHealthProcessor.cs +++ b/osu.Game/Rulesets/Scoring/AccumulatingHealthProcessor.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.Scoring { /// /// A that accumulates health and causes a fail if the final health /// is less than a value required to pass the beatmap. /// - public class AccumulatingHealthProcessor : HealthProcessor + public partial class AccumulatingHealthProcessor : HealthProcessor { protected override bool DefaultFailCondition => JudgedHits == MaxHits && Health.Value < requiredHealth; diff --git a/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs b/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs index e7bb6d79f8..592dcbfeb8 100644 --- a/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs +++ b/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -17,7 +19,7 @@ namespace osu.Game.Rulesets.Scoring /// At HP=5, the minimum health reached for a perfect play is 70%.
/// At HP=10, the minimum health reached for a perfect play is 30%. ///
- public class DrainingHealthProcessor : HealthProcessor + public partial class DrainingHealthProcessor : HealthProcessor { /// /// A reasonable allowable error for the minimum health offset from . A 1% error is unnoticeable. @@ -63,7 +65,7 @@ namespace osu.Game.Rulesets.Scoring public DrainingHealthProcessor(double drainStartTime, double drainLenience = 0) { this.drainStartTime = drainStartTime; - this.drainLenience = drainLenience; + this.drainLenience = Math.Clamp(drainLenience, 0, 1); } protected override void Update() @@ -77,7 +79,8 @@ namespace osu.Game.Rulesets.Scoring double lastGameplayTime = Math.Clamp(Time.Current - Time.Elapsed, drainStartTime, gameplayEndTime); double currentGameplayTime = Math.Clamp(Time.Current, drainStartTime, gameplayEndTime); - Health.Value -= drainRate * (currentGameplayTime - lastGameplayTime); + if (drainLenience < 1) + Health.Value -= drainRate * (currentGameplayTime - lastGameplayTime); } public override void ApplyBeatmap(IBeatmap beatmap) diff --git a/osu.Game/Rulesets/Scoring/HealthProcessor.cs b/osu.Game/Rulesets/Scoring/HealthProcessor.cs index 4f5ff95477..3e0b6433c2 100644 --- a/osu.Game/Rulesets/Scoring/HealthProcessor.cs +++ b/osu.Game/Rulesets/Scoring/HealthProcessor.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 enable - using System; using osu.Framework.Bindables; using osu.Framework.Utils; @@ -10,7 +8,7 @@ using osu.Game.Rulesets.Judgements; namespace osu.Game.Rulesets.Scoring { - public abstract class HealthProcessor : JudgementProcessor + public abstract partial class HealthProcessor : JudgementProcessor { /// /// Invoked when the is in a failed state. @@ -88,7 +86,7 @@ namespace osu.Game.Rulesets.Scoring { foreach (var condition in FailConditions.GetInvocationList()) { - bool conditionResult = (bool)condition.Method.Invoke(condition.Target, new object[] { this, result }); + bool conditionResult = (bool)condition.Method.Invoke(condition.Target, new object[] { this, result })!; if (conditionResult) return true; } diff --git a/osu.Game/Rulesets/Scoring/HitEvent.cs b/osu.Game/Rulesets/Scoring/HitEvent.cs index 0ebbec62ba..cabbf40a7d 100644 --- a/osu.Game/Rulesets/Scoring/HitEvent.cs +++ b/osu.Game/Rulesets/Scoring/HitEvent.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using osu.Game.Rulesets.Objects; using osuTK; diff --git a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs index fea13cf4b6..2fde73d5a2 100644 --- a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs +++ b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs index 9f03c381ee..83ed98768c 100644 --- a/osu.Game/Rulesets/Scoring/HitResult.cs +++ b/osu.Game/Rulesets/Scoring/HitResult.cs @@ -2,10 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; using System.Linq; using System.Runtime.Serialization; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Utils; namespace osu.Game.Rulesets.Scoring @@ -117,10 +119,24 @@ namespace osu.Game.Rulesets.Scoring [EnumMember(Value = "ignore_hit")] [Order(12)] IgnoreHit, + + /// + /// A special result used as a padding value for legacy rulesets. It is a hit type and affects combo, but does not affect the base score (does not affect accuracy). + /// + /// + /// DO NOT USE. + /// + [EnumMember(Value = "legacy_combo_increase")] + [Order(99)] + [Obsolete("Do not use.")] + LegacyComboIncrease = 99 } +#pragma warning disable CS0618 public static class HitResultExtensions { + private static readonly IList order = EnumExtensions.GetValuesInOrder().ToList(); + /// /// Whether a increases the combo. /// @@ -148,6 +164,7 @@ namespace osu.Game.Rulesets.Scoring case HitResult.Perfect: case HitResult.LargeTickHit: case HitResult.LargeTickMiss: + case HitResult.LegacyComboIncrease: return true; default: @@ -159,13 +176,25 @@ namespace osu.Game.Rulesets.Scoring /// Whether a affects the accuracy portion of the score. /// public static bool AffectsAccuracy(this HitResult result) - => IsScorable(result) && !IsBonus(result); + { + // LegacyComboIncrease is a special type which is neither a basic, tick, bonus, or accuracy-affecting result. + if (result == HitResult.LegacyComboIncrease) + return false; + + return IsScorable(result) && !IsBonus(result); + } /// /// Whether a is a non-tick and non-bonus result. /// public static bool IsBasic(this HitResult result) - => IsScorable(result) && !IsTick(result) && !IsBonus(result); + { + // LegacyComboIncrease is a special type which is neither a basic, tick, bonus, or accuracy-affecting result. + if (result == HitResult.LegacyComboIncrease) + return false; + + return IsScorable(result) && !IsTick(result) && !IsBonus(result); + } /// /// Whether a should be counted as a tick. @@ -223,12 +252,19 @@ namespace osu.Game.Rulesets.Scoring /// /// Whether a is scorable. /// - public static bool IsScorable(this HitResult result) => result >= HitResult.Miss && result < HitResult.IgnoreMiss; + public static bool IsScorable(this HitResult result) + { + // LegacyComboIncrease is not actually scorable (in terms of usable by rulesets for that purpose), but needs to be defined as such to be correctly included in statistics output. + if (result == HitResult.LegacyComboIncrease) + return true; + + return result >= HitResult.Miss && result < HitResult.IgnoreMiss; + } /// /// An array of all scorable s. /// - public static readonly HitResult[] ALL_TYPES = ((HitResult[])Enum.GetValues(typeof(HitResult))).ToArray(); + public static readonly HitResult[] ALL_TYPES = Enum.GetValues().Except(new[] { HitResult.LegacyComboIncrease }).ToArray(); /// /// Whether a is valid within a given range. @@ -248,5 +284,13 @@ namespace osu.Game.Rulesets.Scoring Debug.Assert(minResult <= maxResult); return result > minResult && result < maxResult; } + + /// + /// Ordered index of a . Used for consistent order when displaying hit results to the user. + /// + /// The to get the index of. + /// The index of . + public static int GetIndexForOrderedDisplay(this HitResult result) => order.IndexOf(result); } +#pragma warning restore CS0618 } diff --git a/osu.Game/Rulesets/Scoring/HitWindows.cs b/osu.Game/Rulesets/Scoring/HitWindows.cs index 2d008b58ba..99129fcf96 100644 --- a/osu.Game/Rulesets/Scoring/HitWindows.cs +++ b/osu.Game/Rulesets/Scoring/HitWindows.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; diff --git a/osu.Game/Rulesets/Scoring/JudgementProcessor.cs b/osu.Game/Rulesets/Scoring/JudgementProcessor.cs index bfa67b8c45..09b5f0a6bc 100644 --- a/osu.Game/Rulesets/Scoring/JudgementProcessor.cs +++ b/osu.Game/Rulesets/Scoring/JudgementProcessor.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 enable - using System; using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; @@ -15,7 +13,7 @@ using osu.Game.Rulesets.Replays; namespace osu.Game.Rulesets.Scoring { - public abstract class JudgementProcessor : Component + public abstract partial class JudgementProcessor : Component { /// /// Invoked when a new judgement has occurred. This occurs after the judgement has been processed by this . @@ -63,6 +61,11 @@ namespace osu.Game.Rulesets.Scoring /// The to apply. public void ApplyResult(JudgementResult result) { +#pragma warning disable CS0618 + if (result.Type == HitResult.LegacyComboIncrease) + throw new ArgumentException(@$"A {nameof(HitResult.LegacyComboIncrease)} hit result cannot be applied."); +#pragma warning restore CS0618 + JudgedHits++; lastAppliedResult = result; diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index df094ddb7c..96f6922224 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.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 enable - using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.Contracts; using System.Linq; using osu.Framework.Bindables; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Extensions; -using osu.Game.Online.Spectator; +using osu.Game.Localisation; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; @@ -21,8 +20,15 @@ using osu.Game.Scoring; namespace osu.Game.Rulesets.Scoring { - public class ScoreProcessor : JudgementProcessor + public partial class ScoreProcessor : JudgementProcessor { + private const double accuracy_cutoff_x = 1; + private const double accuracy_cutoff_s = 0.95; + private const double accuracy_cutoff_a = 0.9; + private const double accuracy_cutoff_b = 0.8; + private const double accuracy_cutoff_c = 0.7; + private const double accuracy_cutoff_d = 0; + private const double max_score = 1000000; /// @@ -33,13 +39,25 @@ namespace osu.Game.Rulesets.Scoring /// /// The current total score. /// - public readonly BindableDouble TotalScore = new BindableDouble { MinValue = 0 }; + public readonly BindableLong TotalScore = new BindableLong { MinValue = 0 }; /// /// The current accuracy. /// public readonly BindableDouble Accuracy = new BindableDouble(1) { MinValue = 0, MaxValue = 1 }; + /// + /// The minimum achievable accuracy for the whole beatmap at this stage of gameplay. + /// Assumes that all objects that have not been judged yet will receive the minimum hit result. + /// + public readonly BindableDouble MinimumAccuracy = new BindableDouble { MinValue = 0, MaxValue = 1 }; + + /// + /// The maximum achievable accuracy for the whole beatmap at this stage of gameplay. + /// Assumes that all objects that have not been judged yet will receive the maximum hit result. + /// + public readonly BindableDouble MaximumAccuracy = new BindableDouble(1) { MinValue = 0, MaxValue = 1 }; + /// /// The current combo. /// @@ -86,21 +104,22 @@ namespace osu.Game.Rulesets.Scoring /// protected virtual double ClassicScoreMultiplier => 36; - private readonly Ruleset ruleset; + /// + /// The ruleset this score processor is valid for. + /// + public readonly Ruleset Ruleset; + private readonly double accuracyPortion; private readonly double comboPortion; - /// - /// Scoring values for a perfect play. - /// - public ScoringValues MaximumScoringValues + public Dictionary MaximumStatistics { get { if (!beatmapApplied) - throw new InvalidOperationException($"Cannot access maximum scoring values before calling {nameof(ApplyBeatmap)}."); + throw new InvalidOperationException($"Cannot access maximum statistics before calling {nameof(ApplyBeatmap)}."); - return maximumScoringValues; + return new Dictionary(maximumResultCounts); } } @@ -121,13 +140,15 @@ namespace osu.Game.Rulesets.Scoring /// /// The maximum of a basic (non-tick and non-bonus) hitobject. - /// Only populated via or . + /// Only populated via or . /// private HitResult? maxBasicResult; private bool beatmapApplied; private readonly Dictionary scoreResultCounts = new Dictionary(); + private readonly Dictionary maximumResultCounts = new Dictionary(); + private readonly List hitEvents = new List(); private HitObject? lastHitObject; @@ -135,7 +156,7 @@ namespace osu.Game.Rulesets.Scoring public ScoreProcessor(Ruleset ruleset) { - this.ruleset = ruleset; + Ruleset = ruleset; accuracyPortion = DefaultAccuracyPortion; comboPortion = DefaultComboPortion; @@ -146,7 +167,7 @@ namespace osu.Game.Rulesets.Scoring Combo.ValueChanged += combo => HighestCombo.Value = Math.Max(HighestCombo.Value, combo.NewValue); Accuracy.ValueChanged += accuracy => { - Rank.Value = rankFrom(accuracy.NewValue); + Rank.Value = RankFromAccuracy(accuracy.NewValue); foreach (var mod in Mods.Value.OfType()) Rank.Value = mod.AdjustRank(Rank.Value, accuracy.NewValue); }; @@ -265,12 +286,33 @@ namespace osu.Game.Rulesets.Scoring private void updateScore() { - Accuracy.Value = currentMaximumScoringValues.BaseScore > 0 ? currentScoringValues.BaseScore / currentMaximumScoringValues.BaseScore : 1; - TotalScore.Value = ComputeScore(Mode.Value, currentScoringValues, maximumScoringValues); + 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); } /// - /// Computes the total score of a given finalised . This should be used when a score is known to be complete. + /// Computes the accuracy of a given . + /// + /// The to compute the total score of. + /// The score's accuracy. + [Pure] + public double ComputeAccuracy(ScoreInfo scoreInfo) + { + 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. @@ -279,68 +321,14 @@ namespace osu.Game.Rulesets.Scoring /// The to compute the total score of. /// The total score in the given . [Pure] - public double ComputeFinalScore(ScoringMode mode, ScoreInfo scoreInfo) + 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}\"."); + 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); + extractScoringValues(scoreInfo, out var current, out var maximum); - return ComputeScore(mode, current, maximum); - } - - /// - /// Computes the total score of a partially-completed . This should be used when it is unknown whether a score is complete. - /// - /// - /// Requires 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 double ComputePartialScore(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}\"."); - - if (!beatmapApplied) - throw new InvalidOperationException($"Cannot compute partial score without calling {nameof(ApplyBeatmap)}."); - - ExtractScoringValues(scoreInfo, out var current, out _); - - return ComputeScore(mode, current, MaximumScoringValues); - } - - /// - /// Computes the total score of a given with a given custom max achievable combo. - /// - /// - /// This is useful for processing legacy scores in which the maximum achievable combo can be more accurately determined via external means (e.g. database values or difficulty calculation). - ///

Does not require to have been called before use.

- ///
- /// The to represent the score as. - /// The to compute the total score of. - /// The maximum achievable combo for the provided beatmap. - /// The total score in the given . - [Pure] - public double ComputeFinalLegacyScore(ScoringMode mode, ScoreInfo scoreInfo, int maxAchievableCombo) - { - if (!ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) - throw new ArgumentException($"Unexpected score ruleset. Expected \"{ruleset.RulesetInfo.ShortName}\" but was \"{scoreInfo.Ruleset.ShortName}\"."); - - double accuracyRatio = scoreInfo.Accuracy; - double comboRatio = maxAchievableCombo > 0 ? (double)scoreInfo.MaxCombo / maxAchievableCombo : 1; - - ExtractScoringValues(scoreInfo, out var current, out var maximum); - - // For legacy osu!mania scores, a full-GREAT score has 100% accuracy. If combined with a full-combo, the score becomes indistinguishable from a full-PERFECT score. - // To get around this, the accuracy ratio is always recalculated based on the hit statistics rather than trusting the score. - // Note: This cannot be applied universally to all legacy scores, as some rulesets (e.g. catch) group multiple judgements together. - if (scoreInfo.IsLegacyScore && scoreInfo.Ruleset.OnlineID == 3 && maximum.BaseScore > 0) - accuracyRatio = current.BaseScore / maximum.BaseScore; - - return ComputeScore(mode, accuracyRatio, comboRatio, current.BonusScore, maximum.CountBasicHitObjects); + return computeScore(mode, current, maximum); } /// @@ -351,9 +339,9 @@ namespace osu.Game.Rulesets.Scoring /// The maximum scoring values. /// The total score computed from the given scoring values. [Pure] - public double ComputeScore(ScoringMode mode, ScoringValues current, ScoringValues maximum) + private long computeScore(ScoringMode mode, ScoringValues current, ScoringValues maximum) { - double accuracyRatio = maximum.BaseScore > 0 ? current.BaseScore / maximum.BaseScore : 1; + 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); } @@ -368,42 +356,26 @@ namespace osu.Game.Rulesets.Scoring /// 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 double ComputeScore(ScoringMode mode, double accuracyRatio, double comboRatio, double bonusScore, int totalBasicHitObjects) + 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: - double accuracyScore = accuracyPortion * accuracyRatio; - double comboScore = comboPortion * comboRatio; - return (max_score * (accuracyScore + comboScore) + bonusScore) * scoreMultiplier; + 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 scaledStandardised = ComputeScore(ScoringMode.Standardised, accuracyRatio, comboRatio, bonusScore, totalBasicHitObjects) / max_score; - return Math.Pow(scaledStandardised * Math.Max(1, totalBasicHitObjects), 2) * ClassicScoreMultiplier; + double scaledRawScore = rawScore / max_score; + return (long)Math.Round(Math.Pow(scaledRawScore * Math.Max(1, totalBasicHitObjects), 2) * ClassicScoreMultiplier); } } - private ScoreRank rankFrom(double acc) - { - if (acc == 1) - return ScoreRank.X; - if (acc >= 0.95) - return ScoreRank.S; - if (acc >= 0.9) - return ScoreRank.A; - if (acc >= 0.8) - return ScoreRank.B; - if (acc >= 0.7) - return ScoreRank.C; - - return ScoreRank.D; - } - - public int GetStatistic(HitResult result) => scoreResultCounts.GetValueOrDefault(result); - /// /// Resets this ScoreProcessor to a default state. /// @@ -412,19 +384,26 @@ namespace osu.Game.Rulesets.Scoring { base.Reset(storeResults); - scoreResultCounts.Clear(); hitEvents.Clear(); lastHitObject = null; if (storeResults) + { maximumScoringValues = currentScoringValues; + maximumResultCounts.Clear(); + maximumResultCounts.AddRange(scoreResultCounts); + } + + scoreResultCounts.Clear(); + currentScoringValues = default; currentMaximumScoringValues = default; TotalScore.Value = 0; Accuracy.Value = 1; Combo.Value = 0; + Rank.Disabled = false; Rank.Value = ScoreRank.X; HighestCombo.Value = 0; } @@ -439,12 +418,31 @@ namespace osu.Game.Rulesets.Scoring score.Accuracy = Accuracy.Value; score.Rank = Rank.Value; score.HitEvents = hitEvents; + score.Statistics.Clear(); + score.MaximumStatistics.Clear(); foreach (var result in HitResultExtensions.ALL_TYPES) - score.Statistics[result] = GetStatistic(result); + score.Statistics[result] = scoreResultCounts.GetValueOrDefault(result); + + foreach (var result in HitResultExtensions.ALL_TYPES) + score.MaximumStatistics[result] = maximumResultCounts.GetValueOrDefault(result); // Populate total score after everything else. - score.TotalScore = (long)Math.Round(ComputeFinalScore(ScoringMode.Standardised, score)); + score.TotalScore = ComputeScore(ScoringMode.Standardised, score); + } + + /// + /// Populates a failed score, marking it with the rank. + /// + public void FailScore(ScoreInfo score) + { + if (Rank.Value == ScoreRank.F) + return; + + score.Passed = false; + Rank.Value = ScoreRank.F; + + PopulateScore(score); } public override void ResetFromReplayFrame(ReplayFrame frame) @@ -485,42 +483,20 @@ namespace osu.Game.Rulesets.Scoring /// 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] - internal void ExtractScoringValues(ScoreInfo scoreInfo, out ScoringValues current, out ScoringValues maximum) + private void extractScoringValues(ScoreInfo scoreInfo, out ScoringValues current, out ScoringValues maximum) { extractScoringValues(scoreInfo.Statistics, out current, out maximum); current.MaxCombo = scoreInfo.MaxCombo; - } - /// - /// 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 replay frame header 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] - internal void ExtractScoringValues(FrameHeader header, out ScoringValues current, out ScoringValues maximum) - { - extractScoringValues(header.Statistics, out current, out maximum); - current.MaxCombo = header.MaxCombo; + if (scoreInfo.MaximumStatistics.Count > 0) + extractScoringValues(scoreInfo.MaximumStatistics, out _, out maximum); } /// @@ -551,7 +527,8 @@ namespace osu.Game.Rulesets.Scoring if (result.IsBonus()) current.BonusScore += count * Judgement.ToNumericResult(result); - else + + 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). @@ -570,7 +547,7 @@ namespace osu.Game.Rulesets.Scoring break; default: - maxResult = maxBasicResult ??= ruleset.GetHitResults().OrderByDescending(kvp => Judgement.ToNumericResult(kvp.result)).First().result; + maxResult = maxBasicResult ??= Ruleset.GetHitResults().MaxBy(kvp => Judgement.ToNumericResult(kvp.result)).result; break; } @@ -596,11 +573,96 @@ namespace osu.Game.Rulesets.Scoring base.Dispose(isDisposing); hitEvents.Clear(); } + + #region Static helper methods + + /// + /// Given an accuracy (0..1), return the correct . + /// + public static ScoreRank RankFromAccuracy(double accuracy) + { + if (accuracy == accuracy_cutoff_x) + return ScoreRank.X; + if (accuracy >= accuracy_cutoff_s) + return ScoreRank.S; + if (accuracy >= accuracy_cutoff_a) + return ScoreRank.A; + if (accuracy >= accuracy_cutoff_b) + return ScoreRank.B; + if (accuracy >= accuracy_cutoff_c) + return ScoreRank.C; + + return ScoreRank.D; + } + + /// + /// Given a , return the cutoff accuracy (0..1). + /// Accuracy must be greater than or equal to the cutoff to qualify for the provided rank. + /// + public static double AccuracyCutoffFromRank(ScoreRank rank) + { + switch (rank) + { + case ScoreRank.X: + case ScoreRank.XH: + return accuracy_cutoff_x; + + case ScoreRank.S: + case ScoreRank.SH: + return accuracy_cutoff_s; + + case ScoreRank.A: + return accuracy_cutoff_a; + + case ScoreRank.B: + return accuracy_cutoff_b; + + case ScoreRank.C: + return accuracy_cutoff_c; + + case ScoreRank.D: + return accuracy_cutoff_d; + + default: + throw new ArgumentOutOfRangeException(nameof(rank), rank, null); + } + } + + #endregion + + /// + /// Stores the required scoring data that fulfils the minimum requirements for a to calculate score. + /// + private struct ScoringValues + { + /// + /// 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; + } } public enum ScoringMode { + [LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.StandardisedScoreDisplay))] Standardised, + + [LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.ClassicScoreDisplay))] Classic } } diff --git a/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs b/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs index 23325bcd13..279de2f940 100644 --- a/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs +++ b/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Game.Beatmaps.ControlPoints; @@ -9,12 +11,12 @@ namespace osu.Game.Rulesets.Timing /// /// A control point which adds an aggregated multiplier based on the provided 's BeatLength and 's SpeedMultiplier. /// - public class MultiplierControlPoint : IComparable + public class MultiplierControlPoint : IComparable, IControlPoint { /// /// The time in milliseconds at which this starts. /// - public double StartTime; + public double Time { get; set; } /// /// The aggregate multiplier which this provides. @@ -52,13 +54,13 @@ namespace osu.Game.Rulesets.Timing /// /// Creates a . /// - /// The start time of this . - public MultiplierControlPoint(double startTime) + /// The start time of this . + public MultiplierControlPoint(double time) { - StartTime = startTime; + Time = time; } // ReSharper disable once ImpureMethodCallOnReadonlyValueField - public int CompareTo(MultiplierControlPoint other) => StartTime.CompareTo(other?.StartTime); + public int CompareTo(MultiplierControlPoint other) => Time.CompareTo(other?.Time); } } diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index be1105e7ff..4f22c0c617 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -28,6 +30,8 @@ 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; namespace osu.Game.Rulesets.UI @@ -36,7 +40,7 @@ namespace osu.Game.Rulesets.UI /// Displays an interactive ruleset gameplay instance. /// /// The type of HitObject contained by this DrawableRuleset. - public abstract class DrawableRuleset : DrawableRuleset, IProvideCursor, ICanAttachKeyCounter + public abstract partial class DrawableRuleset : DrawableRuleset, IProvideCursor, ICanAttachHUDPieces where TObject : HitObject { public override event Action NewResult; @@ -50,7 +54,7 @@ namespace osu.Game.Rulesets.UI /// /// The key conversion input manager for this DrawableRuleset. /// - public PassThroughInputManager KeyBindingInputManager; + protected PassThroughInputManager KeyBindingInputManager; public override double GameplayStartTime => Objects.FirstOrDefault()?.StartTime - 2000 ?? 0; @@ -63,9 +67,13 @@ namespace osu.Game.Rulesets.UI public override Container Overlays { get; } = new Container { RelativeSizeAxes = Axes.Both }; + public override IAdjustableAudioComponent Audio => audioContainer; + + private readonly AudioContainer audioContainer = new AudioContainer { RelativeSizeAxes = Axes.Both }; + public override Container FrameStableComponents { get; } = new Container { RelativeSizeAxes = Axes.Both }; - public override IFrameStableClock FrameStableClock => frameStabilityContainer.FrameStableClock; + public override IFrameStableClock FrameStableClock => frameStabilityContainer; private bool frameStablePlayback = true; @@ -99,14 +107,6 @@ namespace osu.Game.Rulesets.UI private DrawableRulesetDependencies dependencies; - /// - /// Audio adjustments which are applied to the playfield. - /// - /// - /// Does not affect . - /// - public IAdjustableAudioComponent Audio { get; private set; } - /// /// Creates a ruleset visualisation for the provided ruleset and beatmap. /// @@ -131,7 +131,7 @@ namespace osu.Game.Rulesets.UI playfield = new Lazy(() => CreatePlayfield().With(p => { p.NewResult += (_, r) => NewResult?.Invoke(r); - p.RevertResult += (_, r) => RevertResult?.Invoke(r); + p.RevertResult += r => RevertResult?.Invoke(r); })); } @@ -169,28 +169,22 @@ namespace osu.Game.Rulesets.UI [BackgroundDependencyLoader] private void load(CancellationToken? cancellationToken) { - AudioContainer audioContainer; - InternalChild = frameStabilityContainer = new FrameStabilityContainer(GameplayStartTime) { FrameStablePlayback = FrameStablePlayback, Children = new Drawable[] { FrameStableComponents, - audioContainer = new AudioContainer - { - RelativeSizeAxes = Axes.Both, - Child = KeyBindingInputManager - .WithChild(CreatePlayfieldAdjustmentContainer() - .WithChild(Playfield) - ), - }, - Overlays, + audioContainer.WithChild(KeyBindingInputManager + .WithChildren(new Drawable[] + { + CreatePlayfieldAdjustmentContainer() + .WithChild(Playfield), + Overlays + })), } }; - Audio = audioContainer; - if ((ResumeOverlay = CreateResumeOverlay()) != null) { AddInternal(CreateInputManager() @@ -227,7 +221,7 @@ namespace osu.Game.Rulesets.UI public override void RequestResume(Action continueResume) { - if (ResumeOverlay != null && (Cursor == null || (Cursor.LastFrameState == Visibility.Visible && Contains(Cursor.ActiveCursor.ScreenSpaceDrawQuad.Centre)))) + if (ResumeOverlay != null && UseResumeOverlay && (Cursor == null || (Cursor.LastFrameState == Visibility.Visible && Contains(Cursor.ActiveCursor.ScreenSpaceDrawQuad.Centre)))) { ResumeOverlay.GameplayCursor = Cursor; ResumeOverlay.ResumeAction = continueResume; @@ -336,7 +330,10 @@ namespace osu.Game.Rulesets.UI public abstract DrawableHitObject CreateDrawableRepresentation(TObject h); public void Attach(KeyCounterDisplay keyCounter) => - (KeyBindingInputManager as ICanAttachKeyCounter)?.Attach(keyCounter); + (KeyBindingInputManager as ICanAttachHUDPieces)?.Attach(keyCounter); + + public void Attach(ClicksPerSecondCalculator calculator) => + (KeyBindingInputManager as ICanAttachHUDPieces)?.Attach(calculator); /// /// Creates a key conversion input manager. An exception will be thrown if a valid is not returned. @@ -408,7 +405,7 @@ namespace osu.Game.Rulesets.UI /// /// [Cached(typeof(DrawableRuleset))] - public abstract class DrawableRuleset : CompositeDrawable + public abstract partial class DrawableRuleset : CompositeDrawable { /// /// Invoked when a has been applied by a . @@ -430,13 +427,18 @@ namespace osu.Game.Rulesets.UI /// public readonly BindableBool IsPaused = new BindableBool(); + /// + /// Audio adjustments which are applied to the playfield. + /// + public abstract IAdjustableAudioComponent Audio { get; } + /// /// The playfield. /// public abstract Playfield Playfield { get; } /// - /// Content to be placed above hitobjects. Will be affected by frame stability. + /// Content to be placed above hitobjects. Will be affected by frame stability and adjustments applied to . /// public abstract Container Overlays { get; } @@ -493,6 +495,7 @@ namespace osu.Game.Rulesets.UI /// /// The cursor being displayed by the . May be null if no cursor is provided. /// + [CanBeNull] public abstract GameplayCursorContainer Cursor { get; } /// @@ -500,6 +503,15 @@ namespace osu.Game.Rulesets.UI /// public ResumeOverlay ResumeOverlay { get; protected set; } + /// + /// Whether the should be used to return the user's cursor position to its previous location after a pause. + /// + /// + /// Defaults to true. + /// Even if true, will not have any effect if the ruleset does not have a resume overlay (see ). + /// + public bool UseResumeOverlay { get; set; } = true; + /// /// Returns first available provided by a . /// @@ -524,6 +536,11 @@ namespace osu.Game.Rulesets.UI } } + /// + /// Create an optional resume overlay, which is displayed when a player requests to resume gameplay during non-break time. + /// This can be used to force the player to return their hands / cursor to the position they left off, to avoid players + /// using pauses as a means of adjusting their inputs (aka "pause buffering"). + /// protected virtual ResumeOverlay CreateResumeOverlay() => null; /// diff --git a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs index 53996b6b84..e34289c968 100644 --- a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs +++ b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs @@ -10,7 +10,8 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Shaders; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; @@ -24,45 +25,50 @@ 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; } /// - /// The ruleset config manager. + /// The ruleset config manager. May be null if ruleset does not expose a configuration manager. /// - public IRulesetConfigManager RulesetConfigManager { get; private set; } + public IRulesetConfigManager? RulesetConfigManager { get; } public DrawableRulesetDependencies(Ruleset ruleset, IReadOnlyDependencyContainer parent) : base(parent) { var resources = ruleset.CreateResourceStore(); - if (resources != null) - { - TextureStore = new TextureStore(parent.Get().CreateTextureLoaderStore(new NamespacedResourceStore(resources, @"Textures"))); - CacheAs(TextureStore = new FallbackTextureStore(TextureStore, parent.Get())); + var host = parent.Get(); - SampleStore = parent.Get().GetSampleStore(new NamespacedResourceStore(resources, @"Samples")); - SampleStore.PlaybackConcurrency = OsuGameBase.SAMPLE_CONCURRENCY; - CacheAs(SampleStore = new FallbackSampleStore(SampleStore, parent.Get())); + TextureStore = new TextureStore(host.Renderer, parent.Get().CreateTextureLoaderStore(new NamespacedResourceStore(resources, @"Textures"))); + CacheAs(TextureStore = new FallbackTextureStore(host.Renderer, TextureStore, parent.Get())); - ShaderManager = new ShaderManager(new NamespacedResourceStore(resources, @"Shaders")); - CacheAs(ShaderManager = new FallbackShaderManager(ShaderManager, parent.Get())); - } + SampleStore = parent.Get().GetSampleStore(new NamespacedResourceStore(resources, @"Samples")); + SampleStore.PlaybackConcurrency = OsuGameBase.SAMPLE_CONCURRENCY; + CacheAs(SampleStore = new FallbackSampleStore(SampleStore, parent.Get())); + + CacheAs(ShaderManager = new RulesetShaderManager(host.Renderer, new NamespacedResourceStore(resources, @"Shaders"), parent.Get())); RulesetConfigManager = parent.Get().GetConfigFor(ruleset); if (RulesetConfigManager != null) @@ -92,10 +98,9 @@ namespace osu.Game.Rulesets.UI isDisposed = true; - SampleStore?.Dispose(); - TextureStore?.Dispose(); - ShaderManager?.Dispose(); - RulesetConfigManager = null; + if (SampleStore.IsNotNull()) SampleStore.Dispose(); + if (TextureStore.IsNotNull()) TextureStore.Dispose(); + if (ShaderManager.IsNotNull()) ShaderManager.Dispose(); } #endregion @@ -116,7 +121,11 @@ namespace osu.Game.Rulesets.UI public Sample Get(string name) => primary.Get(name) ?? fallback.Get(name); - public Task GetAsync(string name, CancellationToken cancellationToken = default) => primary.GetAsync(name, cancellationToken) ?? fallback.GetAsync(name, cancellationToken); + public async Task GetAsync(string name, CancellationToken cancellationToken = default) + { + return await primary.GetAsync(name, cancellationToken).ConfigureAwait(false) + ?? await fallback.GetAsync(name, cancellationToken).ConfigureAwait(false); + } public Stream GetStream(string name) => primary.GetStream(name) ?? fallback.GetStream(name); @@ -154,9 +163,11 @@ namespace osu.Game.Rulesets.UI set => throw new NotSupportedException(); } + public void AddExtension(string extension) => throw new NotSupportedException(); + public void Dispose() { - primary?.Dispose(); + if (primary.IsNotNull()) primary.Dispose(); } } @@ -168,7 +179,8 @@ namespace osu.Game.Rulesets.UI private readonly TextureStore primary; private readonly TextureStore fallback; - public FallbackTextureStore(TextureStore primary, TextureStore fallback) + public FallbackTextureStore(IRenderer renderer, TextureStore primary, TextureStore fallback) + : base(renderer) { this.primary = primary; this.fallback = fallback; @@ -180,29 +192,25 @@ namespace osu.Game.Rulesets.UI protected override void Dispose(bool disposing) { base.Dispose(disposing); - primary?.Dispose(); + if (primary.IsNotNull()) primary.Dispose(); } } - private class FallbackShaderManager : ShaderManager + private class RulesetShaderManager : ShaderManager { - private readonly ShaderManager primary; - private readonly ShaderManager fallback; + private readonly ShaderManager parent; - public FallbackShaderManager(ShaderManager primary, ShaderManager fallback) - : base(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); - 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/DrawableRulesetDependenciesProvidingContainer.cs b/osu.Game/Rulesets/UI/DrawableRulesetDependenciesProvidingContainer.cs new file mode 100644 index 0000000000..6c213497dd --- /dev/null +++ b/osu.Game/Rulesets/UI/DrawableRulesetDependenciesProvidingContainer.cs @@ -0,0 +1,36 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Rulesets.UI +{ + public partial class DrawableRulesetDependenciesProvidingContainer : Container + { + private readonly Ruleset ruleset; + + private DrawableRulesetDependencies rulesetDependencies = null!; + + public DrawableRulesetDependenciesProvidingContainer(Ruleset ruleset) + { + this.ruleset = ruleset; + RelativeSizeAxes = Axes.Both; + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + return rulesetDependencies = new DrawableRulesetDependencies(ruleset, base.CreateChildDependencies(parent)); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (rulesetDependencies.IsNotNull()) + rulesetDependencies.Dispose(); + } + } +} diff --git a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs index f2dbb1a23f..4bb145973d 100644 --- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs +++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs @@ -2,9 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; -using System.Linq; +using System.Diagnostics; using osu.Framework.Allocation; +using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -18,9 +18,11 @@ namespace osu.Game.Rulesets.UI /// A container which consumes a parent gameplay clock and standardises frame counts for children. /// Will ensure a minimum of 50 frames per clock second is maintained, regardless of any system lag or seeks. /// - public class FrameStabilityContainer : Container, IHasReplayHandler + [Cached(typeof(IGameplayClock))] + [Cached(typeof(IFrameStableClock))] + public sealed partial class FrameStabilityContainer : Container, IHasReplayHandler, IFrameStableClock { - private readonly double gameplayStartTime; + public ReplayInputHandler? ReplayInputHandler { get; set; } /// /// The number of frames (per parent frame) which can be run in an attempt to catch-up to real-time. @@ -30,28 +32,35 @@ namespace osu.Game.Rulesets.UI /// /// Whether to enable frame-stable playback. /// - internal bool FrameStablePlayback = true; + internal bool FrameStablePlayback { get; set; } = true; - public IFrameStableClock FrameStableClock => frameStableClock; + protected override bool RequiresChildrenUpdate => base.RequiresChildrenUpdate && state != PlaybackState.NotValid; - [Cached(typeof(GameplayClock))] - private readonly FrameStabilityClock frameStableClock; + private readonly Bindable isCatchingUp = new Bindable(); - public FrameStabilityContainer(double gameplayStartTime = double.MinValue) - { - RelativeSizeAxes = Axes.Both; + private readonly Bindable waitingOnFrames = new Bindable(); - frameStableClock = new FrameStabilityClock(framedClock = new FramedClock(manualClock = new ManualClock())); + private readonly double gameplayStartTime; - this.gameplayStartTime = gameplayStartTime; - } + private IGameplayClock? parentGameplayClock; + /// + /// A clock which is used as reference for time, rate and running state. + /// + private IClock referenceClock = null!; + + /// + /// A local manual clock which tracks the reference clock. + /// Values are transferred from each update call. + /// private readonly ManualClock manualClock; + /// + /// The main framed clock which has stability applied to it. + /// This gets exposed to children as an . + /// private readonly FramedClock framedClock; - private IFrameBasedClock parentGameplayClock; - /// /// The current direction of playback to be exposed to frame stable children. /// @@ -60,32 +69,34 @@ namespace osu.Game.Rulesets.UI /// private int direction = 1; - [BackgroundDependencyLoader(true)] - private void load(GameplayClock clock) - { - if (clock != null) - { - parentGameplayClock = frameStableClock.ParentGameplayClock = clock; - frameStableClock.IsPaused.BindTo(clock.IsPaused); - } - } - - protected override void LoadComplete() - { - base.LoadComplete(); - setClock(); - } - private PlaybackState state; - protected override bool RequiresChildrenUpdate => base.RequiresChildrenUpdate && state != PlaybackState.NotValid; - private bool hasReplayAttached => ReplayInputHandler != null; - private const double sixty_frame_time = 1000.0 / 60; - private bool firstConsumption = true; + public FrameStabilityContainer(double gameplayStartTime = double.MinValue) + { + RelativeSizeAxes = Axes.Both; + + framedClock = new FramedClock(manualClock = new ManualClock()); + + this.gameplayStartTime = gameplayStartTime; + } + + [BackgroundDependencyLoader(true)] + private void load(IGameplayClock? gameplayClock) + { + if (gameplayClock != null) + { + parentGameplayClock = gameplayClock; + IsPaused.BindTo(parentGameplayClock.IsPaused); + } + + referenceClock = gameplayClock ?? Clock; + Clock = this; + } + public override bool UpdateSubTree() { int loops = MaxCatchUpFrames; @@ -108,12 +119,12 @@ namespace osu.Game.Rulesets.UI private void updateClock() { - if (frameStableClock.WaitingOnFrames.Value) + if (waitingOnFrames.Value) { // if waiting on frames, run one update loop to determine if frames have arrived. state = PlaybackState.Valid; } - else if (frameStableClock.IsPaused.Value) + else if (IsPaused.Value) { // time should not advance while paused, nor should anything run. state = PlaybackState.NotValid; @@ -124,10 +135,7 @@ namespace osu.Game.Rulesets.UI state = PlaybackState.Valid; } - if (parentGameplayClock == null) - setClock(); // LoadComplete may not be run yet, but we still want the clock. - - double proposedTime = parentGameplayClock.CurrentTime; + double proposedTime = referenceClock.CurrentTime; if (FrameStablePlayback) // if we require frame stability, the proposed time will be adjusted to move at most one known @@ -147,14 +155,14 @@ namespace osu.Game.Rulesets.UI if (state == PlaybackState.Valid && proposedTime != manualClock.CurrentTime) direction = proposedTime >= manualClock.CurrentTime ? 1 : -1; - double timeBehind = Math.Abs(proposedTime - parentGameplayClock.CurrentTime); + double timeBehind = Math.Abs(proposedTime - referenceClock.CurrentTime); - frameStableClock.IsCatchingUp.Value = timeBehind > 200; - frameStableClock.WaitingOnFrames.Value = state == PlaybackState.NotValid; + isCatchingUp.Value = timeBehind > 200; + waitingOnFrames.Value = state == PlaybackState.NotValid; manualClock.CurrentTime = proposedTime; - manualClock.Rate = Math.Abs(parentGameplayClock.Rate) * direction; - manualClock.IsRunning = parentGameplayClock.IsRunning; + manualClock.Rate = Math.Abs(referenceClock.Rate) * direction; + manualClock.IsRunning = referenceClock.IsRunning; // determine whether catch-up is required. if (state == PlaybackState.Valid && timeBehind > 0) @@ -172,6 +180,8 @@ namespace osu.Game.Rulesets.UI /// Whether playback is still valid. private bool updateReplay(ref double proposedTime) { + Debug.Assert(ReplayInputHandler != null); + double? newTime; if (FrameStablePlayback) @@ -208,6 +218,8 @@ namespace osu.Game.Rulesets.UI /// The time which is to be displayed. private void applyFrameStability(ref double proposedTime) { + const double sixty_frame_time = 1000.0 / 60; + if (firstConsumption) { // On the first update, frame-stability seeking would result in unexpected/unwanted behaviour. @@ -231,20 +243,38 @@ namespace osu.Game.Rulesets.UI } } - private void setClock() - { - if (parentGameplayClock == null) - { - // in case a parent gameplay clock isn't available, just use the parent clock. - parentGameplayClock ??= Clock; - } - else - { - Clock = frameStableClock; - } - } + #region Delegation of IGameplayClock - public ReplayInputHandler ReplayInputHandler { get; set; } + public IBindable IsPaused { get; } = new BindableBool(); + + public double CurrentTime => framedClock.CurrentTime; + + public double Rate => framedClock.Rate; + + public bool IsRunning => framedClock.IsRunning; + + public void ProcessFrame() { } + + public double ElapsedFrameTime => framedClock.ElapsedFrameTime; + + public double FramesPerSecond => framedClock.FramesPerSecond; + + public FrameTimeInfo TimeInfo => framedClock.TimeInfo; + + public double StartTime => parentGameplayClock?.StartTime ?? 0; + + private readonly AudioAdjustments gameplayAdjustments = new AudioAdjustments(); + + public IAdjustableAudioComponent AdjustmentsFromMods => parentGameplayClock?.AdjustmentsFromMods ?? gameplayAdjustments; + + #endregion + + #region Delegation of IFrameStableClock + + IBindable IFrameStableClock.IsCatchingUp => isCatchingUp; + IBindable IFrameStableClock.WaitingOnFrames => waitingOnFrames; + + #endregion private enum PlaybackState { @@ -264,25 +294,5 @@ namespace osu.Game.Rulesets.UI /// Valid } - - private class FrameStabilityClock : GameplayClock, IFrameStableClock - { - public GameplayClock ParentGameplayClock; - - public readonly Bindable IsCatchingUp = new Bindable(); - - public readonly Bindable WaitingOnFrames = new Bindable(); - - public override IEnumerable> NonGameplayAdjustments => ParentGameplayClock?.NonGameplayAdjustments ?? Enumerable.Empty>(); - - public FrameStabilityClock(FramedClock underlyingClock) - : base(underlyingClock) - { - } - - IBindable IFrameStableClock.IsCatchingUp => IsCatchingUp; - - IBindable IFrameStableClock.WaitingOnFrames => WaitingOnFrames; - } } } diff --git a/osu.Game/Rulesets/UI/GameplayCursorContainer.cs b/osu.Game/Rulesets/UI/GameplayCursorContainer.cs index ae5f9c6111..cbce397d1e 100644 --- a/osu.Game/Rulesets/UI/GameplayCursorContainer.cs +++ b/osu.Game/Rulesets/UI/GameplayCursorContainer.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; namespace osu.Game.Rulesets.UI { - public class GameplayCursorContainer : CursorContainer + public partial class GameplayCursorContainer : CursorContainer { /// /// Because Show/Hide are executed by a parent, is updated immediately even if the cursor diff --git a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs index c18698f77e..e1c03e49e3 100644 --- a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs +++ b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs @@ -1,10 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Graphics.Containers; using osu.Game.Audio; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Skinning; namespace osu.Game.Rulesets.UI @@ -12,7 +15,7 @@ namespace osu.Game.Rulesets.UI /// /// A component which can trigger the most appropriate hit sound for a given point in time, based on the state of a /// - public class GameplaySampleTriggerSource : CompositeDrawable + public partial class GameplaySampleTriggerSource : CompositeDrawable { /// /// The number of concurrent samples allowed to be played concurrently so that it feels better when spam-pressing a key. @@ -56,39 +59,71 @@ namespace osu.Game.Rulesets.UI PlaySamples(samples); } - protected void PlaySamples(ISampleInfo[] samples) + protected void PlaySamples(ISampleInfo[] samples) => Schedule(() => { var hitSound = getNextSample(); hitSound.Samples = samples; hitSound.Play(); - } + }); protected HitObject GetMostValidObject() { // The most optimal lookup case we have is when an object is alive. There are usually very few alive objects so there's no drawbacks in attempting this lookup each time. - var hitObject = hitObjectContainer.AliveObjects.FirstOrDefault(h => h.Result?.HasResult != true)?.HitObject; + var drawableHitObject = hitObjectContainer.AliveObjects.FirstOrDefault(h => h.Result?.HasResult != true); - // In the case a next object isn't available in drawable form, we need to do a somewhat expensive traversal to get a valid sound to play. - if (hitObject == null) + if (drawableHitObject != null) { - // This lookup can be skipped if the last entry is still valid (in the future and not yet hit). - if (fallbackObject == null || fallbackObject.Result?.HasResult == true) - { - // We need to use lifetime entries to find the next object (we can't just use `hitObjectContainer.Objects` due to pooling - it may even be empty). - // If required, we can make this lookup more efficient by adding support to get next-future-entry in LifetimeEntryManager. - fallbackObject = hitObjectContainer.Entries - .Where(e => e.Result?.HasResult != true) - .OrderBy(e => e.HitObject.StartTime) - .FirstOrDefault(); + // A hit object may have a more valid nested object. + drawableHitObject = getMostValidNestedDrawable(drawableHitObject); - // In the case there are no unjudged objects, the last hit object should be used instead. - fallbackObject ??= hitObjectContainer.Entries.LastOrDefault(); - } - - hitObject = fallbackObject?.HitObject; + return drawableHitObject.HitObject; } - return hitObject; + // In the case a next object isn't available in drawable form, we need to do a somewhat expensive traversal to get a valid sound to play. + // This lookup can be skipped if the last entry is still valid (in the future and not yet hit). + if (fallbackObject == null || fallbackObject.Result?.HasResult == true) + { + // We need to use lifetime entries to find the next object (we can't just use `hitObjectContainer.Objects` due to pooling - it may even be empty). + // If required, we can make this lookup more efficient by adding support to get next-future-entry in LifetimeEntryManager. + fallbackObject = hitObjectContainer.Entries + .Where(e => e.Result?.HasResult != true).MinBy(e => e.HitObject.StartTime); + + if (fallbackObject != null) + return getEarliestNestedObject(fallbackObject.HitObject); + + // In the case there are no non-judged objects, the last hit object should be used instead. + fallbackObject ??= hitObjectContainer.Entries.LastOrDefault(); + } + + if (fallbackObject == null) + return null; + + bool fallbackHasResult = fallbackObject.Result?.HasResult == true; + + // If the fallback has been judged then we want the sample from the object itself. + if (fallbackHasResult) + return fallbackObject.HitObject; + + // Else we want the earliest (including nested). + // In cases of nested objects, they will always have earlier sample data than their parent object. + return getEarliestNestedObject(fallbackObject.HitObject); + } + + private DrawableHitObject getMostValidNestedDrawable(DrawableHitObject o) + { + var nestedWithoutResult = o.NestedHitObjects.FirstOrDefault(n => n.Result?.HasResult != true); + + if (nestedWithoutResult == null) + return o; + + return getMostValidNestedDrawable(nestedWithoutResult); + } + + private HitObject getEarliestNestedObject(HitObject hitObject) + { + var nested = hitObject.NestedHitObjects.FirstOrDefault(); + + return nested != null ? getEarliestNestedObject(nested) : hitObject; } private SkinnableSound getNextSample() diff --git a/osu.Game/Rulesets/UI/HitObjectContainer.cs b/osu.Game/Rulesets/UI/HitObjectContainer.cs index fee77af0ba..099be486b3 100644 --- a/osu.Game/Rulesets/UI/HitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/HitObjectContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -15,7 +17,7 @@ using osu.Game.Rulesets.Objects.Pooling; namespace osu.Game.Rulesets.UI { - public class HitObjectContainer : PooledDrawableWithLifetimeContainer, IHitObjectContainer + public partial class HitObjectContainer : PooledDrawableWithLifetimeContainer, IHitObjectContainer { public IEnumerable Objects => InternalChildren.Cast().OrderBy(h => h.HitObject.StartTime); @@ -26,11 +28,6 @@ namespace osu.Game.Rulesets.UI /// public event Action NewResult; - /// - /// Invoked when a judgement is reverted. - /// - public event Action RevertResult; - /// /// Invoked when a becomes used by a . /// @@ -109,7 +106,6 @@ namespace osu.Game.Rulesets.UI private void addDrawable(DrawableHitObject drawable) { drawable.OnNewResult += onNewResult; - drawable.OnRevertResult += onRevertResult; bindStartTime(drawable); AddInternal(drawable); @@ -118,11 +114,10 @@ namespace osu.Game.Rulesets.UI private void removeDrawable(DrawableHitObject drawable) { drawable.OnNewResult -= onNewResult; - drawable.OnRevertResult -= onRevertResult; unbindStartTime(drawable); - RemoveInternal(drawable); + RemoveInternal(drawable, false); } #endregion @@ -152,7 +147,6 @@ namespace osu.Game.Rulesets.UI #endregion private void onNewResult(DrawableHitObject d, JudgementResult r) => NewResult?.Invoke(d, r); - private void onRevertResult(DrawableHitObject d, JudgementResult r) => RevertResult?.Invoke(d, r); #region Comparator + StartTime tracking diff --git a/osu.Game/Rulesets/UI/IFrameStableClock.cs b/osu.Game/Rulesets/UI/IFrameStableClock.cs index 569ef5e06c..4e50d059e9 100644 --- a/osu.Game/Rulesets/UI/IFrameStableClock.cs +++ b/osu.Game/Rulesets/UI/IFrameStableClock.cs @@ -2,11 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; -using osu.Framework.Timing; +using osu.Game.Screens.Play; namespace osu.Game.Rulesets.UI { - public interface IFrameStableClock : IFrameBasedClock + public interface IFrameStableClock : IGameplayClock { IBindable IsCatchingUp { get; } diff --git a/osu.Game/Rulesets/UI/IHitObjectContainer.cs b/osu.Game/Rulesets/UI/IHitObjectContainer.cs index 4c784132e8..74fd7dee81 100644 --- a/osu.Game/Rulesets/UI/IHitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/IHitObjectContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Game.Rulesets.Objects.Drawables; diff --git a/osu.Game/Rulesets/UI/IPooledHitObjectProvider.cs b/osu.Game/Rulesets/UI/IPooledHitObjectProvider.cs index 2d700076d6..b842e708b0 100644 --- a/osu.Game/Rulesets/UI/IPooledHitObjectProvider.cs +++ b/osu.Game/Rulesets/UI/IPooledHitObjectProvider.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; diff --git a/osu.Game/Rulesets/UI/JudgementContainer.cs b/osu.Game/Rulesets/UI/JudgementContainer.cs index f1a68a0323..7181e80206 100644 --- a/osu.Game/Rulesets/UI/JudgementContainer.cs +++ b/osu.Game/Rulesets/UI/JudgementContainer.cs @@ -1,22 +1,24 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Judgements; namespace osu.Game.Rulesets.UI { - public class JudgementContainer : Container + public partial class JudgementContainer : Container where T : DrawableJudgement { public override void Add(T judgement) { - if (judgement == null) throw new ArgumentNullException(nameof(judgement)); + ArgumentNullException.ThrowIfNull(judgement); // remove any existing judgements for the judged object. // this can be the case when rewinding. - RemoveAll(c => c.JudgedObject == judgement.JudgedObject); + RemoveAll(c => c.JudgedObject == judgement.JudgedObject, false); base.Add(judgement); } diff --git a/osu.Game/Rulesets/UI/ModIcon.cs b/osu.Game/Rulesets/UI/ModIcon.cs index 79bada0490..bf212ad72f 100644 --- a/osu.Game/Rulesets/UI/ModIcon.cs +++ b/osu.Game/Rulesets/UI/ModIcon.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osuTK.Graphics; using osu.Framework.Allocation; @@ -13,6 +15,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Mods; using osuTK; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Localisation; namespace osu.Game.Rulesets.UI @@ -20,7 +23,7 @@ namespace osu.Game.Rulesets.UI /// /// Display the specified mod at a fixed size. /// - public class ModIcon : Container, IHasTooltip + public partial class ModIcon : Container, IHasTooltip { public readonly BindableBool Selected = new BindableBool(); @@ -51,7 +54,6 @@ namespace osu.Game.Rulesets.UI private OsuColour colours { get; set; } private Color4 backgroundColour; - private Color4 highlightedColour; /// /// Construct a new instance. @@ -121,47 +123,13 @@ namespace osu.Game.Rulesets.UI modAcronym.FadeOut(); } - switch (value.Type) - { - default: - case ModType.DifficultyIncrease: - backgroundColour = colours.Yellow; - highlightedColour = colours.YellowLight; - break; - - case ModType.DifficultyReduction: - backgroundColour = colours.Green; - highlightedColour = colours.GreenLight; - break; - - case ModType.Automation: - backgroundColour = colours.Blue; - highlightedColour = colours.BlueLight; - break; - - case ModType.Conversion: - backgroundColour = colours.Purple; - highlightedColour = colours.PurpleLight; - break; - - case ModType.Fun: - backgroundColour = colours.Pink; - highlightedColour = colours.PinkLight; - break; - - case ModType.System: - backgroundColour = colours.Gray6; - highlightedColour = colours.Gray7; - modIcon.Colour = colours.Yellow; - break; - } - + backgroundColour = colours.ForModType(value.Type); updateColour(); } private void updateColour() { - background.Colour = Selected.Value ? highlightedColour : backgroundColour; + background.Colour = Selected.Value ? backgroundColour.Lighten(0.2f) : backgroundColour; } } } diff --git a/osu.Game/Rulesets/UI/ModSwitchSmall.cs b/osu.Game/Rulesets/UI/ModSwitchSmall.cs index 676bbac95c..b6058c16ce 100644 --- a/osu.Game/Rulesets/UI/ModSwitchSmall.cs +++ b/osu.Game/Rulesets/UI/ModSwitchSmall.cs @@ -13,11 +13,9 @@ using osu.Game.Rulesets.Mods; using osuTK; using osuTK.Graphics; -#nullable enable - namespace osu.Game.Rulesets.UI { - public class ModSwitchSmall : CompositeDrawable + public partial class ModSwitchSmall : CompositeDrawable { public BindableBool Active { get; } = new BindableBool(); diff --git a/osu.Game/Rulesets/UI/ModSwitchTiny.cs b/osu.Game/Rulesets/UI/ModSwitchTiny.cs index b1d453f588..a5cf75bd07 100644 --- a/osu.Game/Rulesets/UI/ModSwitchTiny.cs +++ b/osu.Game/Rulesets/UI/ModSwitchTiny.cs @@ -14,11 +14,9 @@ using osu.Game.Rulesets.Mods; using osuTK; using osuTK.Graphics; -#nullable enable - namespace osu.Game.Rulesets.UI { - public class ModSwitchTiny : CompositeDrawable + public partial class ModSwitchTiny : CompositeDrawable { public BindableBool Active { get; } = new BindableBool(); diff --git a/osu.Game/Rulesets/UI/Playfield.cs b/osu.Game/Rulesets/UI/Playfield.cs index ea5ffb10c6..b1c3b78e67 100644 --- a/osu.Game/Rulesets/UI/Playfield.cs +++ b/osu.Game/Rulesets/UI/Playfield.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; @@ -19,12 +21,14 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Skinning; using osuTK; +using osu.Game.Rulesets.Objects.Pooling; +using osu.Framework.Extensions.ObjectExtensions; namespace osu.Game.Rulesets.UI { [Cached(typeof(IPooledHitObjectProvider))] [Cached(typeof(IPooledSampleProvider))] - public abstract class Playfield : CompositeDrawable, IPooledHitObjectProvider, IPooledSampleProvider + public abstract partial class Playfield : CompositeDrawable, IPooledHitObjectProvider, IPooledSampleProvider { /// /// Invoked when a is judged. @@ -32,9 +36,9 @@ namespace osu.Game.Rulesets.UI public event Action NewResult; /// - /// Invoked when a judgement is reverted. + /// Invoked when a judgement result is reverted. /// - public event Action RevertResult; + public event Action RevertResult; /// /// The contained in this Playfield. @@ -90,7 +94,12 @@ namespace osu.Game.Rulesets.UI public readonly BindableBool DisplayJudgements = new BindableBool(true); [Resolved(CanBeNull = true)] - private IReadOnlyList mods { get; set; } + [CanBeNull] + protected IReadOnlyList Mods { get; private set; } + + private readonly HitObjectEntryManager entryManager = new HitObjectEntryManager(); + + private readonly Stack judgedEntries; /// /// Creates a new . @@ -101,11 +110,15 @@ namespace osu.Game.Rulesets.UI hitObjectContainerLazy = new Lazy(() => CreateHitObjectContainer().With(h => { - h.NewResult += (d, r) => NewResult?.Invoke(d, r); - h.RevertResult += (d, r) => RevertResult?.Invoke(d, r); + h.NewResult += onNewResult; h.HitObjectUsageBegan += o => HitObjectUsageBegan?.Invoke(o); h.HitObjectUsageFinished += o => HitObjectUsageFinished?.Invoke(o); })); + + entryManager.OnEntryAdded += onEntryAdded; + entryManager.OnEntryRemoved += onEntryRemoved; + + judgedEntries = new Stack(); } [BackgroundDependencyLoader] @@ -169,6 +182,7 @@ namespace osu.Game.Rulesets.UI /// The added . protected virtual void OnHitObjectAdded(HitObject hitObject) { + preloadSamples(hitObject); } /// @@ -193,16 +207,14 @@ namespace osu.Game.Rulesets.UI /// /// The cursor currently being used by this . May be null if no cursor is provided. /// + [CanBeNull] public GameplayCursorContainer Cursor { get; private set; } /// /// Provide a cursor which is to be used for gameplay. /// - /// - /// The default provided cursor is invisible when inside the bounds of the . - /// /// The cursor, or null to show the menu cursor. - protected virtual GameplayCursorContainer CreateCursor() => new InvisibleCursorContainer(); + protected virtual GameplayCursorContainer CreateCursor() => null; /// /// Registers a as a nested . @@ -216,7 +228,7 @@ namespace osu.Game.Rulesets.UI otherPlayfield.DisplayJudgements.BindTo(DisplayJudgements); otherPlayfield.NewResult += (d, r) => NewResult?.Invoke(d, r); - otherPlayfield.RevertResult += (d, r) => RevertResult?.Invoke(d, r); + otherPlayfield.RevertResult += r => RevertResult?.Invoke(r); otherPlayfield.HitObjectUsageBegan += h => HitObjectUsageBegan?.Invoke(h); otherPlayfield.HitObjectUsageFinished += h => HitObjectUsageFinished?.Invoke(h); @@ -236,14 +248,26 @@ namespace osu.Game.Rulesets.UI { base.Update(); - if (!IsNested && mods != null) + if (!IsNested && Mods != null) { - foreach (var mod in mods) + foreach (var mod in Mods) { if (mod is IUpdatableByPlayfield updatable) updatable.Update(this); } } + + // When rewinding, revert future judgements in the reverse order. + while (judgedEntries.Count > 0) + { + var result = judgedEntries.Peek().Result; + Debug.Assert(result?.RawTime != null); + + if (Time.Current >= result.RawTime.Value) + break; + + revertResult(judgedEntries.Pop()); + } } /// @@ -262,12 +286,7 @@ namespace osu.Game.Rulesets.UI public virtual void Add(HitObject hitObject) { var entry = CreateLifetimeEntry(hitObject); - lifetimeEntryMap[entry.HitObject] = entry; - - preloadSamples(hitObject); - - HitObjectContainer.Add(entry); - OnHitObjectAdded(entry.HitObject); + entryManager.Add(entry, null); } private void preloadSamples(HitObject hitObject) @@ -290,16 +309,31 @@ namespace osu.Game.Rulesets.UI /// Whether the was successfully removed. public virtual bool Remove(HitObject hitObject) { - if (lifetimeEntryMap.Remove(hitObject, out var entry)) + if (entryManager.TryGet(hitObject, out var entry)) { - HitObjectContainer.Remove(entry); - OnHitObjectRemoved(hitObject); + entryManager.Remove(entry); return true; } return nestedPlayfields.Any(p => p.Remove(hitObject)); } + private void onEntryAdded(HitObjectLifetimeEntry entry, [CanBeNull] HitObject parentHitObject) + { + if (parentHitObject != null) return; + + HitObjectContainer.Add(entry); + OnHitObjectAdded(entry.HitObject); + } + + private void onEntryRemoved(HitObjectLifetimeEntry entry, [CanBeNull] HitObject parentHitObject) + { + if (parentHitObject != null) return; + + HitObjectContainer.Remove(entry); + OnHitObjectRemoved(entry.HitObject); + } + /// /// Creates the for a given . /// @@ -357,15 +391,18 @@ namespace osu.Game.Rulesets.UI // If this is the first time this DHO is being used, then apply the DHO mods. // This is done before Apply() so that the state is updated once when the hitobject is applied. - if (mods != null) + if (Mods != null) { - foreach (var m in mods.OfType()) + foreach (var m in Mods.OfType()) m.ApplyToDrawableHitObject(dho); } } - if (!lifetimeEntryMap.TryGetValue(hitObject, out var entry)) - lifetimeEntryMap[hitObject] = entry = CreateLifetimeEntry(hitObject); + if (!entryManager.TryGet(hitObject, out var entry)) + { + entry = CreateLifetimeEntry(hitObject); + entryManager.Add(entry, parent?.HitObject); + } dho.ParentHitObject = parent; dho.Apply(entry); @@ -407,7 +444,7 @@ namespace osu.Game.Rulesets.UI return pool; } - private class DrawableSamplePool : DrawablePool + private partial class DrawableSamplePool : DrawablePool { private readonly ISampleInfo sampleInfo; @@ -422,6 +459,25 @@ namespace osu.Game.Rulesets.UI #endregion + private void onNewResult(DrawableHitObject drawable, JudgementResult result) + { + Debug.Assert(result != null && drawable.Entry?.Result == result && result.RawTime != null); + judgedEntries.Push(drawable.Entry.AsNonNull()); + + NewResult?.Invoke(drawable, result); + } + + private void revertResult(HitObjectLifetimeEntry entry) + { + var result = entry.Result; + Debug.Assert(result != null); + + RevertResult?.Invoke(result); + entry.OnRevertResult(); + + result.Reset(); + } + #region Editor logic /// @@ -440,8 +496,6 @@ namespace osu.Game.Rulesets.UI /// internal event Action HitObjectUsageFinished; - private readonly Dictionary lifetimeEntryMap = new Dictionary(); - /// /// Sets whether to keep a given always alive within this or any nested . /// @@ -449,7 +503,7 @@ namespace osu.Game.Rulesets.UI /// Whether to keep always alive. internal void SetKeepAlive(HitObject hitObject, bool keepAlive) { - if (lifetimeEntryMap.TryGetValue(hitObject, out var entry)) + if (entryManager.TryGet(hitObject, out var entry)) { entry.KeepAlive = keepAlive; return; @@ -464,7 +518,7 @@ namespace osu.Game.Rulesets.UI /// internal void KeepAllAlive() { - foreach (var (_, entry) in lifetimeEntryMap) + foreach (var entry in entryManager.AllEntries) entry.KeepAlive = true; foreach (var p in nestedPlayfields) @@ -502,14 +556,5 @@ namespace osu.Game.Rulesets.UI } #endregion - - public class InvisibleCursorContainer : GameplayCursorContainer - { - protected override Drawable CreateCursor() => new InvisibleCursor(); - - private class InvisibleCursor : Drawable - { - } - } } } diff --git a/osu.Game/Rulesets/UI/PlayfieldAdjustmentContainer.cs b/osu.Game/Rulesets/UI/PlayfieldAdjustmentContainer.cs index fff4a450e5..0f440adef8 100644 --- a/osu.Game/Rulesets/UI/PlayfieldAdjustmentContainer.cs +++ b/osu.Game/Rulesets/UI/PlayfieldAdjustmentContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -9,7 +11,7 @@ namespace osu.Game.Rulesets.UI /// /// A container which handles sizing of the and any other components that need to match their size. /// - public class PlayfieldAdjustmentContainer : Container + public partial class PlayfieldAdjustmentContainer : Container { public PlayfieldAdjustmentContainer() { diff --git a/osu.Game/Rulesets/UI/PlayfieldBorder.cs b/osu.Game/Rulesets/UI/PlayfieldBorder.cs index 458b88c6db..211a87de84 100644 --- a/osu.Game/Rulesets/UI/PlayfieldBorder.cs +++ b/osu.Game/Rulesets/UI/PlayfieldBorder.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -14,7 +16,7 @@ namespace osu.Game.Rulesets.UI /// /// Provides a border around the playfield. /// - public class PlayfieldBorder : CompositeDrawable + public partial class PlayfieldBorder : CompositeDrawable { public Bindable PlayfieldBorderStyle { get; } = new Bindable(); @@ -106,7 +108,7 @@ namespace osu.Game.Rulesets.UI } } - private class Line : Box + private partial class Line : Box { private readonly Direction direction; diff --git a/osu.Game/Rulesets/UI/PlayfieldBorderStyle.cs b/osu.Game/Rulesets/UI/PlayfieldBorderStyle.cs index 0a0aad884e..79f3a2ca84 100644 --- a/osu.Game/Rulesets/UI/PlayfieldBorderStyle.cs +++ b/osu.Game/Rulesets/UI/PlayfieldBorderStyle.cs @@ -1,12 +1,22 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using osu.Framework.Localisation; +using osu.Game.Localisation; + namespace osu.Game.Rulesets.UI { public enum PlayfieldBorderStyle { + [LocalisableDescription(typeof(RulesetSettingsStrings), nameof(RulesetSettingsStrings.BorderNone))] None, + + [LocalisableDescription(typeof(RulesetSettingsStrings), nameof(RulesetSettingsStrings.BorderCorners))] Corners, + + [LocalisableDescription(typeof(RulesetSettingsStrings), nameof(RulesetSettingsStrings.BorderFull))] Full } } diff --git a/osu.Game/Rulesets/UI/ReplayRecorder.cs b/osu.Game/Rulesets/UI/ReplayRecorder.cs index dcd8f12028..28e25c72e1 100644 --- a/osu.Game/Rulesets/UI/ReplayRecorder.cs +++ b/osu.Game/Rulesets/UI/ReplayRecorder.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -12,12 +14,11 @@ using osu.Framework.Input.Events; using osu.Game.Online.Spectator; using osu.Game.Rulesets.Replays; using osu.Game.Scoring; -using osu.Game.Screens.Play; using osuTK; namespace osu.Game.Rulesets.UI { - public abstract class ReplayRecorder : ReplayRecorder, IKeyBindingHandler + public abstract partial class ReplayRecorder : ReplayRecorder, IKeyBindingHandler where T : struct { private readonly Score target; @@ -31,9 +32,6 @@ namespace osu.Game.Rulesets.UI [Resolved] private SpectatorClient spectatorClient { get; set; } - [Resolved] - private GameplayState gameplayState { get; set; } - protected ReplayRecorder(Score target) { this.target = target; @@ -46,15 +44,7 @@ namespace osu.Game.Rulesets.UI protected override void LoadComplete() { base.LoadComplete(); - inputManager = GetContainingInputManager(); - spectatorClient.BeginPlaying(gameplayState, target); - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - spectatorClient?.EndPlaying(gameplayState); } protected override void Update() @@ -104,7 +94,7 @@ namespace osu.Game.Rulesets.UI protected abstract ReplayFrame HandleFrame(Vector2 mousePosition, List actions, ReplayFrame previousFrame); } - public abstract class ReplayRecorder : Component + public abstract partial class ReplayRecorder : Component { public Func ScreenSpaceToGamefield; } diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs index b5390eb6e2..2ae54a3afe 100644 --- a/osu.Game/Rulesets/UI/RulesetInputManager.cs +++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using osu.Framework.Allocation; @@ -17,12 +19,13 @@ 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; namespace osu.Game.Rulesets.UI { - public abstract class RulesetInputManager : PassThroughInputManager, ICanAttachKeyCounter, IHasReplayHandler, IHasRecordingHandler + public abstract partial class RulesetInputManager : PassThroughInputManager, ICanAttachHUDPieces, IHasReplayHandler, IHasRecordingHandler where T : struct { public readonly KeyBindingContainer KeyBindingContainer; @@ -36,6 +39,9 @@ namespace osu.Game.Rulesets.UI { set { + if (value == recorder) + return; + if (value != null && recorder != null) throw new InvalidOperationException("Cannot attach more than one recorder"); @@ -121,7 +127,7 @@ namespace osu.Game.Rulesets.UI { switch (e) { - case MouseDownEvent _: + case MouseDownEvent: if (mouseDisabled.Value) return true; // importantly, block upwards propagation so global bindings also don't fire. @@ -163,31 +169,65 @@ namespace osu.Game.Rulesets.UI .Select(b => b.GetAction()) .Distinct() .OrderBy(action => action) - .Select(action => new KeyCounterAction(action))); + .Select(action => new KeyCounterActionTrigger(action))); } - public class ActionReceptor : KeyCounterDisplay.Receptor, IKeyBindingHandler + private partial class ActionReceptor : KeyCounterDisplay.Receptor, IKeyBindingHandler { public ActionReceptor(KeyCounterDisplay target) : base(target) { } - 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); } } #endregion + #region Keys per second Counter Attachment + + public void Attach(ClicksPerSecondCalculator calculator) + { + var listener = new ActionListener(calculator); + + KeyBindingContainer.Add(listener); + } + + private partial class ActionListener : Component, IKeyBindingHandler + { + private readonly ClicksPerSecondCalculator calculator; + + public ActionListener(ClicksPerSecondCalculator calculator) + { + this.calculator = calculator; + } + + public bool OnPressed(KeyBindingPressEvent e) + { + calculator.AddInputTimestamp(); + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } + + #endregion + protected virtual KeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) => new RulesetKeyBindingContainer(ruleset, variant, unique); - public class RulesetKeyBindingContainer : DatabasedKeyBindingContainer + public partial class RulesetKeyBindingContainer : DatabasedKeyBindingContainer { protected override bool HandleRepeats => false; @@ -196,9 +236,9 @@ namespace osu.Game.Rulesets.UI { } - protected override void ReloadMappings() + protected override void ReloadMappings(IQueryable realmKeyBindings) { - base.ReloadMappings(); + base.ReloadMappings(realmKeyBindings); KeyBindings = KeyBindings.Where(b => RealmKeyBindingStore.CheckValidForGameplay(b.KeyCombination)).ToList(); } @@ -219,12 +259,13 @@ namespace osu.Game.Rulesets.UI } /// - /// Supports attaching a . + /// Supports attaching various HUD pieces. /// Keys will be populated automatically and a receptor will be injected inside. /// - public interface ICanAttachKeyCounter + public interface ICanAttachHUDPieces { void Attach(KeyCounterDisplay keyCounter); + void Attach(ClicksPerSecondCalculator calculator); } public class RulesetInputManagerInputState : InputState diff --git a/osu.Game/Rulesets/UI/Scrolling/Algorithms/ConstantScrollAlgorithm.cs b/osu.Game/Rulesets/UI/Scrolling/Algorithms/ConstantScrollAlgorithm.cs index ab6e07f424..c957a84eb1 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/ConstantScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/ConstantScrollAlgorithm.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.UI.Scrolling.Algorithms { public class ConstantScrollAlgorithm : IScrollAlgorithm @@ -18,7 +20,7 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms return -PositionAt(startTime, endTime, timeRange, scrollLength); } - public float PositionAt(double time, double currentTime, double timeRange, float scrollLength) + public float PositionAt(double time, double currentTime, double timeRange, float scrollLength, double? originTime = null) => (float)((time - currentTime) / timeRange * scrollLength); public double TimeAt(float position, double currentTime, double timeRange, float scrollLength) diff --git a/osu.Game/Rulesets/UI/Scrolling/Algorithms/IScrollAlgorithm.cs b/osu.Game/Rulesets/UI/Scrolling/Algorithms/IScrollAlgorithm.cs index c394a05bcc..f78509f919 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/IScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/IScrollAlgorithm.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.UI.Scrolling.Algorithms { public interface IScrollAlgorithm @@ -51,8 +53,9 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms /// The current time. /// The amount of visible time. /// The absolute spatial length through . + /// The time to be used for control point lookups (ie. the parent's start time for nested hit objects). /// The absolute spatial position. - float PositionAt(double time, double currentTime, double timeRange, float scrollLength); + float PositionAt(double time, double currentTime, double timeRange, float scrollLength, double? originTime = null); /// /// Computes the time which brings a point to a provided spatial position given the current time. @@ -61,7 +64,7 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms /// The current time. /// The amount of visible time. /// The absolute spatial length through . - /// The time at which == . + /// The time at which == . double TimeAt(float position, double currentTime, double timeRange, float scrollLength); /// diff --git a/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs b/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs index 7b827e0c63..54079c7895 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs @@ -1,23 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; +using System.Linq; using osu.Framework.Lists; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Timing; namespace osu.Game.Rulesets.UI.Scrolling.Algorithms { public class OverlappingScrollAlgorithm : IScrollAlgorithm { - private readonly MultiplierControlPoint searchPoint; - private readonly SortedList controlPoints; public OverlappingScrollAlgorithm(SortedList controlPoints) { this.controlPoints = controlPoints; - - searchPoint = new MultiplierControlPoint(); } public double GetDisplayStartTime(double originTime, float offset, double timeRange, float scrollLength) @@ -35,8 +35,8 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms return -PositionAt(startTime, endTime, timeRange, scrollLength); } - public float PositionAt(double time, double currentTime, double timeRange, float scrollLength) - => (float)((time - currentTime) / timeRange * controlPointAt(time).Multiplier * scrollLength); + public float PositionAt(double time, double currentTime, double timeRange, float scrollLength, double? originTime = null) + => (float)((time - currentTime) / timeRange * controlPointAt(originTime ?? time).Multiplier * scrollLength); public double TimeAt(float position, double currentTime, double timeRange, float scrollLength) { @@ -50,7 +50,7 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms for (; i < controlPoints.Count; i++) { float lastPos = pos; - pos = PositionAt(controlPoints[i].StartTime, currentTime, timeRange, scrollLength); + pos = PositionAt(controlPoints[i].Time, currentTime, timeRange, scrollLength); if (pos > position) { @@ -62,7 +62,7 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms i = Math.Clamp(i, 0, controlPoints.Count - 1); - return controlPoints[i].StartTime + (position - pos) * timeRange / controlPoints[i].Multiplier / scrollLength; + return controlPoints[i].Time + (position - pos) * timeRange / controlPoints[i].Multiplier / scrollLength; } public void Reset() @@ -76,19 +76,11 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms /// The . private MultiplierControlPoint controlPointAt(double time) { - if (controlPoints.Count == 0) - return new MultiplierControlPoint(double.NegativeInfinity); - - if (time < controlPoints[0].StartTime) - return controlPoints[0]; - - searchPoint.StartTime = time; - int index = controlPoints.BinarySearch(searchPoint); - - if (index < 0) - index = ~index - 1; - - return controlPoints[index]; + return ControlPointInfo.BinarySearch(controlPoints, time) + // The standard binary search will fail if there's no control points, or if the time is before the first. + // For this method, we want to use the first control point in the latter case. + ?? controlPoints.FirstOrDefault() + ?? new MultiplierControlPoint(double.NegativeInfinity); } } } diff --git a/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs b/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs index 45d3b3bcd4..774beb20c7 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; @@ -36,7 +38,7 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms return (float)(objectLength * scrollLength); } - public float PositionAt(double time, double currentTime, double timeRange, float scrollLength) + public float PositionAt(double time, double currentTime, double timeRange, float scrollLength, double? originTime = null) { double timelineLength = relativePositionAt(time, timeRange) - relativePositionAt(currentTime, timeRange); return (float)(timelineLength * scrollLength); @@ -119,7 +121,7 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms if (controlPoints.Count == 0) return; - positionMappings.Add(new PositionMapping(controlPoints[0].StartTime, controlPoints[0])); + positionMappings.Add(new PositionMapping(controlPoints[0].Time, controlPoints[0])); for (int i = 0; i < controlPoints.Count - 1; i++) { @@ -127,9 +129,9 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms var next = controlPoints[i + 1]; // Figure out how much of the time range the duration represents, and adjust it by the speed multiplier - float length = (float)((next.StartTime - current.StartTime) / timeRange * current.Multiplier); + float length = (float)((next.Time - current.Time) / timeRange * current.Multiplier); - positionMappings.Add(new PositionMapping(next.StartTime, next, positionMappings[^1].Position + length)); + positionMappings.Add(new PositionMapping(next.Time, next, positionMappings[^1].Position + length)); } } diff --git a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs index 926f2fd539..4c7564b791 100644 --- a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs +++ b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -26,7 +28,7 @@ namespace osu.Game.Rulesets.UI.Scrolling /// A type of that supports a . /// s inside this will scroll within the playfield. /// - public abstract class DrawableScrollingRuleset : DrawableRuleset, IKeyBindingHandler + public abstract partial class DrawableScrollingRuleset : DrawableRuleset, IDrawableScrollingRuleset, IKeyBindingHandler where TObject : HitObject { /// @@ -58,13 +60,14 @@ namespace osu.Game.Rulesets.UI.Scrolling /// protected readonly BindableDouble TimeRange = new BindableDouble(time_span_default) { - Default = time_span_default, MinValue = time_span_min, MaxValue = time_span_max }; protected virtual ScrollVisualisationMethod VisualisationMethod => ScrollVisualisationMethod.Sequential; + ScrollVisualisationMethod IDrawableScrollingRuleset.VisualisationMethod => VisualisationMethod; + /// /// Whether the player can change . /// @@ -111,7 +114,7 @@ namespace osu.Game.Rulesets.UI.Scrolling break; } - double lastObjectTime = Objects.LastOrDefault()?.GetEndTime() ?? double.MaxValue; + double lastObjectTime = Beatmap.HitObjects.Any() ? Beatmap.GetLastObjectTime() : double.MaxValue; double baseBeatLength = TimingControlPoint.DEFAULT_BEAT_LENGTH; if (RelativeScaleBeatLengths) @@ -157,9 +160,9 @@ namespace osu.Game.Rulesets.UI.Scrolling // Trim unwanted sequences of timing changes timingChanges = timingChanges // Collapse sections after the last hit object - .Where(s => s.StartTime <= lastObjectTime) + .Where(s => s.Time <= lastObjectTime) // Collapse sections with the same start time - .GroupBy(s => s.StartTime).Select(g => g.Last()).OrderBy(s => s.StartTime); + .GroupBy(s => s.Time).Select(g => g.Last()).OrderBy(s => s.Time); ControlPoints.AddRange(timingChanges); diff --git a/osu.Game/Rulesets/UI/Scrolling/IDrawableScrollingRuleset.cs b/osu.Game/Rulesets/UI/Scrolling/IDrawableScrollingRuleset.cs new file mode 100644 index 0000000000..f3a3bb18bd --- /dev/null +++ b/osu.Game/Rulesets/UI/Scrolling/IDrawableScrollingRuleset.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Configuration; + +namespace osu.Game.Rulesets.UI.Scrolling +{ + /// + /// An interface for scrolling-based s. + /// + public interface IDrawableScrollingRuleset + { + ScrollVisualisationMethod VisualisationMethod { get; } + } +} diff --git a/osu.Game/Rulesets/UI/Scrolling/IScrollingInfo.cs b/osu.Game/Rulesets/UI/Scrolling/IScrollingInfo.cs index cd85932599..e00f0ffe5d 100644 --- a/osu.Game/Rulesets/UI/Scrolling/IScrollingInfo.cs +++ b/osu.Game/Rulesets/UI/Scrolling/IScrollingInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI.Scrolling.Algorithms; diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingDirection.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingDirection.cs index 81e1a6c916..58bb80accd 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingDirection.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingDirection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Rulesets.UI.Scrolling { public enum ScrollingDirection diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs index 3b15bc2cdf..b93a427196 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs @@ -1,11 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System; using System.Collections.Generic; -using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Primitives; using osu.Framework.Layout; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -14,7 +17,7 @@ using osuTK; namespace osu.Game.Rulesets.UI.Scrolling { - public class ScrollingHitObjectContainer : HitObjectContainer + public partial class ScrollingHitObjectContainer : HitObjectContainer { private readonly IBindable timeRange = new BindableDouble(); private readonly IBindable direction = new Bindable(); @@ -90,9 +93,9 @@ namespace osu.Game.Rulesets.UI.Scrolling /// /// Given a time, return the position along the scrolling axis within this at time . /// - public float PositionAtTime(double time, double currentTime) + public float PositionAtTime(double time, double currentTime, double? originTime = null) { - float scrollPosition = scrollingInfo.Algorithm.PositionAt(time, currentTime, timeRange.Value, scrollLength); + float scrollPosition = scrollingInfo.Algorithm.PositionAt(time, currentTime, timeRange.Value, scrollLength, originTime); return axisInverted ? -scrollPosition : scrollPosition; } @@ -124,6 +127,16 @@ namespace osu.Game.Rulesets.UI.Scrolling private float scrollLength => scrollingAxis == Direction.Horizontal ? DrawWidth : DrawHeight; + public override void Add(HitObjectLifetimeEntry entry) + { + // Scroll info is not available until loaded. + // The lifetime of all entries will be updated in the first Update. + if (IsLoaded) + setComputedLifetimeStart(entry); + + base.Add(entry); + } + protected override void AddDrawable(HitObjectLifetimeEntry entry, DrawableHitObject drawable) { base.AddDrawable(entry, drawable); @@ -142,7 +155,6 @@ namespace osu.Game.Rulesets.UI.Scrolling private void invalidateHitObject(DrawableHitObject hitObject) { - hitObject.LifetimeStart = computeOriginAdjustedLifetimeStart(hitObject); layoutComputed.Remove(hitObject); } @@ -154,10 +166,8 @@ namespace osu.Game.Rulesets.UI.Scrolling layoutComputed.Clear(); - // Reset lifetime to the conservative estimation. - // If a drawable becomes alive by this lifetime, its lifetime will be updated to a more precise lifetime in the next update. foreach (var entry in Entries) - entry.SetInitialLifetime(); + setComputedLifetimeStart(entry); scrollingInfo.Algorithm.Reset(); @@ -184,39 +194,51 @@ namespace osu.Game.Rulesets.UI.Scrolling } } - private double computeOriginAdjustedLifetimeStart(DrawableHitObject hitObject) + /// + /// Get a conservative maximum bounding box of a corresponding to . + /// It is used to calculate when the hit object appears. + /// + protected virtual RectangleF GetConservativeBoundingBox(HitObjectLifetimeEntry entry) => new RectangleF().Inflate(100); + + private double computeDisplayStartTime(HitObjectLifetimeEntry entry) { - // Origin position may be relative to the parent size - Debug.Assert(hitObject.Parent != null); + RectangleF boundingBox = GetConservativeBoundingBox(entry); + float startOffset = 0; - float originAdjustment = 0.0f; - - // calculate the dimension of the part of the hitobject that should already be visible - // when the hitobject origin first appears inside the scrolling container switch (direction.Value) { - case ScrollingDirection.Up: - originAdjustment = hitObject.OriginPosition.Y; + case ScrollingDirection.Right: + startOffset = boundingBox.Right; break; case ScrollingDirection.Down: - originAdjustment = hitObject.DrawHeight - hitObject.OriginPosition.Y; + startOffset = boundingBox.Bottom; break; case ScrollingDirection.Left: - originAdjustment = hitObject.OriginPosition.X; + startOffset = -boundingBox.Left; break; - case ScrollingDirection.Right: - originAdjustment = hitObject.DrawWidth - hitObject.OriginPosition.X; + case ScrollingDirection.Up: + startOffset = -boundingBox.Top; break; } - return scrollingInfo.Algorithm.GetDisplayStartTime(hitObject.HitObject.StartTime, originAdjustment, timeRange.Value, scrollLength); + return scrollingInfo.Algorithm.GetDisplayStartTime(entry.HitObject.StartTime, startOffset, timeRange.Value, scrollLength); } - private void updateLayoutRecursive(DrawableHitObject hitObject) + private void setComputedLifetimeStart(HitObjectLifetimeEntry entry) { + double computedStartTime = computeDisplayStartTime(entry); + + // always load the hitobject before its first judgement offset + entry.LifetimeStart = Math.Min(entry.HitObject.StartTime - entry.HitObject.MaximumJudgementOffset, computedStartTime); + } + + private void updateLayoutRecursive(DrawableHitObject hitObject, double? parentHitObjectStartTime = null) + { + parentHitObjectStartTime ??= hitObject.HitObject.StartTime; + if (hitObject.HitObject is IHasDuration e) { float length = LengthAtTime(hitObject.HitObject.StartTime, e.EndTime); @@ -228,16 +250,17 @@ namespace osu.Game.Rulesets.UI.Scrolling foreach (var obj in hitObject.NestedHitObjects) { - updateLayoutRecursive(obj); + updateLayoutRecursive(obj, parentHitObjectStartTime); - // Nested hitobjects don't need to scroll, but they do need accurate positions - updatePosition(obj, hitObject.HitObject.StartTime); + // Nested hitobjects don't need to scroll, but they do need accurate positions and start lifetime + updatePosition(obj, hitObject.HitObject.StartTime, parentHitObjectStartTime); + setComputedLifetimeStart(obj.Entry); } } - private void updatePosition(DrawableHitObject hitObject, double currentTime) + private void updatePosition(DrawableHitObject hitObject, double currentTime, double? parentHitObjectStartTime = null) { - float position = PositionAtTime(hitObject.HitObject.StartTime, currentTime); + float position = PositionAtTime(hitObject.HitObject.StartTime, currentTime, parentHitObjectStartTime); if (scrollingAxis == Direction.Horizontal) hitObject.X = position; diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs index 782255733f..7d141113df 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Rulesets.Objects.Drawables; @@ -11,7 +13,7 @@ namespace osu.Game.Rulesets.UI.Scrolling /// /// A type of specialized towards scrolling s. /// - public abstract class ScrollingPlayfield : Playfield + public abstract partial class ScrollingPlayfield : Playfield { protected readonly IBindable Direction = new Bindable(); @@ -36,6 +38,8 @@ namespace osu.Game.Rulesets.UI.Scrolling /// public virtual Vector2 ScreenSpacePositionAtTime(double time) => HitObjectContainer.ScreenSpacePositionAtTime(time); - protected sealed override HitObjectContainer CreateHitObjectContainer() => new ScrollingHitObjectContainer(); + protected sealed override HitObjectContainer CreateHitObjectContainer() => CreateScrollingHitObjectContainer(); + + protected virtual ScrollingHitObjectContainer CreateScrollingHitObjectContainer() => new ScrollingHitObjectContainer(); } } diff --git a/osu.Game/Scoring/Drawables/UnprocessedPerformancePointsPlaceholder.cs b/osu.Game/Scoring/Drawables/UnprocessedPerformancePointsPlaceholder.cs new file mode 100644 index 0000000000..99eb7e964d --- /dev/null +++ b/osu.Game/Scoring/Drawables/UnprocessedPerformancePointsPlaceholder.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable +using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Scoring.Drawables +{ + /// + /// A placeholder used in PP columns for scores with unprocessed PP value. + /// + public partial class UnprocessedPerformancePointsPlaceholder : SpriteIcon, IHasTooltip + { + public LocalisableString TooltipText => ScoresStrings.StatusProcessing; + + public UnprocessedPerformancePointsPlaceholder() + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + Icon = FontAwesome.Solid.ExclamationTriangle; + } + } +} diff --git a/osu.Game/Scoring/EFScoreInfo.cs b/osu.Game/Scoring/EFScoreInfo.cs deleted file mode 100644 index 4161336cfc..0000000000 --- a/osu.Game/Scoring/EFScoreInfo.cs +++ /dev/null @@ -1,270 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations.Schema; -using System.Linq; -using Newtonsoft.Json; -using osu.Framework.Localisation; -using osu.Game.Beatmaps; -using osu.Game.Database; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Scoring; -using osu.Game.Users; -using osu.Game.Utils; - -namespace osu.Game.Scoring -{ - [Table(@"ScoreInfo")] - public class EFScoreInfo : IScoreInfo, IHasFiles, IHasPrimaryKey, ISoftDelete, IEquatable, IDeepCloneable - { - public int ID { get; set; } - - public bool IsManaged => ID > 0; - - public ScoreRank Rank { get; set; } - - public long TotalScore { get; set; } - - [Column(TypeName = "DECIMAL(1,4)")] // TODO: This data type is wrong (should contain more precision). But at the same time, we probably don't need to be storing this in the database. - public double Accuracy { get; set; } - - public LocalisableString DisplayAccuracy => Accuracy.FormatAccuracy(); - - public double? PP { get; set; } - - public int MaxCombo { get; set; } - - public int Combo { get; set; } // Todo: Shouldn't exist in here - - public int RulesetID { get; set; } - - [NotMapped] - public bool Passed { get; set; } = true; - - public EFRulesetInfo Ruleset { get; set; } - - private APIMod[] localAPIMods; - - private Mod[] mods; - - [NotMapped] - public Mod[] Mods - { - get - { - var rulesetInstance = Ruleset?.CreateInstance(); - if (rulesetInstance == null) - return mods ?? Array.Empty(); - - Mod[] scoreMods = Array.Empty(); - - if (mods != null) - scoreMods = mods; - else if (localAPIMods != null) - scoreMods = APIMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); - - return scoreMods; - } - set - { - localAPIMods = null; - mods = value; - } - } - - // Used for API serialisation/deserialisation. - [NotMapped] - public APIMod[] APIMods - { - get - { - if (localAPIMods != null) - return localAPIMods; - - if (mods == null) - return Array.Empty(); - - return localAPIMods = mods.Select(m => new APIMod(m)).ToArray(); - } - set - { - localAPIMods = value; - - // We potentially can't update this yet due to Ruleset being late-bound, so instead update on read as necessary. - mods = null; - } - } - - // Used for database serialisation/deserialisation. - [Column("Mods")] - public string ModsJson - { - get => JsonConvert.SerializeObject(APIMods); - set => APIMods = !string.IsNullOrEmpty(value) ? JsonConvert.DeserializeObject(value) : Array.Empty(); - } - - [NotMapped] - public APIUser User { get; set; } - - [Column("User")] - public string UserString - { - get => User?.Username; - set - { - User ??= new APIUser(); - User.Username = value; - } - } - - [Column("UserID")] - public int? UserID - { - get => User?.Id ?? 1; - set - { - User ??= new APIUser(); - User.Id = value ?? 1; - } - } - - public int BeatmapInfoID { get; set; } - - [Column("Beatmap")] - public EFBeatmapInfo BeatmapInfo { get; set; } - - private long? onlineID; - - [JsonProperty("id")] - [Column("OnlineScoreID")] - public long? OnlineID - { - get => onlineID; - set => onlineID = value > 0 ? value : null; - } - - public DateTimeOffset Date { get; set; } - - [NotMapped] - public Dictionary Statistics { get; set; } = new Dictionary(); - - [Column("Statistics")] - public string StatisticsJson - { - get => JsonConvert.SerializeObject(Statistics); - set - { - if (value == null) - { - Statistics.Clear(); - return; - } - - Statistics = JsonConvert.DeserializeObject>(value); - } - } - - [NotMapped] - public List HitEvents { get; set; } - - public List Files { get; } = new List(); - - public string Hash { get; set; } - - public bool DeletePending { get; set; } - - /// - /// The position of this score, starting at 1. - /// - [NotMapped] - public int? Position { get; set; } // TODO: remove after all calls to `CreateScoreInfo` are gone. - - /// - /// Whether this represents a legacy (osu!stable) score. - /// - [NotMapped] - public bool IsLegacyScore => Mods.OfType().Any(); - - public IEnumerable GetStatisticsForDisplay() - { - foreach (var r in Ruleset.CreateInstance().GetHitResults()) - { - int value = Statistics.GetValueOrDefault(r.result); - - switch (r.result) - { - case HitResult.SmallTickHit: - { - int total = value + Statistics.GetValueOrDefault(HitResult.SmallTickMiss); - if (total > 0) - yield return new HitResultDisplayStatistic(r.result, value, total, r.displayName); - - break; - } - - case HitResult.LargeTickHit: - { - int total = value + Statistics.GetValueOrDefault(HitResult.LargeTickMiss); - if (total > 0) - yield return new HitResultDisplayStatistic(r.result, value, total, r.displayName); - - break; - } - - case HitResult.SmallTickMiss: - case HitResult.LargeTickMiss: - break; - - default: - yield return new HitResultDisplayStatistic(r.result, value, null, r.displayName); - - break; - } - } - } - - public EFScoreInfo DeepClone() - { - var clone = (EFScoreInfo)MemberwiseClone(); - - clone.Statistics = new Dictionary(clone.Statistics); - - return clone; - } - - public override string ToString() => this.GetDisplayTitle(); - - public bool Equals(EFScoreInfo other) - { - if (ReferenceEquals(this, other)) return true; - if (other == null) return false; - - if (ID != 0 && other.ID != 0) - return ID == other.ID; - - return false; - } - - #region Implementation of IHasOnlineID - - long IHasOnlineID.OnlineID => OnlineID ?? -1; - - #endregion - - #region Implementation of IScoreInfo - - IBeatmapInfo IScoreInfo.Beatmap => BeatmapInfo; - IRulesetInfo IScoreInfo.Ruleset => Ruleset; - IUser IScoreInfo.User => User; - bool IScoreInfo.HasReplay => Files.Any(); - - #endregion - - IEnumerable IHasNamedFiles.Files => Files; - } -} diff --git a/osu.Game/Scoring/HitResultDisplayStatistic.cs b/osu.Game/Scoring/HitResultDisplayStatistic.cs index d43d8bf0ba..20deff4875 100644 --- a/osu.Game/Scoring/HitResultDisplayStatistic.cs +++ b/osu.Game/Scoring/HitResultDisplayStatistic.cs @@ -1,6 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using osu.Framework.Localisation; using osu.Game.Rulesets.Scoring; namespace osu.Game.Scoring @@ -28,9 +31,9 @@ namespace osu.Game.Scoring /// /// A custom display name for the result type. May be provided by rulesets to give better clarity. /// - public string DisplayName { get; } + public LocalisableString DisplayName { get; } - public HitResultDisplayStatistic(HitResult result, int count, int? maxCount, string displayName) + public HitResultDisplayStatistic(HitResult result, int count, int? maxCount, LocalisableString displayName) { Result = result; Count = count; diff --git a/osu.Game/Scoring/IScoreInfo.cs b/osu.Game/Scoring/IScoreInfo.cs index b4ad183cd3..289679a724 100644 --- a/osu.Game/Scoring/IScoreInfo.cs +++ b/osu.Game/Scoring/IScoreInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Game.Beatmaps; using osu.Game.Database; diff --git a/osu.Game/Scoring/Legacy/DatabasedLegacyScoreDecoder.cs b/osu.Game/Scoring/Legacy/DatabasedLegacyScoreDecoder.cs index 03e13455f0..8908163646 100644 --- a/osu.Game/Scoring/Legacy/DatabasedLegacyScoreDecoder.cs +++ b/osu.Game/Scoring/Legacy/DatabasedLegacyScoreDecoder.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Beatmaps; using osu.Game.Rulesets; diff --git a/osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs b/osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs new file mode 100644 index 0000000000..f2e8cf141b --- /dev/null +++ b/osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.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; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Scoring.Legacy +{ + /// + /// A minified version of retrofit onto the end of legacy replay files (.osr), + /// containing the minimum data required to support storage of non-legacy replays. + /// + [Serializable] + [JsonObject(MemberSerialization.OptIn)] + public class LegacyReplaySoloScoreInfo + { + [JsonProperty("mods")] + public APIMod[] Mods { get; set; } = Array.Empty(); + + [JsonProperty("statistics")] + public Dictionary Statistics { get; set; } = new Dictionary(); + + [JsonProperty("maximum_statistics")] + public Dictionary MaximumStatistics { get; set; } = new Dictionary(); + + public static LegacyReplaySoloScoreInfo FromScore(ScoreInfo score) => new LegacyReplaySoloScoreInfo + { + Mods = score.APIMods, + Statistics = score.Statistics.Where(kvp => kvp.Value != 0).ToDictionary(kvp => kvp.Key, kvp => kvp.Value), + MaximumStatistics = score.MaximumStatistics.Where(kvp => kvp.Value != 0).ToDictionary(kvp => kvp.Key, kvp => kvp.Value), + }; + } +} diff --git a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs index 754ace82c5..9b145ad56e 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs @@ -1,9 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; +using System.Diagnostics; using System.IO; using System.Linq; +using Newtonsoft.Json; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; using osu.Game.Beatmaps.Legacy; @@ -42,10 +46,12 @@ namespace osu.Game.Scoring.Legacy score.ScoreInfo = scoreInfo; int version = sr.ReadInt32(); + string beatmapHash = sr.ReadString(); + + workingBeatmap = GetBeatmap(beatmapHash); - workingBeatmap = GetBeatmap(sr.ReadString()); if (workingBeatmap is DummyWorkingBeatmap) - throw new BeatmapNotFoundException(); + throw new BeatmapNotFoundException(beatmapHash); scoreInfo.User = new APIUser { Username = sr.ReadString() }; @@ -89,31 +95,26 @@ namespace osu.Game.Scoring.Legacy else if (version >= 20121008) scoreInfo.OnlineID = sr.ReadInt32(); + byte[] compressedScoreInfo = null; + + if (version >= 30000001) + compressedScoreInfo = sr.ReadByteArray(); + if (compressedReplay?.Length > 0) + readCompressedData(compressedReplay, reader => readLegacyReplay(score.Replay, reader)); + + if (compressedScoreInfo?.Length > 0) { - using (var replayInStream = new MemoryStream(compressedReplay)) + readCompressedData(compressedScoreInfo, reader => { - byte[] properties = new byte[5]; - if (replayInStream.Read(properties, 0, 5) != 5) - throw new IOException("input .lzma is too short"); + LegacyReplaySoloScoreInfo readScore = JsonConvert.DeserializeObject(reader.ReadToEnd()); - long outSize = 0; + Debug.Assert(readScore != null); - for (int i = 0; i < 8; i++) - { - int v = replayInStream.ReadByte(); - if (v < 0) - throw new IOException("Can't Read 1"); - - outSize |= (long)(byte)v << (8 * i); - } - - long compressedSize = replayInStream.Length - replayInStream.Position; - - using (var lzma = new LzmaStream(properties, replayInStream, compressedSize, outSize)) - using (var reader = new StreamReader(lzma)) - readLegacyReplay(score.Replay, reader); - } + score.ScoreInfo.Statistics = readScore.Statistics; + score.ScoreInfo.MaximumStatistics = readScore.MaximumStatistics; + score.ScoreInfo.Mods = readScore.Mods.Select(m => m.ToMod(currentRuleset)).ToArray(); + }); } } @@ -122,10 +123,38 @@ namespace osu.Game.Scoring.Legacy // before returning for database import, we must restore the database-sourced BeatmapInfo. // if not, the clone operation in GetPlayableBeatmap will cause a dereference and subsequent database exception. score.ScoreInfo.BeatmapInfo = workingBeatmap.BeatmapInfo; + score.ScoreInfo.BeatmapHash = workingBeatmap.BeatmapInfo.Hash; return score; } + private void readCompressedData(byte[] data, Action readFunc) + { + using (var replayInStream = new MemoryStream(data)) + { + byte[] properties = new byte[5]; + if (replayInStream.Read(properties, 0, 5) != 5) + throw new IOException("input .lzma is too short"); + + long outSize = 0; + + for (int i = 0; i < 8; i++) + { + int v = replayInStream.ReadByte(); + if (v < 0) + throw new IOException("Can't Read 1"); + + outSize |= (long)(byte)v << (8 * i); + } + + long compressedSize = replayInStream.Length - replayInStream.Position; + + using (var lzma = new LzmaStream(properties, replayInStream, compressedSize, outSize)) + using (var reader = new StreamReader(lzma)) + readFunc(reader); + } + } + /// /// Populates the accuracy of a given from its contained statistics. /// @@ -308,9 +337,11 @@ namespace osu.Game.Scoring.Legacy public class BeatmapNotFoundException : Exception { - public BeatmapNotFoundException() - : base("No corresponding beatmap for the score could be found.") + public string Hash { get; } + + public BeatmapNotFoundException(string hash) { + Hash = hash; } } } diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs index ae9afbf32e..a78ae24da2 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.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 enable - using System; +using System.Diagnostics; using System.IO; using System.Linq; using System.Text; @@ -12,6 +11,7 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; using osu.Game.Extensions; using osu.Game.IO.Legacy; +using osu.Game.IO.Serialization; using osu.Game.Replays.Legacy; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays.Types; @@ -25,7 +25,12 @@ namespace osu.Game.Scoring.Legacy /// Database version in stable-compatible YYYYMMDD format. /// Should be incremented if any changes are made to the format/usage. /// - public const int LATEST_VERSION = FIRST_LAZER_VERSION; + /// + /// + /// 30000001: Appends to the end of scores. + /// + /// + public const int LATEST_VERSION = 30000001; /// /// The first stable-compatible YYYYMMDD format version given to lazer usage of replays. @@ -53,9 +58,9 @@ namespace osu.Game.Scoring.Legacy throw new ArgumentException(@"Only scores in the osu, taiko, catch, or mania rulesets can be encoded to the legacy score format.", nameof(score)); } - public void Encode(Stream stream) + public void Encode(Stream stream, bool leaveOpen = false) { - using (SerializationWriter sw = new SerializationWriter(stream)) + using (SerializationWriter sw = new SerializationWriter(stream, leaveOpen)) { sw.Write((byte)(score.ScoreInfo.Ruleset.OnlineID)); sw.Write(LATEST_VERSION); @@ -78,6 +83,7 @@ namespace osu.Game.Scoring.Legacy sw.WriteByteArray(createReplayData()); sw.Write((long)0); writeModSpecificData(score.ScoreInfo, sw); + sw.WriteByteArray(createScoreInfoData()); } } @@ -85,9 +91,13 @@ namespace osu.Game.Scoring.Legacy { } - private byte[] createReplayData() + private byte[] createReplayData() => compress(replayStringContent); + + private byte[] createScoreInfoData() => compress(LegacyReplaySoloScoreInfo.FromScore(score.ScoreInfo).Serialize()); + + private byte[] compress(string data) { - byte[] content = new ASCIIEncoding().GetBytes(replayStringContent); + byte[] content = new ASCIIEncoding().GetBytes(data); using (var outStream = new MemoryStream()) { @@ -145,6 +155,7 @@ namespace osu.Game.Scoring.Legacy return legacyFrame; case IConvertibleReplayFrame convertibleFrame: + Debug.Assert(beatmap != null); return convertibleFrame.ToLegacy(beatmap); default: diff --git a/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs b/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs index a11cd5fcbd..e42f6caf26 100644 --- a/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs +++ b/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs @@ -1,6 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System.Collections.Generic; using osu.Game.Rulesets.Scoring; namespace osu.Game.Scoring.Legacy @@ -11,6 +14,9 @@ namespace osu.Game.Scoring.Legacy { switch (scoreInfo.Ruleset.OnlineID) { + case 1: + return getCount(scoreInfo, HitResult.LargeBonus); + case 3: return getCount(scoreInfo, HitResult.Perfect); } @@ -22,6 +28,12 @@ namespace osu.Game.Scoring.Legacy { switch (scoreInfo.Ruleset.OnlineID) { + // For legacy scores, Geki indicates hit300 + perfect strong note hit. + // Lazer only has one result for a perfect strong note hit (LargeBonus). + case 1: + scoreInfo.Statistics[HitResult.LargeBonus] = scoreInfo.Statistics.GetValueOrDefault(HitResult.LargeBonus) + value; + break; + case 3: scoreInfo.Statistics[HitResult.Perfect] = value; break; @@ -36,11 +48,15 @@ namespace osu.Game.Scoring.Legacy { switch (scoreInfo.Ruleset.OnlineID) { - case 3: - return getCount(scoreInfo, HitResult.Good); + // For taiko, Katu is bundled into Geki. + case 1: + break; case 2: return getCount(scoreInfo, HitResult.SmallTickMiss); + + case 3: + return getCount(scoreInfo, HitResult.Good); } return null; @@ -50,13 +66,19 @@ namespace osu.Game.Scoring.Legacy { switch (scoreInfo.Ruleset.OnlineID) { - case 3: - scoreInfo.Statistics[HitResult.Good] = value; + // For legacy scores, Katu indicates hit100 + perfect strong note hit. + // Lazer only has one result for a perfect strong note hit (LargeBonus). + case 1: + scoreInfo.Statistics[HitResult.LargeBonus] = scoreInfo.Statistics.GetValueOrDefault(HitResult.LargeBonus) + value; break; case 2: scoreInfo.Statistics[HitResult.SmallTickMiss] = value; break; + + case 3: + scoreInfo.Statistics[HitResult.Good] = value; + break; } } diff --git a/osu.Game/Scoring/LegacyDatabasedScore.cs b/osu.Game/Scoring/LegacyDatabasedScore.cs index ac444c1bf3..a7641c7999 100644 --- a/osu.Game/Scoring/LegacyDatabasedScore.cs +++ b/osu.Game/Scoring/LegacyDatabasedScore.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using osu.Framework.IO.Stores; @@ -23,7 +25,12 @@ namespace osu.Game.Scoring return; using (var stream = store.GetStream(replayFilename)) + { + if (stream == null) + return; + Replay = new DatabasedLegacyScoreDecoder(rulesets, beatmaps).Parse(stream).Replay; + } } } } diff --git a/osu.Game/Scoring/RankingTier.cs b/osu.Game/Scoring/RankingTier.cs new file mode 100644 index 0000000000..e57c241515 --- /dev/null +++ b/osu.Game/Scoring/RankingTier.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.Scoring +{ + public enum RankingTier + { + Iron, + Bronze, + Silver, + Gold, + Platinum, + Rhodium, + Radiant, + Lustrous + } +} diff --git a/osu.Game/Scoring/Score.cs b/osu.Game/Scoring/Score.cs index 83e4389dc8..06bc3edd37 100644 --- a/osu.Game/Scoring/Score.cs +++ b/osu.Game/Scoring/Score.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Replays; using osu.Game.Utils; diff --git a/osu.Game/Scoring/ScoreFileInfo.cs b/osu.Game/Scoring/ScoreFileInfo.cs deleted file mode 100644 index 8acc98eff6..0000000000 --- a/osu.Game/Scoring/ScoreFileInfo.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.ComponentModel.DataAnnotations; -using osu.Game.Database; -using osu.Game.IO; - -namespace osu.Game.Scoring -{ - public class ScoreFileInfo : INamedFileInfo, IHasPrimaryKey, INamedFileUsage - { - public int ID { get; set; } - - public bool IsManaged => ID > 0; - - public int ScoreInfoID { get; set; } - - public EFScoreInfo ScoreInfo { get; set; } - - public int FileInfoID { get; set; } - - public FileInfo FileInfo { get; set; } - - [Required] - public string Filename { get; set; } - - IFileInfo INamedFileUsage.File => FileInfo; - } -} diff --git a/osu.Game/Scoring/ScoreImporter.cs b/osu.Game/Scoring/ScoreImporter.cs new file mode 100644 index 0000000000..f69c1b9385 --- /dev/null +++ b/osu.Game/Scoring/ScoreImporter.cs @@ -0,0 +1,161 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using Newtonsoft.Json; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.IO.Archives; +using osu.Game.Rulesets; +using osu.Game.Scoring.Legacy; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using Realms; + +namespace osu.Game.Scoring +{ + public class ScoreImporter : RealmArchiveModelImporter + { + public override IEnumerable HandledExtensions => new[] { ".osr" }; + + protected override string[] HashableFileTypes => new[] { ".osr" }; + + private readonly RulesetStore rulesets; + private readonly Func beatmaps; + + private readonly IAPIProvider api; + + public ScoreImporter(RulesetStore rulesets, Func beatmaps, Storage storage, RealmAccess realm, IAPIProvider api) + : base(storage, realm) + { + this.rulesets = rulesets; + this.beatmaps = beatmaps; + this.api = api; + } + + protected override ScoreInfo? CreateModel(ArchiveReader archive) + { + string name = archive.Filenames.First(f => f.EndsWith(".osr", StringComparison.OrdinalIgnoreCase)); + + using (var stream = archive.GetStream(name)) + { + try + { + return new DatabasedLegacyScoreDecoder(rulesets, beatmaps()).Parse(stream).ScoreInfo; + } + catch (LegacyScoreDecoder.BeatmapNotFoundException e) + { + Logger.Log($@"Score '{name}' failed to import: no corresponding beatmap with the hash '{e.Hash}' could be found.", LoggingTarget.Database); + return null; + } + } + } + + public Score GetScore(ScoreInfo score) => new LegacyDatabasedScore(score, rulesets, beatmaps(), Files.Store); + + protected override void Populate(ScoreInfo model, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default) + { + // Ensure the beatmap is not detached. + if (!model.BeatmapInfo.IsManaged) + model.BeatmapInfo = realm.Find(model.BeatmapInfo.ID); + + if (!model.Ruleset.IsManaged) + model.Ruleset = realm.Find(model.Ruleset.ShortName); + + // These properties are known to be non-null, but these final checks ensure a null hasn't come from somewhere (or the refetch has failed). + // Under no circumstance do we want these to be written to realm as null. + ArgumentNullException.ThrowIfNull(model.BeatmapInfo); + ArgumentNullException.ThrowIfNull(model.Ruleset); + + PopulateMaximumStatistics(model); + + if (string.IsNullOrEmpty(model.StatisticsJson)) + model.StatisticsJson = JsonConvert.SerializeObject(model.Statistics); + + if (string.IsNullOrEmpty(model.MaximumStatisticsJson)) + model.MaximumStatisticsJson = JsonConvert.SerializeObject(model.MaximumStatistics); + } + + /// + /// Populates the for a given . + /// + /// The score to populate the statistics of. + public void PopulateMaximumStatistics(ScoreInfo score) + { + if (score.MaximumStatistics.Select(kvp => kvp.Value).Sum() > 0) + return; + + var beatmap = score.BeatmapInfo.Detach(); + var ruleset = score.Ruleset.Detach(); + var rulesetInstance = ruleset.CreateInstance(); + + Debug.Assert(rulesetInstance != null); + + // Populate the maximum statistics. + HitResult maxBasicResult = rulesetInstance.GetHitResults() + .Select(h => h.result) + .Where(h => h.IsBasic()).MaxBy(Judgement.ToNumericResult); + + foreach ((HitResult result, int count) in score.Statistics) + { + switch (result) + { + case HitResult.LargeTickHit: + case HitResult.LargeTickMiss: + score.MaximumStatistics[HitResult.LargeTickHit] = score.MaximumStatistics.GetValueOrDefault(HitResult.LargeTickHit) + count; + break; + + case HitResult.SmallTickHit: + case HitResult.SmallTickMiss: + score.MaximumStatistics[HitResult.SmallTickHit] = score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit) + count; + break; + + case HitResult.IgnoreHit: + case HitResult.IgnoreMiss: + case HitResult.SmallBonus: + case HitResult.LargeBonus: + break; + + default: + score.MaximumStatistics[maxBasicResult] = score.MaximumStatistics.GetValueOrDefault(maxBasicResult) + count; + break; + } + } + + if (!score.IsLegacyScore) + return; + +#pragma warning disable CS0618 + // In osu! and osu!mania, some judgements affect combo but aren't stored to scores. + // A special hit result is used to pad out the combo value to match, based on the max combo from the difficulty attributes. + var calculator = rulesetInstance.CreateDifficultyCalculator(beatmaps().GetWorkingBeatmap(beatmap)); + var attributes = calculator.Calculate(score.Mods); + + int maxComboFromStatistics = score.MaximumStatistics.Where(kvp => kvp.Key.AffectsCombo()).Select(kvp => kvp.Value).DefaultIfEmpty(0).Sum(); + if (attributes.MaxCombo > maxComboFromStatistics) + score.MaximumStatistics[HitResult.LegacyComboIncrease] = attributes.MaxCombo - maxComboFromStatistics; +#pragma warning restore CS0618 + } + + protected override void PostImport(ScoreInfo model, Realm realm, ImportParameters parameters) + { + base.PostImport(model, realm, parameters); + + var userRequest = new GetUserRequest(model.RealmUser.Username); + + api.Perform(userRequest); + + if (userRequest.Response is APIUser user) + model.User = user; + } + } +} diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index d7185a1677..02c7acf350 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -20,10 +20,11 @@ using osu.Game.Users; using osu.Game.Utils; using Realms; -#nullable enable - 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 @@ -31,8 +32,19 @@ namespace osu.Game.Scoring [PrimaryKey] public Guid ID { get; set; } + /// + /// The this score was made against. + /// + /// + /// When setting this, make sure to also set to allow relational consistency when a beatmap is potentially changed. + /// public BeatmapInfo BeatmapInfo { get; set; } = null!; + /// + /// The at the point in time when the score was set. + /// + public string BeatmapHash { get; set; } = string.Empty; + public RulesetInfo Ruleset { get; set; } = null!; public IList Files { get; } = null!; @@ -47,7 +59,7 @@ namespace osu.Game.Scoring public double Accuracy { get; set; } - public bool HasReplay { get; set; } + public bool HasReplay => !string.IsNullOrEmpty(Hash); public DateTimeOffset Date { get; set; } @@ -65,6 +77,9 @@ namespace osu.Game.Scoring [MapTo("Statistics")] public string StatisticsJson { get; set; } = string.Empty; + [MapTo("MaximumStatistics")] + public string MaximumStatisticsJson { get; set; } = string.Empty; + public ScoreInfo(BeatmapInfo? beatmap = null, RulesetInfo? ruleset = null, RealmUser? realmUser = null) { Ruleset = ruleset ?? new RulesetInfo(); @@ -87,8 +102,9 @@ namespace osu.Game.Scoring { get => user ??= new APIUser { - Username = RealmUser.Username, Id = RealmUser.OnlineID, + Username = RealmUser.Username, + CountryCode = RealmUser.CountryCode, }; set { @@ -97,7 +113,8 @@ namespace osu.Game.Scoring RealmUser = new RealmUser { OnlineID = user.OnlineID, - Username = user.Username + Username = user.Username, + CountryCode = user.CountryCode, }; } } @@ -133,10 +150,17 @@ namespace osu.Game.Scoring var clone = (ScoreInfo)this.Detach().MemberwiseClone(); clone.Statistics = new Dictionary(clone.Statistics); + clone.MaximumStatistics = new Dictionary(clone.MaximumStatistics); + + // Ensure we have fresh mods to avoid any references (ie. after gameplay). + clone.clearAllMods(); + clone.ModsJson = ModsJson; + clone.RealmUser = new RealmUser { OnlineID = RealmUser.OnlineID, Username = RealmUser.Username, + CountryCode = RealmUser.CountryCode, }; return clone; @@ -180,6 +204,24 @@ namespace osu.Game.Scoring set => statistics = value; } + private Dictionary? maximumStatistics; + + [Ignored] + public Dictionary MaximumStatistics + { + get + { + if (maximumStatistics != null) + return maximumStatistics; + + if (!string.IsNullOrEmpty(MaximumStatisticsJson)) + maximumStatistics = JsonConvert.DeserializeObject>(MaximumStatisticsJson); + + return maximumStatistics ??= new Dictionary(); + } + set => maximumStatistics = value; + } + private Mod[]? mods; [Ignored] @@ -268,6 +310,13 @@ namespace osu.Game.Scoring break; } + case HitResult.LargeBonus: + case HitResult.SmallBonus: + if (MaximumStatistics.TryGetValue(r.result, out int count) && count > 0) + yield return new HitResultDisplayStatistic(r.result, value, null, r.displayName); + + break; + case HitResult.SmallTickMiss: case HitResult.LargeTickMiss: break; @@ -282,7 +331,7 @@ namespace osu.Game.Scoring #endregion - public bool Equals(ScoreInfo other) => other.ID == ID; + public bool Equals(ScoreInfo? other) => other?.ID == ID; public override string ToString() => this.GetDisplayTitle(); } diff --git a/osu.Game/Scoring/ScoreInfoExtensions.cs b/osu.Game/Scoring/ScoreInfoExtensions.cs index 2279337fef..7979ca8aaa 100644 --- a/osu.Game/Scoring/ScoreInfoExtensions.cs +++ b/osu.Game/Scoring/ScoreInfoExtensions.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Beatmaps; namespace osu.Game.Scoring diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index fbec6ea1fb..3217c79768 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -9,9 +11,7 @@ using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Bindables; -using osu.Framework.Extensions; using osu.Framework.Platform; -using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; @@ -19,29 +19,38 @@ using osu.Game.IO.Archives; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Rulesets.Scoring; +using osu.Game.Online.API; namespace osu.Game.Scoring { - public class ScoreManager : IModelManager, IModelImporter + public class ScoreManager : ModelManager, IModelImporter { - private readonly RealmAccess realm; - private readonly Scheduler scheduler; - private readonly Func difficulties; private readonly OsuConfigManager configManager; - private readonly ScoreModelManager scoreModelManager; + private readonly ScoreImporter scoreImporter; - public ScoreManager(RulesetStore rulesets, Func beatmaps, Storage storage, RealmAccess realm, Scheduler scheduler, - Func difficulties = null, OsuConfigManager configManager = null) + public override bool PauseImports { - this.realm = realm; - this.scheduler = scheduler; - this.difficulties = difficulties; - this.configManager = configManager; - - scoreModelManager = new ScoreModelManager(rulesets, beatmaps, storage, realm); + get => base.PauseImports; + set + { + base.PauseImports = value; + scoreImporter.PauseImports = value; + } } - public Score GetScore(ScoreInfo score) => scoreModelManager.GetScore(score); + public ScoreManager(RulesetStore rulesets, Func beatmaps, Storage storage, RealmAccess realm, IAPIProvider api, + OsuConfigManager configManager = null) + : base(storage, realm) + { + this.configManager = configManager; + + scoreImporter = new ScoreImporter(rulesets, beatmaps, storage, realm, api) + { + PostNotification = obj => PostNotification?.Invoke(obj) + }; + } + + public Score GetScore(ScoreInfo score) => scoreImporter.GetScore(score); /// /// Perform a lookup query on available s. @@ -50,37 +59,19 @@ namespace osu.Game.Scoring /// The first result for the provided query, or null if no results were found. public ScoreInfo Query(Expression> query) { - return realm.Run(r => r.All().FirstOrDefault(query)?.Detach()); + return Realm.Run(r => r.All().FirstOrDefault(query)?.Detach()); } /// /// Orders an array of s by total score. /// /// The array of s to reorder. - /// A to cancel the process. /// The given ordered by decreasing total score. - public async Task OrderByTotalScoreAsync(ScoreInfo[] scores, CancellationToken cancellationToken = default) - { - var difficultyCache = difficulties?.Invoke(); - - if (difficultyCache != null) - { - // Compute difficulties asynchronously first to prevent blocking via the GetTotalScore() call below. - foreach (var s in scores) - { - await difficultyCache.GetDifficultyAsync(s.BeatmapInfo, s.Ruleset, s.Mods, cancellationToken).ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); - } - } - - long[] totalScores = await Task.WhenAll(scores.Select(s => GetTotalScoreAsync(s, cancellationToken: cancellationToken))).ConfigureAwait(false); - - return scores.Select((score, index) => (score, totalScore: totalScores[index])) - .OrderByDescending(g => g.totalScore) - .ThenBy(g => g.score.OnlineID) - .Select(g => g.score) - .ToArray(); - } + public IEnumerable OrderByTotalScore(IEnumerable scores) + => scores.OrderByDescending(s => GetTotalScore(s)) + .ThenBy(s => s.OnlineID) + // Local scores may not have an online ID. Fall back to date in these cases. + .ThenBy(s => s.Date); /// /// Retrieves a bindable that represents the total score of a . @@ -102,80 +93,31 @@ 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 score is returned in a callback that is run on the update thread. - /// - /// The to calculate the total score of. - /// The callback to be invoked with the total score. - /// The to return the total score as. - /// A to cancel the process. - public void GetTotalScore([NotNull] ScoreInfo score, [NotNull] Action callback, ScoringMode mode = ScoringMode.Standardised, CancellationToken cancellationToken = default) - { - GetTotalScoreAsync(score, mode, cancellationToken) - .ContinueWith(task => scheduler.Add(() => - { - if (!cancellationToken.IsCancellationRequested) - callback(task.GetResultSafely()); - }), TaskContinuationOptions.OnlyOnRanToCompletion); - } - /// /// Retrieves the total score of a in the given . /// /// The to calculate the total score of. /// The to return the total score as. - /// A to cancel the process. /// The total score. - public async Task GetTotalScoreAsync([NotNull] ScoreInfo score, ScoringMode mode = ScoringMode.Standardised, CancellationToken cancellationToken = default) + 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; - int? beatmapMaxCombo = await GetMaximumAchievableComboAsync(score, cancellationToken).ConfigureAwait(false); - if (beatmapMaxCombo == null) - return score.TotalScore; - - if (beatmapMaxCombo == 0) - return 0; - var ruleset = score.Ruleset.CreateInstance(); var scoreProcessor = ruleset.CreateScoreProcessor(); scoreProcessor.Mods.Value = score.Mods; - return (long)Math.Round(scoreProcessor.ComputeFinalLegacyScore(mode, score, beatmapMaxCombo.Value)); + return scoreProcessor.ComputeScore(mode, score); } /// /// Retrieves the maximum achievable combo for the provided score. /// /// The to compute the maximum achievable combo for. - /// A to cancel the process. - /// The maximum achievable combo. A return value indicates the difficulty cache has failed to retrieve the combo. - public async Task GetMaximumAchievableComboAsync([NotNull] ScoreInfo score, CancellationToken cancellationToken = default) - { - if (score.IsLegacyScore) - { - // This score is guaranteed to be an osu!stable score. - // The combo must be determined through either the beatmap's max combo value or the difficulty calculator, as lazer's scoring has changed and the score statistics cannot be used. -#pragma warning disable CS0618 - if (score.BeatmapInfo.MaxCombo != null) - return score.BeatmapInfo.MaxCombo.Value; -#pragma warning restore CS0618 - - if (difficulties == null) - return null; - - // We can compute the max combo locally after the async beatmap difficulty computation. - var difficulty = await difficulties().GetDifficultyAsync(score.BeatmapInfo, score.Ruleset, score.Mods, cancellationToken).ConfigureAwait(false); - return difficulty?.MaxCombo; - } - - // This is guaranteed to be a non-legacy score. - // The combo must be determined through the score's statistics, as both the beatmap's max combo and the difficulty calculator will provide osu!stable combo values. - return Enum.GetValues(typeof(HitResult)).OfType().Where(r => r.AffectsCombo()).Select(r => score.Statistics.GetValueOrDefault(r)).Sum(); - } + /// The maximum achievable combo. + public int GetMaximumAchievableCombo([NotNull] ScoreInfo score) => score.MaximumStatistics.Where(kvp => kvp.Key.AffectsCombo()).Sum(kvp => kvp.Value); /// /// Provides the total score of a . Responds to changes in the currently-selected . @@ -183,10 +125,6 @@ namespace osu.Game.Scoring private class TotalScoreBindable : Bindable { private readonly Bindable scoringMode = new Bindable(); - private readonly ScoreInfo score; - private readonly ScoreManager scoreManager; - - private CancellationTokenSource difficultyCalculationCancellationSource; /// /// Creates a new . @@ -196,19 +134,8 @@ namespace osu.Game.Scoring /// The config. public TotalScoreBindable(ScoreInfo score, ScoreManager scoreManager, OsuConfigManager configManager) { - this.score = score; - this.scoreManager = scoreManager; - configManager?.BindWith(OsuSetting.ScoreDisplayMode, scoringMode); - scoringMode.BindValueChanged(onScoringModeChanged, true); - } - - private void onScoringModeChanged(ValueChangedEvent mode) - { - difficultyCalculationCancellationSource?.Cancel(); - difficultyCalculationCancellationSource = new CancellationTokenSource(); - - scoreManager.GetTotalScore(score, s => Value = s, mode.NewValue, difficultyCalculationCancellationSource.Token); + scoringMode.BindValueChanged(mode => Value = scoreManager.GetTotalScore(score, mode.NewValue), true); } } @@ -227,25 +154,9 @@ namespace osu.Game.Scoring } } - #region Implementation of IPostNotifications - - public Action PostNotification - { - set => scoreModelManager.PostNotification = value; - } - - #endregion - - #region Implementation of IModelManager - - public bool Delete(ScoreInfo item) - { - return scoreModelManager.Delete(item); - } - public void Delete([CanBeNull] Expression> filter = null, bool silent = false) { - realm.Run(r => + Realm.Run(r => { var items = r.All() .Where(s => !s.DeletePending); @@ -253,78 +164,45 @@ namespace osu.Game.Scoring if (filter != null) items = items.Where(filter); - scoreModelManager.Delete(items.ToList(), silent); + Delete(items.ToList(), silent); }); } public void Delete(BeatmapInfo beatmap, bool silent = false) { - realm.Run(r => + Realm.Run(r => { var beatmapScores = r.Find(beatmap.ID).Scores.ToList(); - scoreModelManager.Delete(beatmapScores, silent); + Delete(beatmapScores, silent); }); } - public void Delete(List items, bool silent = false) - { - scoreModelManager.Delete(items, silent); - } + public Task Import(params string[] paths) => scoreImporter.Import(paths); - public void Undelete(List items, bool silent = false) - { - scoreModelManager.Undelete(items, silent); - } + public Task Import(ImportTask[] imports, ImportParameters parameters = default) => scoreImporter.Import(imports, parameters); - public void Undelete(ScoreInfo item) - { - scoreModelManager.Undelete(item); - } + public override bool IsAvailableLocally(ScoreInfo model) => Realm.Run(realm => realm.All().Any(s => s.OnlineID == model.OnlineID)); - public Task Import(params string[] paths) - { - return scoreModelManager.Import(paths); - } + public IEnumerable HandledExtensions => scoreImporter.HandledExtensions; - public Task Import(params ImportTask[] tasks) - { - return scoreModelManager.Import(tasks); - } + public Task>> Import(ProgressNotification notification, ImportTask[] tasks, ImportParameters parameters = default) => scoreImporter.Import(notification, tasks); - public IEnumerable HandledExtensions => scoreModelManager.HandledExtensions; + public Task> ImportAsUpdate(ProgressNotification notification, ImportTask task, ScoreInfo original) => scoreImporter.ImportAsUpdate(notification, task, original); - public Task>> Import(ProgressNotification notification, params ImportTask[] tasks) - { - return scoreModelManager.Import(notification, tasks); - } + public Live Import(ScoreInfo item, ArchiveReader archive = null, ImportParameters parameters = default, CancellationToken cancellationToken = default) => + scoreImporter.ImportModel(item, archive, parameters, cancellationToken); - public Task> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default) - { - return scoreModelManager.Import(task, lowPriority, cancellationToken); - } - - public Task> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default) - { - return scoreModelManager.Import(archive, lowPriority, cancellationToken); - } - - public Live Import(ScoreInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) - { - return scoreModelManager.Import(item, archive, lowPriority, cancellationToken); - } - - public bool IsAvailableLocally(ScoreInfo model) - { - return scoreModelManager.IsAvailableLocally(model); - } - - #endregion + /// + /// Populates the for a given . + /// + /// The score to populate the statistics of. + public void PopulateMaximumStatistics(ScoreInfo score) => scoreImporter.PopulateMaximumStatistics(score); #region Implementation of IPresentImports - public Action>> PostImport + public Action>> PresentImport { - set => scoreModelManager.PostImport = value; + set => scoreImporter.PresentImport = value; } #endregion diff --git a/osu.Game/Scoring/ScoreModelDownloader.cs b/osu.Game/Scoring/ScoreModelDownloader.cs index 514b7a57de..c5434e8faf 100644 --- a/osu.Game/Scoring/ScoreModelDownloader.cs +++ b/osu.Game/Scoring/ScoreModelDownloader.cs @@ -17,7 +17,7 @@ namespace osu.Game.Scoring protected override ArchiveDownloadRequest CreateDownloadRequest(IScoreInfo score, bool minimiseDownload) => new DownloadReplayRequest(score); - public override ArchiveDownloadRequest GetExistingDownload(IScoreInfo model) + public override ArchiveDownloadRequest? GetExistingDownload(IScoreInfo model) => CurrentDownloads.Find(r => r.Model.MatchesOnlineID(model)); } } diff --git a/osu.Game/Scoring/ScoreModelManager.cs b/osu.Game/Scoring/ScoreModelManager.cs deleted file mode 100644 index 59102360f9..0000000000 --- a/osu.Game/Scoring/ScoreModelManager.cs +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using Newtonsoft.Json; -using osu.Framework.Logging; -using osu.Framework.Platform; -using osu.Game.Beatmaps; -using osu.Game.Database; -using osu.Game.IO.Archives; -using osu.Game.Rulesets; -using osu.Game.Scoring.Legacy; -using osu.Game.Stores; -using Realms; - -#nullable enable - -namespace osu.Game.Scoring -{ - public class ScoreModelManager : RealmArchiveModelManager - { - public override IEnumerable HandledExtensions => new[] { ".osr" }; - - protected override string[] HashableFileTypes => new[] { ".osr" }; - - private readonly RulesetStore rulesets; - private readonly Func beatmaps; - - public ScoreModelManager(RulesetStore rulesets, Func beatmaps, Storage storage, RealmAccess realm) - : base(storage, realm) - { - this.rulesets = rulesets; - this.beatmaps = beatmaps; - } - - protected override ScoreInfo? CreateModel(ArchiveReader archive) - { - using (var stream = archive.GetStream(archive.Filenames.First(f => f.EndsWith(".osr", StringComparison.OrdinalIgnoreCase)))) - { - try - { - return new DatabasedLegacyScoreDecoder(rulesets, beatmaps()).Parse(stream).ScoreInfo; - } - catch (LegacyScoreDecoder.BeatmapNotFoundException e) - { - Logger.Log(e.Message, LoggingTarget.Information, LogLevel.Error); - return null; - } - } - } - - public Score GetScore(ScoreInfo score) => new LegacyDatabasedScore(score, rulesets, beatmaps(), Files.Store); - - protected override void Populate(ScoreInfo model, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default) - { - // Ensure the beatmap is not detached. - if (!model.BeatmapInfo.IsManaged) - model.BeatmapInfo = realm.Find(model.BeatmapInfo.ID); - - if (!model.Ruleset.IsManaged) - model.Ruleset = realm.Find(model.Ruleset.ShortName); - - // These properties are known to be non-null, but these final checks ensure a null hasn't come from somewhere (or the refetch has failed). - // Under no circumstance do we want these to be written to realm as null. - if (model.BeatmapInfo == null) throw new ArgumentNullException(nameof(model.BeatmapInfo)); - if (model.Ruleset == null) throw new ArgumentNullException(nameof(model.Ruleset)); - - if (string.IsNullOrEmpty(model.StatisticsJson)) - model.StatisticsJson = JsonConvert.SerializeObject(model.Statistics); - } - - public override bool IsAvailableLocally(ScoreInfo model) - { - return Realm.Run(realm => realm.All().Any(s => s.OnlineID == model.OnlineID)); - } - } -} diff --git a/osu.Game/Scoring/ScorePerformanceCache.cs b/osu.Game/Scoring/ScorePerformanceCache.cs index e15d59e648..17a0c0ea6a 100644 --- a/osu.Game/Scoring/ScorePerformanceCache.cs +++ b/osu.Game/Scoring/ScorePerformanceCache.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Threading; using System.Threading.Tasks; @@ -16,7 +18,7 @@ namespace osu.Game.Scoring /// A component which performs and acts as a central cache for performance calculations of locally databased scores. /// Currently not persisted between game sessions. /// - public class ScorePerformanceCache : MemoryCachingComponent + public partial class ScorePerformanceCache : MemoryCachingComponent { [Resolved] private BeatmapDifficultyCache difficultyCache { get; set; } diff --git a/osu.Game/Scoring/ScoreRank.cs b/osu.Game/Scoring/ScoreRank.cs index 64f7da9ba3..a1916953c4 100644 --- a/osu.Game/Scoring/ScoreRank.cs +++ b/osu.Game/Scoring/ScoreRank.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; @@ -9,6 +11,10 @@ namespace osu.Game.Scoring { public enum ScoreRank { + // TODO: Localisable? + [Description(@"F")] + F = -1, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankD))] [Description(@"D")] D, diff --git a/osu.Game/Scoring/ScoringValues.cs b/osu.Game/Scoring/ScoringValues.cs deleted file mode 100644 index d31cd7c68b..0000000000 --- a/osu.Game/Scoring/ScoringValues.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using MessagePack; -using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Scoring; - -namespace osu.Game.Scoring -{ - /// - /// Stores the required scoring data that fulfils the minimum requirements for a to calculate score. - /// - [MessagePackObject] - public struct ScoringValues - { - /// - /// The sum of all "basic" scoring values. See: and . - /// - [Key(0)] - public double BaseScore; - - /// - /// The sum of all "bonus" scoring values. See: and . - /// - [Key(1)] - public double BonusScore; - - /// - /// The highest achieved combo. - /// - [Key(2)] - public int MaxCombo; - - /// - /// The count of "basic" s. See: . - /// - [Key(3)] - public int CountBasicHitObjects; - } -} diff --git a/osu.Game/Screens/BackgroundScreen.cs b/osu.Game/Screens/BackgroundScreen.cs index 6084ec4b01..a7502f22d5 100644 --- a/osu.Game/Screens/BackgroundScreen.cs +++ b/osu.Game/Screens/BackgroundScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Screens; using osu.Framework.Graphics; @@ -9,7 +11,7 @@ using osuTK; namespace osu.Game.Screens { - public abstract class BackgroundScreen : Screen, IEquatable + public abstract partial class BackgroundScreen : Screen, IEquatable { protected const float TRANSITION_LENGTH = 500; private const float x_movement_amount = 50; diff --git a/osu.Game/Screens/BackgroundScreenStack.cs b/osu.Game/Screens/BackgroundScreenStack.cs index 9f562a618e..ca0dad83c8 100644 --- a/osu.Game/Screens/BackgroundScreenStack.cs +++ b/osu.Game/Screens/BackgroundScreenStack.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Screens; namespace osu.Game.Screens { - public class BackgroundScreenStack : ScreenStack + public partial class BackgroundScreenStack : ScreenStack { public BackgroundScreenStack() : base(false) diff --git a/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs b/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs index 65bc9cfaea..312fd496a1 100644 --- a/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs +++ b/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Threading; using osu.Framework.Allocation; @@ -15,7 +17,7 @@ using osuTK; namespace osu.Game.Screens.Backgrounds { - public class BackgroundScreenBeatmap : BackgroundScreen + public partial class BackgroundScreenBeatmap : BackgroundScreen { /// /// The amount of blur to apply when full user blur is requested. @@ -41,6 +43,11 @@ namespace osu.Game.Screens.Backgrounds /// public readonly Bindable BlurAmount = new BindableFloat(); + /// + /// The amount of dim to be used when is true. + /// + public readonly Bindable DimWhenUserSettingsIgnored = new Bindable(); + internal readonly IBindable IsBreakTime = new Bindable(); private readonly DimmableBackground dimmable; @@ -56,6 +63,7 @@ namespace osu.Game.Screens.Backgrounds dimmable.IgnoreUserSettings.BindTo(IgnoreUserSettings); dimmable.IsBreakTime.BindTo(IsBreakTime); dimmable.BlurAmount.BindTo(BlurAmount); + dimmable.DimWhenUserSettingsIgnored.BindTo(DimWhenUserSettingsIgnored); StoryboardReplacesBackground.BindTo(dimmable.StoryboardReplacesBackground); } @@ -91,6 +99,18 @@ namespace osu.Game.Screens.Backgrounds } } + /// + /// Reloads beatmap's background. + /// + public void RefreshBackground() + { + Schedule(() => + { + cancellationSource?.Cancel(); + LoadComponentAsync(new BeatmapBackground(beatmap), switchBackground, (cancellationSource = new CancellationTokenSource()).Token); + }); + } + private void switchBackground(BeatmapBackground b) { float newDepth = 0; @@ -114,7 +134,7 @@ namespace osu.Game.Screens.Backgrounds return base.Equals(other) && beatmap == otherBeatmapBackground.Beatmap; } - public class DimmableBackground : UserDimContainer + public partial class DimmableBackground : UserDimContainer { /// /// The amount of blur to be applied to the background in addition to user-specified blur. diff --git a/osu.Game/Screens/Backgrounds/BackgroundScreenBlack.cs b/osu.Game/Screens/Backgrounds/BackgroundScreenBlack.cs index d946fd41d9..09778c5cdf 100644 --- a/osu.Game/Screens/Backgrounds/BackgroundScreenBlack.cs +++ b/osu.Game/Screens/Backgrounds/BackgroundScreenBlack.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Screens; @@ -8,7 +10,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Backgrounds { - public class BackgroundScreenBlack : BackgroundScreen + public partial class BackgroundScreenBlack : BackgroundScreen { public BackgroundScreenBlack() { diff --git a/osu.Game/Screens/Backgrounds/BackgroundScreenCustom.cs b/osu.Game/Screens/Backgrounds/BackgroundScreenCustom.cs index 49c7934ed9..3c8ed6fe76 100644 --- a/osu.Game/Screens/Backgrounds/BackgroundScreenCustom.cs +++ b/osu.Game/Screens/Backgrounds/BackgroundScreenCustom.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Graphics.Backgrounds; namespace osu.Game.Screens.Backgrounds { - public class BackgroundScreenCustom : BackgroundScreen + public partial class BackgroundScreenCustom : BackgroundScreen { private readonly string textureName; diff --git a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs index 452f033dcc..0d9b39f099 100644 --- a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs +++ b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs @@ -1,10 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Logging; using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Beatmaps; @@ -16,7 +19,7 @@ using osu.Game.Skinning; namespace osu.Game.Screens.Backgrounds { - public class BackgroundScreenDefault : BackgroundScreen + public partial class BackgroundScreenDefault : BackgroundScreen { private Background background; @@ -24,7 +27,7 @@ namespace osu.Game.Screens.Backgrounds private const int background_count = 7; private IBindable user; private Bindable skin; - private Bindable mode; + private Bindable source; private Bindable introSequence; private readonly SeasonalBackgroundLoader seasonalBackgroundLoader = new SeasonalBackgroundLoader(); @@ -43,24 +46,29 @@ namespace osu.Game.Screens.Backgrounds { user = api.LocalUser.GetBoundCopy(); skin = skinManager.CurrentSkin.GetBoundCopy(); - mode = config.GetBindable(OsuSetting.MenuBackgroundSource); + source = config.GetBindable(OsuSetting.MenuBackgroundSource); introSequence = config.GetBindable(OsuSetting.IntroSequence); AddInternal(seasonalBackgroundLoader); - user.ValueChanged += _ => Scheduler.AddOnce(loadNextIfRequired); - skin.ValueChanged += _ => Scheduler.AddOnce(loadNextIfRequired); - mode.ValueChanged += _ => Scheduler.AddOnce(loadNextIfRequired); - beatmap.ValueChanged += _ => Scheduler.AddOnce(loadNextIfRequired); - introSequence.ValueChanged += _ => Scheduler.AddOnce(loadNextIfRequired); - seasonalBackgroundLoader.SeasonalBackgroundChanged += () => Scheduler.AddOnce(loadNextIfRequired); - + // Load first background asynchronously as part of BDL load. currentDisplay = RNG.Next(0, background_count); - Next(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + user.ValueChanged += _ => Scheduler.AddOnce(next); + skin.ValueChanged += _ => Scheduler.AddOnce(next); + source.ValueChanged += _ => Scheduler.AddOnce(next); + beatmap.ValueChanged += _ => Scheduler.AddOnce(next); + introSequence.ValueChanged += _ => Scheduler.AddOnce(next); + seasonalBackgroundLoader.SeasonalBackgroundChanged += () => Scheduler.AddOnce(next); // helper function required for AddOnce usage. - void loadNextIfRequired() => Next(); + void next() => Next(); } private ScheduledDelegate nextTask; @@ -78,6 +86,8 @@ namespace osu.Game.Screens.Backgrounds if (nextBackground == background) return false; + Logger.Log("🌅 Background change queued"); + cancellationTokenSource?.Cancel(); cancellationTokenSource = new CancellationTokenSource(); @@ -85,14 +95,14 @@ namespace osu.Game.Screens.Backgrounds nextTask = Scheduler.AddDelayed(() => { LoadComponentAsync(nextBackground, displayNext, cancellationTokenSource.Token); - }, 100); + }, 500); return true; } private void displayNext(Background newBackground) { - background?.FadeOut(800, Easing.InOutSine); + background?.FadeOut(800, Easing.OutQuint); background?.Expire(); AddInternal(background = newBackground); @@ -106,12 +116,12 @@ namespace osu.Game.Screens.Backgrounds if (newBackground == null && user.Value?.IsSupporter == true) { - switch (mode.Value) + switch (source.Value) { case BackgroundSource.Beatmap: case BackgroundSource.BeatmapWithStoryboard: { - if (mode.Value == BackgroundSource.BeatmapWithStoryboard && AllowStoryboardBackground) + if (source.Value == BackgroundSource.BeatmapWithStoryboard && AllowStoryboardBackground) newBackground = new BeatmapBackgroundWithStoryboard(beatmap.Value, getBackgroundTextureName()); newBackground ??= new BeatmapBackground(beatmap.Value, getBackgroundTextureName()); @@ -119,11 +129,19 @@ namespace osu.Game.Screens.Backgrounds } case BackgroundSource.Skin: - // default skins should use the default background rotation, which won't be the case if a SkinBackground is created for them. - if (skin.Value is DefaultSkin || skin.Value is DefaultLegacySkin) - break; + switch (skin.Value) + { + case TrianglesSkin: + case ArgonSkin: + case DefaultLegacySkin: + // default skins should use the default background rotation, which won't be the case if a SkinBackground is created for them. + break; + + default: + newBackground = new SkinBackground(skin.Value, getBackgroundTextureName()); + break; + } - newBackground = new SkinBackground(skin.Value, getBackgroundTextureName()); break; } } diff --git a/osu.Game/Screens/Edit/BackgroundDimMenuItem.cs b/osu.Game/Screens/Edit/BackgroundDimMenuItem.cs new file mode 100644 index 0000000000..2a1159eb27 --- /dev/null +++ b/osu.Game/Screens/Edit/BackgroundDimMenuItem.cs @@ -0,0 +1,46 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Bindables; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; + +namespace osu.Game.Screens.Edit +{ + internal class BackgroundDimMenuItem : MenuItem + { + private readonly Bindable backgroundDim; + + private readonly Dictionary menuItemLookup = new Dictionary(); + + public BackgroundDimMenuItem(Bindable backgroundDim) + : base(GameplaySettingsStrings.BackgroundDim) + { + Items = new[] + { + createMenuItem(0f), + createMenuItem(0.25f), + createMenuItem(0.5f), + createMenuItem(0.75f), + }; + + this.backgroundDim = backgroundDim; + backgroundDim.BindValueChanged(dim => + { + foreach (var kvp in menuItemLookup) + kvp.Value.State.Value = kvp.Key == dim.NewValue ? TernaryState.True : TernaryState.False; + }, true); + } + + private TernaryStateRadioMenuItem createMenuItem(float dim) + { + var item = new TernaryStateRadioMenuItem($"{dim * 100}%", MenuItemType.Standard, _ => updateOpacity(dim)); + menuItemLookup[dim] = item; + return item; + } + + private void updateOpacity(float dim) => backgroundDim.Value = dim; + } +} diff --git a/osu.Game/Screens/Edit/BeatmapEditorChangeHandler.cs b/osu.Game/Screens/Edit/BeatmapEditorChangeHandler.cs new file mode 100644 index 0000000000..3c19994a8a --- /dev/null +++ b/osu.Game/Screens/Edit/BeatmapEditorChangeHandler.cs @@ -0,0 +1,40 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using System.Text; +using osu.Game.Beatmaps.Formats; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Screens.Edit +{ + public partial class BeatmapEditorChangeHandler : EditorChangeHandler + { + private readonly LegacyEditorBeatmapPatcher patcher; + private readonly EditorBeatmap editorBeatmap; + + /// + /// Creates a new . + /// + /// The to track the s of. + public BeatmapEditorChangeHandler(EditorBeatmap editorBeatmap) + { + this.editorBeatmap = editorBeatmap; + + editorBeatmap.TransactionBegan += BeginChange; + editorBeatmap.TransactionEnded += EndChange; + editorBeatmap.SaveStateTriggered += SaveState; + + patcher = new LegacyEditorBeatmapPatcher(editorBeatmap); + } + + protected override void WriteCurrentStateToStream(MemoryStream stream) + { + using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) + new LegacyBeatmapEncoder(editorBeatmap, editorBeatmap.BeatmapSkin).Encode(sw); + } + + protected override void ApplyStateChange(byte[] previousState, byte[] newState) => + patcher.Patch(previousState, newState); + } +} diff --git a/osu.Game/Screens/Edit/BindableBeatDivisor.cs b/osu.Game/Screens/Edit/BindableBeatDivisor.cs index 8f430dce77..aa8e202e22 100644 --- a/osu.Game/Screens/Edit/BindableBeatDivisor.cs +++ b/osu.Game/Screens/Edit/BindableBeatDivisor.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Bindables; using osu.Game.Graphics; @@ -23,6 +25,26 @@ namespace osu.Game.Screens.Edit BindValueChanged(_ => ensureValidDivisor()); } + /// + /// Set a divisor, updating the valid divisor range appropriately. + /// + /// The intended divisor. + public void SetArbitraryDivisor(int divisor) + { + // If the current valid divisor range doesn't contain the proposed value, attempt to find one which does. + if (!ValidDivisors.Value.Presets.Contains(divisor)) + { + if (BeatDivisorPresetCollection.COMMON.Presets.Contains(divisor)) + ValidDivisors.Value = BeatDivisorPresetCollection.COMMON; + else if (BeatDivisorPresetCollection.TRIPLETS.Presets.Contains(divisor)) + ValidDivisors.Value = BeatDivisorPresetCollection.TRIPLETS; + else + ValidDivisors.Value = BeatDivisorPresetCollection.Custom(divisor); + } + + Value = divisor; + } + private void updateBindableProperties() { ensureValidDivisor(); diff --git a/osu.Game/Screens/Edit/BottomBar.cs b/osu.Game/Screens/Edit/BottomBar.cs index 62caaced89..b8fed4b935 100644 --- a/osu.Game/Screens/Edit/BottomBar.cs +++ b/osu.Game/Screens/Edit/BottomBar.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -15,7 +17,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Edit { - internal class BottomBar : CompositeDrawable + internal partial class BottomBar : CompositeDrawable { public TestGameplayButton TestGameplayButton { get; private set; } diff --git a/osu.Game/Screens/Edit/ClipboardContent.cs b/osu.Game/Screens/Edit/ClipboardContent.cs index 0348a7c15d..a39b24e7bb 100644 --- a/osu.Game/Screens/Edit/ClipboardContent.cs +++ b/osu.Game/Screens/Edit/ClipboardContent.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; diff --git a/osu.Game/Screens/Edit/Components/BottomBarContainer.cs b/osu.Game/Screens/Edit/Components/BottomBarContainer.cs index 3c63da3a4a..0ba1ab9258 100644 --- a/osu.Game/Screens/Edit/Components/BottomBarContainer.cs +++ b/osu.Game/Screens/Edit/Components/BottomBarContainer.cs @@ -12,7 +12,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Edit.Components { - public class BottomBarContainer : Container + public partial class BottomBarContainer : Container { private const float contents_padding = 15; diff --git a/osu.Game/Screens/Edit/Components/CircularButton.cs b/osu.Game/Screens/Edit/Components/CircularButton.cs deleted file mode 100644 index 40b5ac663a..0000000000 --- a/osu.Game/Screens/Edit/Components/CircularButton.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Graphics.UserInterface; -using osuTK; - -namespace osu.Game.Screens.Edit.Components -{ - public class CircularButton : OsuButton - { - private const float width = 125; - private const float height = 30; - - public CircularButton() - { - Size = new Vector2(width, height); - } - - protected override void Update() - { - base.Update(); - Content.CornerRadius = DrawHeight / 2f; - Content.CornerExponent = 2; - } - } -} diff --git a/osu.Game/Screens/Edit/Components/EditorSidebar.cs b/osu.Game/Screens/Edit/Components/EditorSidebar.cs index 4e9b1d5222..24e21ceafe 100644 --- a/osu.Game/Screens/Edit/Components/EditorSidebar.cs +++ b/osu.Game/Screens/Edit/Components/EditorSidebar.cs @@ -14,10 +14,12 @@ namespace osu.Game.Screens.Edit.Components /// A sidebar area that can be attached to the left or right edge of the screen. /// Houses scrolling sectionised content. /// - internal class EditorSidebar : Container + internal partial class EditorSidebar : Container { public const float WIDTH = 250; + public const float PADDING = 3; + private readonly Box background; protected override Container Content { get; } @@ -35,13 +37,13 @@ namespace osu.Game.Screens.Edit.Components }, new OsuScrollContainer { - Padding = new MarginPadding { Left = 20 }, ScrollbarOverlapsContent = false, RelativeSizeAxes = Axes.Both, Child = Content = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(PADDING), Direction = FillDirection.Vertical, }, } diff --git a/osu.Game/Screens/Edit/Components/EditorSidebarSection.cs b/osu.Game/Screens/Edit/Components/EditorSidebarSection.cs index 3871720562..279793c0a1 100644 --- a/osu.Game/Screens/Edit/Components/EditorSidebarSection.cs +++ b/osu.Game/Screens/Edit/Components/EditorSidebarSection.cs @@ -13,7 +13,7 @@ using osuTK; namespace osu.Game.Screens.Edit.Components { - public class EditorSidebarSection : Container + public partial class EditorSidebarSection : Container { protected override Container Content { get; } @@ -39,7 +39,7 @@ namespace osu.Game.Screens.Edit.Components }; } - public class SectionHeader : CompositeDrawable + public partial class SectionHeader : CompositeDrawable { private readonly LocalisableString text; diff --git a/osu.Game/Screens/Edit/Components/Menus/DifficultyMenuItem.cs b/osu.Game/Screens/Edit/Components/Menus/DifficultyMenuItem.cs index f17fe4c3ce..6af3ba908d 100644 --- a/osu.Game/Screens/Edit/Components/Menus/DifficultyMenuItem.cs +++ b/osu.Game/Screens/Edit/Components/Menus/DifficultyMenuItem.cs @@ -22,6 +22,6 @@ namespace osu.Game.Screens.Edit.Components.Menus Action.Value = () => difficultyChangeFunc.Invoke(beatmapInfo); } - public override IconUsage? GetIconForState(bool state) => state ? (IconUsage?)FontAwesome.Solid.Check : null; + public override IconUsage? GetIconForState(bool state) => state ? FontAwesome.Solid.Check : null; } } diff --git a/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs b/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs index 20b8bba6da..a911b4e1d8 100644 --- a/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs +++ b/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs @@ -12,7 +12,7 @@ using osuTK; namespace osu.Game.Screens.Edit.Components.Menus { - public class EditorMenuBar : OsuMenu + public partial class EditorMenuBar : OsuMenu { public EditorMenuBar() : base(Direction.Horizontal, true) @@ -33,7 +33,7 @@ namespace osu.Game.Screens.Edit.Components.Menus protected override DrawableMenuItem CreateDrawableMenuItem(MenuItem item) => new DrawableEditorBarMenuItem(item); - private class DrawableEditorBarMenuItem : DrawableOsuMenuItem + private partial class DrawableEditorBarMenuItem : DrawableOsuMenuItem { public DrawableEditorBarMenuItem(MenuItem item) : base(item) @@ -75,7 +75,7 @@ namespace osu.Game.Screens.Edit.Components.Menus protected override DrawableOsuMenuItem.TextContainer CreateTextContainer() => new TextContainer(); - private new class TextContainer : DrawableOsuMenuItem.TextContainer + private new partial class TextContainer : DrawableOsuMenuItem.TextContainer { public TextContainer() { @@ -85,7 +85,7 @@ namespace osu.Game.Screens.Edit.Components.Menus } } - private class SubMenu : OsuMenu + private partial class SubMenu : OsuMenu { public SubMenu() : base(Direction.Vertical) @@ -118,7 +118,7 @@ namespace osu.Game.Screens.Edit.Components.Menus } } - private class EditorStatefulMenuItem : DrawableStatefulMenuItem + private partial class EditorStatefulMenuItem : DrawableStatefulMenuItem { public EditorStatefulMenuItem(StatefulMenuItem item) : base(item) @@ -135,7 +135,7 @@ namespace osu.Game.Screens.Edit.Components.Menus } } - private class EditorMenuItem : DrawableOsuMenuItem + private partial class EditorMenuItem : DrawableOsuMenuItem { public EditorMenuItem(MenuItem item) : base(item) @@ -152,7 +152,7 @@ namespace osu.Game.Screens.Edit.Components.Menus } } - private class DrawableSpacer : DrawableOsuMenuItem + private partial class DrawableSpacer : DrawableOsuMenuItem { public DrawableSpacer(MenuItem item) : base(item) diff --git a/osu.Game/Screens/Edit/Components/Menus/EditorMenuItem.cs b/osu.Game/Screens/Edit/Components/Menus/EditorMenuItem.cs index 0a2c073dcd..368fe40977 100644 --- a/osu.Game/Screens/Edit/Components/Menus/EditorMenuItem.cs +++ b/osu.Game/Screens/Edit/Components/Menus/EditorMenuItem.cs @@ -2,21 +2,20 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; namespace osu.Game.Screens.Edit.Components.Menus { public class EditorMenuItem : OsuMenuItem { - private const int min_text_length = 40; - - public EditorMenuItem(string text, MenuItemType type = MenuItemType.Standard) - : base(text.PadRight(min_text_length), type) + public EditorMenuItem(LocalisableString text, MenuItemType type = MenuItemType.Standard) + : base(text, type) { } - public EditorMenuItem(string text, MenuItemType type, Action action) - : base(text.PadRight(min_text_length), type, action) + public EditorMenuItem(LocalisableString text, MenuItemType type, Action action) + : base(text, type, action) { } } diff --git a/osu.Game/Screens/Edit/Components/Menus/EditorScreenSwitcherControl.cs b/osu.Game/Screens/Edit/Components/Menus/EditorScreenSwitcherControl.cs index 8b868a4649..1f6d61d0ad 100644 --- a/osu.Game/Screens/Edit/Components/Menus/EditorScreenSwitcherControl.cs +++ b/osu.Game/Screens/Edit/Components/Menus/EditorScreenSwitcherControl.cs @@ -12,7 +12,7 @@ using osuTK; namespace osu.Game.Screens.Edit.Components.Menus { - public class EditorScreenSwitcherControl : OsuTabControl + public partial class EditorScreenSwitcherControl : OsuTabControl { public EditorScreenSwitcherControl() { @@ -36,11 +36,11 @@ namespace osu.Game.Screens.Edit.Components.Menus }); } - protected override Dropdown CreateDropdown() => null; + protected override Dropdown CreateDropdown() => null!; protected override TabItem CreateTabItem(EditorScreenMode value) => new TabItem(value); - private class TabItem : OsuTabItem + private partial class TabItem : OsuTabItem { private const float transition_length = 250; @@ -56,11 +56,6 @@ namespace osu.Game.Screens.Edit.Components.Menus Bar.Expire(); } - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) - { - } - protected override void OnActivated() { base.OnActivated(); diff --git a/osu.Game/Screens/Edit/Components/PlaybackControl.cs b/osu.Game/Screens/Edit/Components/PlaybackControl.cs index d1a999c2d1..72c299f443 100644 --- a/osu.Game/Screens/Edit/Components/PlaybackControl.cs +++ b/osu.Game/Screens/Edit/Components/PlaybackControl.cs @@ -16,17 +16,18 @@ using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Overlays; using osuTK.Input; namespace osu.Game.Screens.Edit.Components { - public class PlaybackControl : BottomBarContainer + public partial class PlaybackControl : BottomBarContainer { - private IconButton playButton; + private IconButton playButton = null!; [Resolved] - private EditorClock editorClock { get; set; } + private EditorClock editorClock { get; set; } = null!; private readonly BindableNumber freqAdjust = new BindableDouble(1); @@ -47,7 +48,7 @@ namespace osu.Game.Screens.Edit.Components new OsuSpriteText { Origin = Anchor.BottomLeft, - Text = "Playback speed", + Text = EditorStrings.PlaybackSpeed, RelativePositionAxes = Axes.Y, Y = 0.5f, Padding = new MarginPadding { Left = 45 } @@ -100,13 +101,13 @@ namespace osu.Game.Screens.Edit.Components playButton.Icon = editorClock.IsRunning ? FontAwesome.Regular.PauseCircle : FontAwesome.Regular.PlayCircle; } - private class PlaybackTabControl : OsuTabControl + private partial class PlaybackTabControl : OsuTabControl { private static readonly double[] tempo_values = { 0.25, 0.5, 0.75, 1 }; protected override TabItem CreateTabItem(double value) => new PlaybackTabItem(value); - protected override Dropdown CreateDropdown() => null; + protected override Dropdown CreateDropdown() => null!; public PlaybackTabControl() { @@ -118,7 +119,7 @@ namespace osu.Game.Screens.Edit.Components Current.Value = tempo_values.Last(); } - public class PlaybackTabItem : TabItem + public partial class PlaybackTabItem : TabItem { private const float fade_duration = 200; diff --git a/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButton.cs b/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButton.cs index d66856ebd8..65f3e41c13 100644 --- a/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButton.cs +++ b/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButton.cs @@ -6,36 +6,35 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; -using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; using osuTK; using osuTK.Graphics; namespace osu.Game.Screens.Edit.Components.RadioButtons { - public class EditorRadioButton : OsuButton, IHasTooltip + public partial class EditorRadioButton : OsuButton, IHasTooltip { /// /// Invoked when this has been selected. /// - public Action Selected; + public Action? Selected; public readonly RadioButton Button; private Color4 defaultBackgroundColour; - private Color4 defaultBubbleColour; + private Color4 defaultIconColour; private Color4 selectedBackgroundColour; - private Color4 selectedBubbleColour; + private Color4 selectedIconColour; - private Drawable icon; + private Drawable icon = null!; - [Resolved(canBeNull: true)] - private EditorBeatmap editorBeatmap { get; set; } + [Resolved] + private EditorBeatmap? editorBeatmap { get; set; } public EditorRadioButton(RadioButton button) { @@ -48,20 +47,13 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - defaultBackgroundColour = colours.Gray3; - defaultBubbleColour = defaultBackgroundColour.Darken(0.5f); - selectedBackgroundColour = colours.BlueDark; - selectedBubbleColour = selectedBackgroundColour.Lighten(0.5f); + defaultBackgroundColour = colourProvider.Background3; + selectedBackgroundColour = colourProvider.Background1; - Content.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Radius = 2, - Offset = new Vector2(0, 1), - Colour = Color4.Black.Opacity(0.5f) - }; + defaultIconColour = defaultBackgroundColour.Darken(0.5f); + selectedIconColour = selectedBackgroundColour.Lighten(0.5f); Add(icon = (Button.CreateIcon?.Invoke() ?? new Circle()).With(b => { @@ -96,7 +88,7 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons return; BackgroundColour = Button.Selected.Value ? selectedBackgroundColour : defaultBackgroundColour; - icon.Colour = Button.Selected.Value ? selectedBubbleColour : defaultBubbleColour; + icon.Colour = Button.Selected.Value ? selectedIconColour : defaultIconColour; } protected override SpriteText CreateText() => new OsuSpriteText diff --git a/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButtonCollection.cs b/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButtonCollection.cs index 6a7b0c9ef7..4391729adc 100644 --- a/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButtonCollection.cs +++ b/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButtonCollection.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; @@ -9,9 +10,9 @@ using osuTK; namespace osu.Game.Screens.Edit.Components.RadioButtons { - public class EditorRadioButtonCollection : CompositeDrawable + public partial class EditorRadioButtonCollection : CompositeDrawable { - private IReadOnlyList items; + private IReadOnlyList items = Array.Empty(); public IReadOnlyList Items { @@ -43,7 +44,7 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons }; } - private RadioButton currentlySelected; + private RadioButton? currentlySelected; private void addButton(RadioButton button) { diff --git a/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs b/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs index ca79dd15d7..9dcd29bf83 100644 --- a/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs +++ b/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs @@ -22,11 +22,11 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons /// /// A function which creates a drawable icon to represent this item. If null, a sane default should be used. /// - public readonly Func CreateIcon; + public readonly Func? CreateIcon; - private readonly Action action; + private readonly Action? action; - public RadioButton(string label, Action action, Func createIcon = null) + public RadioButton(string label, Action? action, Func? createIcon = null) { Label = label; CreateIcon = createIcon; diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs index c43561eaa7..873551db77 100644 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs @@ -4,25 +4,24 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; using osuTK; using osuTK.Graphics; namespace osu.Game.Screens.Edit.Components.TernaryButtons { - internal class DrawableTernaryButton : OsuButton + internal partial class DrawableTernaryButton : OsuButton { private Color4 defaultBackgroundColour; - private Color4 defaultBubbleColour; + private Color4 defaultIconColour; private Color4 selectedBackgroundColour; - private Color4 selectedBubbleColour; + private Color4 selectedIconColour; - private Drawable icon; + private Drawable icon = null!; public readonly TernaryButton Button; @@ -36,20 +35,13 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - defaultBackgroundColour = colours.Gray3; - defaultBubbleColour = defaultBackgroundColour.Darken(0.5f); - selectedBackgroundColour = colours.BlueDark; - selectedBubbleColour = selectedBackgroundColour.Lighten(0.5f); + defaultBackgroundColour = colourProvider.Background3; + selectedBackgroundColour = colourProvider.Background1; - Content.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Radius = 2, - Offset = new Vector2(0, 1), - Colour = Color4.Black.Opacity(0.5f) - }; + defaultIconColour = defaultBackgroundColour.Darken(0.5f); + selectedIconColour = selectedBackgroundColour.Lighten(0.5f); Add(icon = (Button.CreateIcon?.Invoke() ?? new Circle()).With(b => { @@ -65,7 +57,7 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons { base.LoadComplete(); - Button.Bindable.BindValueChanged(selected => updateSelectionState(), true); + Button.Bindable.BindValueChanged(_ => updateSelectionState(), true); Action = onAction; } @@ -83,17 +75,17 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons switch (Button.Bindable.Value) { case TernaryState.Indeterminate: - icon.Colour = selectedBubbleColour.Darken(0.5f); + icon.Colour = selectedIconColour.Darken(0.5f); BackgroundColour = selectedBackgroundColour.Darken(0.5f); break; case TernaryState.False: - icon.Colour = defaultBubbleColour; + icon.Colour = defaultIconColour; BackgroundColour = defaultBackgroundColour; break; case TernaryState.True: - icon.Colour = selectedBubbleColour; + icon.Colour = selectedIconColour; BackgroundColour = selectedBackgroundColour; break; } diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.cs index 7f64695bde..0ff2aa83b5 100644 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.cs +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.cs @@ -17,9 +17,9 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons /// /// A function which creates a drawable icon to represent this item. If null, a sane default should be used. /// - public readonly Func CreateIcon; + public readonly Func? CreateIcon; - public TernaryButton(Bindable bindable, string description, Func createIcon = null) + public TernaryButton(Bindable bindable, string description, Func? createIcon = null) { Bindable = bindable; Description = description; diff --git a/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs b/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs index bd5377e578..9c51258f17 100644 --- a/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs +++ b/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs @@ -11,16 +11,16 @@ using osuTK; namespace osu.Game.Screens.Edit.Components { - public class TimeInfoContainer : BottomBarContainer + public partial class TimeInfoContainer : BottomBarContainer { - private OsuSpriteText trackTimer; - private OsuSpriteText bpm; + private OsuSpriteText trackTimer = null!; + private OsuSpriteText bpm = null!; [Resolved] - private EditorBeatmap editorBeatmap { get; set; } + private EditorBeatmap editorBeatmap { get; set; } = null!; [Resolved] - private EditorClock editorClock { get; set; } + private EditorClock editorClock { get; set; } = null!; [BackgroundDependencyLoader] private void load(OsuColour colours, OverlayColourProvider colourProvider) diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BookmarkPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BookmarkPart.cs index 8298cf4773..3102bf7c06 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BookmarkPart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BookmarkPart.cs @@ -10,7 +10,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts /// /// The part of the timeline that displays bookmarks. /// - public class BookmarkPart : TimelinePart + public partial class BookmarkPart : TimelinePart { protected override void LoadBeatmap(EditorBeatmap beatmap) { @@ -19,7 +19,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts Add(new BookmarkVisualisation(bookmark)); } - private class BookmarkVisualisation : PointVisualisation + private partial class BookmarkVisualisation : PointVisualisation { public BookmarkVisualisation(double startTime) : base(startTime) diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BreakPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BreakPart.cs index 3d535ec915..e502dd951b 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BreakPart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BreakPart.cs @@ -11,7 +11,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts /// /// The part of the timeline that displays breaks in the song. /// - public class BreakPart : TimelinePart + public partial class BreakPart : TimelinePart { protected override void LoadBeatmap(EditorBeatmap beatmap) { @@ -20,7 +20,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts Add(new BreakVisualisation(breakPeriod)); } - private class BreakVisualisation : DurationVisualisation + private partial class BreakVisualisation : DurationVisualisation { public BreakVisualisation(BreakPeriod breakPeriod) : base(breakPeriod.StartTime, breakPeriod.EndTime) diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointPart.cs index f1edb7dc7e..6f53f710ba 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointPart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointPart.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Specialized; +using System.Diagnostics; using System.Linq; using osu.Framework.Bindables; using osu.Game.Beatmaps.ControlPoints; @@ -12,7 +13,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts /// /// The part of the timeline that displays the control points. /// - public class ControlPointPart : TimelinePart + public partial class ControlPointPart : TimelinePart { private readonly IBindableList controlPointGroups = new BindableList(); @@ -22,7 +23,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts controlPointGroups.UnbindAll(); controlPointGroups.BindTo(beatmap.ControlPointInfo.Groups); - controlPointGroups.BindCollectionChanged((sender, args) => + controlPointGroups.BindCollectionChanged((_, args) => { switch (args.Action) { @@ -31,6 +32,8 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts break; case NotifyCollectionChangedAction.Add: + Debug.Assert(args.NewItems != null); + foreach (var group in args.NewItems.OfType()) { // as an optimisation, don't add a visualisation if there are already groups with the same types in close proximity. @@ -45,9 +48,11 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts break; case NotifyCollectionChangedAction.Remove: + Debug.Assert(args.OldItems != null); + foreach (var group in args.OldItems.OfType()) { - var matching = Children.SingleOrDefault(gv => gv.Group == group); + var matching = Children.SingleOrDefault(gv => ReferenceEquals(gv.Group, group)); if (matching != null) matching.Expire(); diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointVisualisation.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointVisualisation.cs index 41716f9c23..12620963e1 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointVisualisation.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointVisualisation.cs @@ -9,7 +9,7 @@ using osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations; namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts { - public class ControlPointVisualisation : PointVisualisation, IControlPointVisualisation + public partial class ControlPointVisualisation : PointVisualisation, IControlPointVisualisation { protected readonly ControlPoint Point; diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/EffectPointVisualisation.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/EffectPointVisualisation.cs index 7c14152b3d..d92beba38a 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/EffectPointVisualisation.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/EffectPointVisualisation.cs @@ -13,16 +13,16 @@ using osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations; namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts { - public class EffectPointVisualisation : CompositeDrawable, IControlPointVisualisation + public partial class EffectPointVisualisation : CompositeDrawable, IControlPointVisualisation { private readonly EffectControlPoint effect; - private Bindable kiai; + private Bindable kiai = null!; [Resolved] - private EditorBeatmap beatmap { get; set; } + private EditorBeatmap beatmap { get; set; } = null!; [Resolved] - private OsuColour colours { get; set; } + private OsuColour colours { get; set; } = null!; public EffectPointVisualisation(EffectControlPoint point) { @@ -36,37 +36,61 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts private void load() { kiai = effect.KiaiModeBindable.GetBoundCopy(); - kiai.BindValueChanged(_ => + kiai.BindValueChanged(_ => refreshDisplay(), true); + } + + private EffectControlPoint? nextControlPoint; + + protected override void LoadComplete() + { + base.LoadComplete(); + + // Due to the limitations of ControlPointInfo, it's impossible to know via event flow when the next kiai point has changed. + // This is due to the fact that an EffectPoint can be added to an existing group. We would need to bind to ItemAdded on *every* + // future group to track this. + // + // I foresee this being a potential performance issue on beatmaps with many control points, so let's limit how often we check + // for changes. ControlPointInfo needs a refactor to make this flow better, but it should do for now. + Scheduler.AddDelayed(() => { - ClearInternal(); + var next = beatmap.ControlPointInfo.EffectPoints.FirstOrDefault(c => c.Time > effect.Time); - AddInternal(new ControlPointVisualisation(effect)); - - if (!kiai.Value) - return; - - var endControlPoint = beatmap.ControlPointInfo.EffectPoints.FirstOrDefault(c => c.Time > effect.Time && !c.KiaiMode); - - // handle kiai duration - // eventually this will be simpler when we have control points with durations. - if (endControlPoint != null) + if (!ReferenceEquals(nextControlPoint, next)) { - RelativeSizeAxes = Axes.Both; - Origin = Anchor.TopLeft; - - Width = (float)(endControlPoint.Time - effect.Time); - - AddInternal(new PointVisualisation - { - RelativeSizeAxes = Axes.Both, - Origin = Anchor.TopLeft, - Width = 1, - Height = 0.25f, - Depth = float.MaxValue, - Colour = effect.GetRepresentingColour(colours).Darken(0.5f), - }); + nextControlPoint = next; + refreshDisplay(); } - }, true); + }, 100, true); + } + + private void refreshDisplay() + { + ClearInternal(); + + AddInternal(new ControlPointVisualisation(effect)); + + if (!kiai.Value) + return; + + // handle kiai duration + // eventually this will be simpler when we have control points with durations. + if (nextControlPoint != null) + { + RelativeSizeAxes = Axes.Both; + Origin = Anchor.TopLeft; + + Width = (float)(nextControlPoint.Time - effect.Time); + + AddInternal(new PointVisualisation + { + RelativeSizeAxes = Axes.Both, + Origin = Anchor.TopLeft, + Width = 1, + Height = 0.25f, + Depth = float.MaxValue, + Colour = effect.GetRepresentingColour(colours).Darken(0.5f), + }); + } } // kiai sections display duration, so are required to be visualised. diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/GroupVisualisation.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/GroupVisualisation.cs index 88587399f2..b39365277f 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/GroupVisualisation.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/GroupVisualisation.cs @@ -9,7 +9,7 @@ using osu.Game.Beatmaps.ControlPoints; namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts { - public class GroupVisualisation : CompositeDrawable + public partial class GroupVisualisation : CompositeDrawable { public readonly ControlPointGroup Group; @@ -27,7 +27,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts // Run in constructor so IsRedundant calls can work correctly. controlPoints.BindTo(Group.ControlPoints); - controlPoints.BindCollectionChanged((_, __) => + controlPoints.BindCollectionChanged((_, _) => { ClearInternal(); @@ -38,15 +38,15 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts { switch (point) { - case TimingControlPoint _: + case TimingControlPoint: AddInternal(new ControlPointVisualisation(point) { Y = 0, }); break; - case DifficultyControlPoint _: + case DifficultyControlPoint: AddInternal(new ControlPointVisualisation(point) { Y = 0.25f, }); break; - case SampleControlPoint _: + case SampleControlPoint: AddInternal(new ControlPointVisualisation(point) { Y = 0.5f, }); break; diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs index d551333616..d42c02e03d 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs @@ -16,12 +16,12 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts /// /// The part of the timeline that displays the current position of the song. /// - public class MarkerPart : TimelinePart + public partial class MarkerPart : TimelinePart { - private Drawable marker; + private Drawable marker = null!; [Resolved] - private EditorClock editorClock { get; set; } + private EditorClock editorClock { get; set; } = null!; [BackgroundDependencyLoader] private void load() @@ -42,7 +42,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts return true; } - private ScheduledDelegate scheduledSeek; + private ScheduledDelegate? scheduledSeek; /// /// Seeks the to the time closest to a position on the screen relative to the . @@ -69,7 +69,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts // block base call so we don't clear our marker (can be reused on beatmap change). } - private class MarkerVisualisation : CompositeDrawable + private partial class MarkerVisualisation : CompositeDrawable { public MarkerVisualisation() { diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/PreviewTimePart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/PreviewTimePart.cs new file mode 100644 index 0000000000..c63bb7ac24 --- /dev/null +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/PreviewTimePart.cs @@ -0,0 +1,41 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Graphics; +using osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations; + +namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts +{ + public partial class PreviewTimePart : TimelinePart + { + private readonly BindableInt previewTime = new BindableInt(); + + protected override void LoadBeatmap(EditorBeatmap beatmap) + { + base.LoadBeatmap(beatmap); + + previewTime.UnbindAll(); + previewTime.BindTo(beatmap.PreviewTime); + previewTime.BindValueChanged(t => + { + Clear(); + + if (t.NewValue >= 0) + Add(new PreviewTimeVisualisation(t.NewValue)); + }, true); + } + + private partial class PreviewTimeVisualisation : PointVisualisation + { + public PreviewTimeVisualisation(double time) + : base(time) + { + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) => Colour = colours.Green1; + } + } +} diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs index 5aba81aa7d..ee7e759ebc 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs @@ -12,19 +12,19 @@ using osu.Game.Beatmaps; namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts { - public class TimelinePart : TimelinePart + public partial class TimelinePart : TimelinePart { } /// /// Represents a part of the summary timeline.. /// - public class TimelinePart : Container where T : Drawable + public partial class TimelinePart : Container where T : Drawable { private readonly IBindable beatmap = new Bindable(); [Resolved] - protected EditorBeatmap EditorBeatmap { get; private set; } + protected EditorBeatmap EditorBeatmap { get; private set; } = null!; protected readonly IBindable Track = new Bindable(); @@ -32,11 +32,11 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts protected override Container Content => content; - public TimelinePart(Container content = null) + public TimelinePart(Container? content = null) { AddInternal(this.content = content ?? new Container { RelativeSizeAxes = Axes.Both }); - beatmap.ValueChanged += b => + beatmap.ValueChanged += _ => { updateRelativeChildSize(); }; @@ -55,15 +55,13 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts private void updateRelativeChildSize() { - // the track may not be loaded completely (only has a length once it is). - if (!beatmap.Value.Track.IsLoaded) - { - content.RelativeChildSize = Vector2.One; - Schedule(updateRelativeChildSize); - return; - } + // If the track is not loaded, assign a default sane length otherwise relative positioning becomes meaningless. + double trackLength = beatmap.Value.Track.IsLoaded ? beatmap.Value.Track.Length : 60000; + content.RelativeChildSize = new Vector2((float)Math.Max(1, trackLength), 1); - content.RelativeChildSize = new Vector2((float)Math.Max(1, beatmap.Value.Track.Length), 1); + // The track may not be loaded completely (only has a length once it is). + if (!beatmap.Value.Track.IsLoaded) + Schedule(updateRelativeChildSize); } protected virtual void LoadBeatmap(EditorBeatmap beatmap) diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs index 1706c47c96..6199cefb57 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs @@ -14,7 +14,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary /// /// The timeline that sits at the bottom of the editor. /// - public class SummaryTimeline : BottomBarContainer + public partial class SummaryTimeline : BottomBarContainer { [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) @@ -39,6 +39,13 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary RelativeSizeAxes = Axes.Both, Height = 0.35f }, + new PreviewTimePart + { + Anchor = Anchor.Centre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Both, + Height = 0.35f + }, new Container { Name = "centre line", diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/TestGameplayButton.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/TestGameplayButton.cs index 99cdc014aa..169e72fe3f 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/TestGameplayButton.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/TestGameplayButton.cs @@ -7,11 +7,12 @@ using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Overlays; namespace osu.Game.Screens.Edit.Components.Timelines.Summary { - public class TestGameplayButton : OsuButton + public partial class TestGameplayButton : OsuButton { protected override SpriteText CreateText() => new OsuSpriteText { @@ -30,7 +31,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary Content.CornerRadius = 0; - Text = "Test!"; + Text = EditorStrings.TestBeatmap; } } } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/DurationVisualisation.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/DurationVisualisation.cs index ec68bf9c00..bfb50a05ea 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/DurationVisualisation.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/DurationVisualisation.cs @@ -9,7 +9,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations /// /// Represents a spanning point on a timeline part. /// - public class DurationVisualisation : Circle + public partial class DurationVisualisation : Circle { protected DurationVisualisation(double startTime, double endTime) { diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/PointVisualisation.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/PointVisualisation.cs index a4b6b0c392..3f0c125ada 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/PointVisualisation.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/PointVisualisation.cs @@ -9,7 +9,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations /// /// Represents a singular point on a timeline part. /// - public class PointVisualisation : Circle + public partial class PointVisualisation : Circle { public const float MAX_WIDTH = 4; diff --git a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs index bf4d70baa6..9f422d5aa9 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using System.Linq; @@ -27,7 +29,7 @@ using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components { - public class BeatDivisorControl : CompositeDrawable + public partial class BeatDivisorControl : CompositeDrawable { private readonly BindableBeatDivisor beatDivisor = new BindableBeatDivisor(); @@ -121,16 +123,6 @@ namespace osu.Game.Screens.Edit.Compose.Components } }, new Drawable[] - { - new TextFlowContainer(s => s.Font = s.Font.With(size: 14)) - { - Padding = new MarginPadding { Horizontal = 15 }, - Text = "beat snap", - RelativeSizeAxes = Axes.X, - TextAnchor = Anchor.TopCentre - }, - }, - new Drawable[] { new Container { @@ -171,6 +163,16 @@ namespace osu.Game.Screens.Edit.Compose.Components } } }, + new Drawable[] + { + new TextFlowContainer(s => s.Font = s.Font.With(size: 14)) + { + Padding = new MarginPadding { Horizontal = 15, Vertical = 8 }, + Text = "beat snap", + RelativeSizeAxes = Axes.X, + TextAnchor = Anchor.TopCentre, + }, + }, }, RowDimensions = new[] { @@ -207,7 +209,18 @@ namespace osu.Game.Screens.Edit.Compose.Components } } - internal class DivisorDisplay : OsuAnimatedButton, IHasPopover + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.ShiftPressed && e.Key >= Key.Number1 && e.Key <= Key.Number9) + { + beatDivisor.SetArbitraryDivisor(e.Key - Key.Number0); + return true; + } + + return base.OnKeyDown(e); + } + + internal partial class DivisorDisplay : OsuAnimatedButton, IHasPopover { public BindableBeatDivisor BeatDivisor { get; } = new BindableBeatDivisor(); @@ -257,7 +270,7 @@ namespace osu.Game.Screens.Edit.Compose.Components }; } - internal class CustomDivisorPopover : OsuPopover + internal partial class CustomDivisorPopover : OsuPopover { public BindableBeatDivisor BeatDivisor { get; } = new BindableBeatDivisor(); @@ -291,7 +304,7 @@ namespace osu.Game.Screens.Edit.Compose.Components { base.LoadComplete(); BeatDivisor.BindValueChanged(_ => updateState(), true); - divisorTextBox.OnCommit += (_, __) => setPresets(); + divisorTextBox.OnCommit += (_, _) => setPresets(); Schedule(() => GetContainingInputManager().ChangeFocus(divisorTextBox)); } @@ -304,17 +317,7 @@ namespace osu.Game.Screens.Edit.Compose.Components return; } - if (!BeatDivisor.ValidDivisors.Value.Presets.Contains(divisor)) - { - if (BeatDivisorPresetCollection.COMMON.Presets.Contains(divisor)) - BeatDivisor.ValidDivisors.Value = BeatDivisorPresetCollection.COMMON; - else if (BeatDivisorPresetCollection.TRIPLETS.Presets.Contains(divisor)) - BeatDivisor.ValidDivisors.Value = BeatDivisorPresetCollection.TRIPLETS; - else - BeatDivisor.ValidDivisors.Value = BeatDivisorPresetCollection.Custom(divisor); - } - - BeatDivisor.Value = divisor; + BeatDivisor.SetArbitraryDivisor(divisor); this.HidePopover(); } @@ -325,7 +328,7 @@ namespace osu.Game.Screens.Edit.Compose.Components } } - private class DivisorTypeText : OsuSpriteText + private partial class DivisorTypeText : OsuSpriteText { public BindableBeatDivisor BeatDivisor { get; } = new BindableBeatDivisor(); @@ -344,7 +347,7 @@ namespace osu.Game.Screens.Edit.Compose.Components } } - internal class ChevronButton : IconButton + internal partial class ChevronButton : IconButton { public ChevronButton() { @@ -367,7 +370,7 @@ namespace osu.Game.Screens.Edit.Compose.Components } } - private class TickSliderBar : SliderBar + private partial class TickSliderBar : SliderBar { private Marker marker; @@ -476,13 +479,13 @@ namespace osu.Game.Screens.Edit.Compose.Components // copied from SliderBar so we can do custom spacing logic. float xPosition = (ToLocalSpace(screenSpaceMousePosition).X - RangePadding) / UsableWidth; - CurrentNumber.Value = beatDivisor.ValidDivisors.Value.Presets.OrderBy(d => Math.Abs(getMappedPosition(d) - xPosition)).First(); + CurrentNumber.Value = beatDivisor.ValidDivisors.Value.Presets.MinBy(d => Math.Abs(getMappedPosition(d) - xPosition)); OnUserChange(Current.Value); } private float getMappedPosition(float divisor) => MathF.Pow((divisor - 1) / (beatDivisor.ValidDivisors.Value.Presets.Last() - 1), 0.90f); - private class Tick : Circle + private partial class Tick : Circle { public Tick(int divisor) { @@ -491,7 +494,7 @@ namespace osu.Game.Screens.Edit.Compose.Components } } - private class Marker : CompositeDrawable + private partial class Marker : CompositeDrawable { [Resolved] private OverlayColourProvider colourProvider { get; set; } diff --git a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorPresetCollection.cs b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorPresetCollection.cs index 4616669c6d..67b346fb64 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorPresetCollection.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorPresetCollection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; diff --git a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorType.cs b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorType.cs index 4a25144881..ebdb030e76 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorType.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorType.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Screens.Edit.Compose.Components { public enum BeatDivisorType diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index d56dc176f6..cb7c083d87 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -1,7 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; +#nullable disable + using System.Collections.Generic; using System.Collections.Specialized; using System.Diagnostics; @@ -11,7 +12,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Primitives; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; @@ -26,7 +26,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 class BlueprintContainer : CompositeDrawable, IKeyBindingHandler + public abstract partial class BlueprintContainer : CompositeDrawable, IKeyBindingHandler where T : class { protected DragBox DragBox { get; private set; } @@ -45,6 +45,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; @@ -53,31 +62,41 @@ namespace osu.Game.Screens.Edit.Compose.Components [BackgroundDependencyLoader] private void load() { - SelectedItems.CollectionChanged += (selectedObjects, args) => + SelectedItems.CollectionChanged += (_, args) => { switch (args.Action) { case NotifyCollectionChangedAction.Add: + Debug.Assert(args.NewItems != null); + foreach (object o in args.NewItems) - SelectionBlueprints.FirstOrDefault(b => b.Item == o)?.Select(); + { + if (blueprintMap.TryGetValue((T)o, out var blueprint)) + blueprint.Select(); + } break; case NotifyCollectionChangedAction.Remove: + Debug.Assert(args.OldItems != null); + foreach (object o in args.OldItems) - SelectionBlueprints.FirstOrDefault(b => b.Item == o)?.Deselect(); + { + if (blueprintMap.TryGetValue((T)o, out var blueprint)) + blueprint.Deselect(); + } break; } }; SelectionHandler = CreateSelectionHandler(); - SelectionHandler.DeselectAll = deselectAll; + SelectionHandler.DeselectAll = DeselectAll; SelectionHandler.SelectedItems.BindTo(SelectedItems); AddRangeInternal(new[] { - DragBox = CreateDragBox(selectBlueprintsFromDragRectangle), + DragBox = CreateDragBox(), SelectionHandler, SelectionBlueprints = CreateSelectionBlueprintContainer(), SelectionHandler.CreateProxy(), @@ -99,12 +118,7 @@ namespace osu.Game.Screens.Edit.Compose.Components [CanBeNull] protected virtual SelectionBlueprint CreateBlueprintFor(T item) => null; - protected virtual DragBox CreateDragBox(Action performSelect) => new DragBox(performSelect); - - /// - /// Whether this component is in a state where items outside a drag selection should be deselected. If false, selection will only be added to. - /// - protected virtual bool AllowDeselectionDuringDrag => true; + protected virtual DragBox CreateDragBox() => new DragBox(); protected override bool OnMouseDown(MouseDownEvent e) { @@ -140,7 +154,7 @@ namespace osu.Game.Screens.Edit.Compose.Components if (endClickSelection(e) || ClickedBlueprint != null) return true; - deselectAll(); + DeselectAll(); return true; } @@ -162,18 +176,24 @@ namespace osu.Game.Screens.Edit.Compose.Components Schedule(() => { endClickSelection(e); - clickSelectionBegan = false; + clickSelectionHandled = false; isDraggingBlueprint = false; + wasDragStarted = false; }); finishSelectionMovement(); } + private MouseButtonEvent lastDragEvent; + protected override bool OnDragStart(DragStartEvent e) { if (e.Button == MouseButton.Right) return false; + lastDragEvent = e; + wasDragStarted = true; + if (movementBlueprints != null) { isDraggingBlueprint = true; @@ -181,30 +201,21 @@ namespace osu.Game.Screens.Edit.Compose.Components return true; } - if (DragBox.HandleDrag(e)) - { - DragBox.Show(); - return true; - } - - return false; + DragBox.HandleDrag(e); + DragBox.Show(); + return true; } protected override void OnDrag(DragEvent e) { - if (e.Button == MouseButton.Right) - return; - - if (DragBox.State == Visibility.Visible) - DragBox.HandleDrag(e); + lastDragEvent = e; moveCurrentSelection(e); } protected override void OnDragEnd(DragEndEvent e) { - if (e.Button == MouseButton.Right) - return; + lastDragEvent = null; if (isDraggingBlueprint) { @@ -212,8 +223,19 @@ namespace osu.Game.Screens.Edit.Compose.Components changeHandler?.EndChange(); } - if (DragBox.State == Visibility.Visible) - DragBox.Hide(); + DragBox.Hide(); + } + + protected override void Update() + { + base.Update(); + + if (lastDragEvent != null && DragBox.State == Visibility.Visible) + { + lastDragEvent.Target = this; + DragBox.HandleDrag(lastDragEvent); + UpdateSelectionFromDragBox(); + } } /// @@ -231,7 +253,7 @@ namespace osu.Game.Screens.Edit.Compose.Components if (!SelectionHandler.SelectedBlueprints.Any()) return false; - deselectAll(); + DeselectAll(); return true; } @@ -290,7 +312,7 @@ namespace osu.Game.Screens.Edit.Compose.Components blueprint.Selected -= OnBlueprintSelected; blueprint.Deselected -= OnBlueprintDeselected; - SelectionBlueprints.Remove(blueprint); + SelectionBlueprints.Remove(blueprint, true); if (movementBlueprints?.Contains(blueprint) == true) finishSelectionMovement(); @@ -328,7 +350,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. @@ -343,7 +370,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; @@ -356,66 +384,84 @@ 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; } /// - /// Select all masks in a given rectangle selection area. + /// Select all blueprints in a selection area specified by . /// - /// The rectangle to perform a selection on in screen-space coordinates. - private void selectBlueprintsFromDragRectangle(RectangleF rect) + protected virtual void UpdateSelectionFromDragBox() { + var quad = DragBox.Box.ScreenSpaceDrawQuad; + foreach (var blueprint in SelectionBlueprints) { - // only run when utmost necessary to avoid unnecessary rect computations. - bool isValidForSelection() => blueprint.IsAlive && blueprint.IsPresent && rect.Contains(blueprint.ScreenSpaceSelectionPoint); - switch (blueprint.State) { - case SelectionState.NotSelected: - if (isValidForSelection()) - blueprint.Select(); + case SelectionState.Selected: + // Selection is preserved even after blueprint becomes dead. + if (!quad.Contains(blueprint.ScreenSpaceSelectionPoint)) + blueprint.Deselect(); break; - case SelectionState.Selected: - if (AllowDeselectionDuringDrag && !isValidForSelection()) - blueprint.Deselect(); + case SelectionState.NotSelected: + if (blueprint.IsAlive && blueprint.IsPresent && quad.Contains(blueprint.ScreenSpaceSelectionPoint)) + blueprint.Select(); break; } } } /// - /// Selects all s. + /// Select all currently-present items. /// - protected virtual void SelectAll() - { - // Scheduled to allow the change in lifetime to take place. - Schedule(() => SelectionBlueprints.ToList().ForEach(m => m.Select())); - } + protected abstract void SelectAll(); /// - /// Deselects all selected s. + /// Deselect all selected items. /// - private void deselectAll() => SelectionHandler.SelectedBlueprints.ToList().ForEach(m => m.Deselect()); + protected void DeselectAll() => SelectedItems.Clear(); protected virtual void OnBlueprintSelected(SelectionBlueprint blueprint) { @@ -433,10 +479,19 @@ namespace osu.Game.Screens.Edit.Compose.Components #region Selection Movement - private Vector2[] movementBlueprintOriginalPositions; + 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. /// @@ -448,12 +503,12 @@ 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 movementBlueprints = SortForMovement(SelectionHandler.SelectedBlueprints).ToArray(); - movementBlueprintOriginalPositions = movementBlueprints.Select(m => m.ScreenSpaceSelectionPoint).ToArray(); + movementBlueprintsOriginalPositions = movementBlueprints.Select(m => m.ScreenSpaceSnapPoints).ToArray(); return true; } @@ -474,26 +529,15 @@ namespace osu.Game.Screens.Edit.Compose.Components if (movementBlueprints == null) return false; - Debug.Assert(movementBlueprintOriginalPositions != null); + Debug.Assert(movementBlueprintsOriginalPositions != null); Vector2 distanceTravelled = e.ScreenSpaceMousePosition - e.ScreenSpaceMouseDownPosition; if (snapProvider != null) { - // check for positional snap for every object in selection (for things like object-object snapping) - for (int i = 0; i < movementBlueprintOriginalPositions.Length; i++) + for (int i = 0; i < movementBlueprints.Length; i++) { - Vector2 originalPosition = movementBlueprintOriginalPositions[i]; - var testPosition = originalPosition + distanceTravelled; - - var positionalResult = snapProvider.FindSnappedPositionAndTime(testPosition, SnapType.NearbyObjects); - - if (positionalResult.ScreenSpacePosition == testPosition) continue; - - var delta = positionalResult.ScreenSpacePosition - movementBlueprints[i].ScreenSpaceSelectionPoint; - - // attempt to move the objects, and abort any time based snapping if we can. - if (SelectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprints[i], delta))) + if (checkSnappingBlueprintToNearbyObjects(movementBlueprints[i], distanceTravelled, movementBlueprintsOriginalPositions[i])) return true; } } @@ -502,7 +546,7 @@ namespace osu.Game.Screens.Edit.Compose.Components // item in the selection. // The final movement position, relative to movementBlueprintOriginalPosition. - Vector2 movePosition = movementBlueprintOriginalPositions.First() + distanceTravelled; + Vector2 movePosition = movementBlueprintsOriginalPositions.First().First() + distanceTravelled; // Retrieve a snapped position. var result = snapProvider?.FindSnappedPositionAndTime(movePosition, ~SnapType.NearbyObjects); @@ -515,6 +559,36 @@ namespace osu.Game.Screens.Edit.Compose.Components return ApplySnapResult(movementBlueprints, result); } + /// + /// Check for positional snap for given blueprint. + /// + /// The blueprint to check for snapping. + /// Distance travelled since start of dragging action. + /// The snap positions of blueprint before start of dragging action. + /// Whether an object to snap to was found. + private bool checkSnappingBlueprintToNearbyObjects(SelectionBlueprint blueprint, Vector2 distanceTravelled, Vector2[] originalPositions) + { + var currentPositions = blueprint.ScreenSpaceSnapPoints; + + for (int i = 0; i < originalPositions.Length; i++) + { + Vector2 originalPosition = originalPositions[i]; + var testPosition = originalPosition + distanceTravelled; + + var positionalResult = snapProvider.FindSnappedPositionAndTime(testPosition, SnapType.NearbyObjects); + + if (positionalResult.ScreenSpacePosition == testPosition) continue; + + var delta = positionalResult.ScreenSpacePosition - currentPositions[i]; + + // attempt to move the objects, and abort any time based snapping if we can. + if (SelectionHandler.HandleMovement(new MoveSelectionEvent(blueprint, delta))) + return true; + } + + return false; + } + protected virtual bool ApplySnapResult(SelectionBlueprint[] blueprints, SnapResult result) => SelectionHandler.HandleMovement(new MoveSelectionEvent(blueprints.First(), result.ScreenSpacePosition - blueprints.First().ScreenSpaceSelectionPoint)); @@ -527,7 +601,7 @@ namespace osu.Game.Screens.Edit.Compose.Components if (movementBlueprints == null) return false; - movementBlueprintOriginalPositions = null; + movementBlueprintsOriginalPositions = null; movementBlueprints = null; return true; diff --git a/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs index 771612fcf1..d6e4e1f030 100644 --- a/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -14,7 +16,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Edit.Compose.Components { - public abstract class CircularDistanceSnapGrid : DistanceSnapGrid + public abstract partial class CircularDistanceSnapGrid : DistanceSnapGrid { protected CircularDistanceSnapGrid(HitObject referenceObject, Vector2 startPosition, double startTime, double? endTime = null) : base(referenceObject, startPosition, startTime, endTime) @@ -51,9 +53,16 @@ namespace osu.Game.Screens.Edit.Compose.Components float maxDistance = new Vector2(dx, dy).Length; int requiredCircles = Math.Min(MaxIntervals, (int)(maxDistance / DistanceBetweenTicks)); + // We need to offset the drawn lines to the next valid snap for the currently selected divisor. + // + // Picture the scenario where the user has just placed an object on a 1/2 snap, then changes to + // 1/3 snap and expects to be able to place the next object on a valid 1/3 snap, regardless of the + // fact that the 1/2 snap reference object is not valid for 1/3 snapping. + float offset = SnapProvider.FindSnappedDistance(ReferenceObject, 0); + for (int i = 0; i < requiredCircles; i++) { - float diameter = (i + 1) * DistanceBetweenTicks * 2; + float diameter = (offset + (i + 1) * DistanceBetweenTicks) * 2; AddInternal(new Ring(ReferenceObject, GetColourForIndexFromPlacement(i)) { @@ -108,7 +117,7 @@ namespace osu.Game.Screens.Edit.Compose.Components return (snappedPosition, snappedTime); } - private class Ring : CircularProgress + private partial class Ring : CircularProgress { [Resolved] private IDistanceSnapProvider snapProvider { get; set; } diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs index 68be20720d..453e4b9130 100644 --- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using Humanizer; @@ -10,7 +12,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; -using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Game.Audio; using osu.Game.Graphics.UserInterface; @@ -19,6 +20,7 @@ using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.UI; using osu.Game.Screens.Edit.Components.TernaryButtons; using osuTK; using osuTK.Input; @@ -28,16 +30,19 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// A blueprint container generally displayed as an overlay to a ruleset's playfield. /// - public class ComposeBlueprintContainer : EditorBlueprintContainer + public partial class ComposeBlueprintContainer : EditorBlueprintContainer { - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; - private readonly Container placementBlueprintContainer; protected new EditorSelectionHandler SelectionHandler => (EditorSelectionHandler)base.SelectionHandler; - private PlacementBlueprint currentPlacement; - private InputManager inputManager; + public PlacementBlueprint CurrentPlacement { get; private set; } + + /// + /// Positional input must be received outside the container's bounds, + /// in order to handle composer blueprints which are partially offscreen. + /// + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; public ComposeBlueprintContainer(HitObjectComposer composer) : base(composer) @@ -53,15 +58,16 @@ namespace osu.Game.Screens.Edit.Compose.Components { TernaryStates = CreateTernaryButtons().ToArray(); - AddInternal(placementBlueprintContainer); + AddInternal(new DrawableRulesetDependenciesProvidingContainer(Composer.Ruleset) + { + Child = placementBlueprintContainer + }); } protected override void LoadComplete() { base.LoadComplete(); - inputManager = GetContainingInputManager(); - Beatmap.HitObjectAdded += hitObjectAdded; // updates to selected are handled for us by SelectionHandler. @@ -85,6 +91,8 @@ namespace osu.Game.Screens.Edit.Compose.Components blueprint.DrawableObject = drawableObject; } + private bool nudgeMovementActive; + protected override bool OnKeyDown(KeyDownEvent e) { if (e.ControlPressed) @@ -92,19 +100,19 @@ namespace osu.Game.Screens.Edit.Compose.Components switch (e.Key) { case Key.Left: - moveSelection(new Vector2(-1, 0)); + nudgeSelection(new Vector2(-1, 0)); return true; case Key.Right: - moveSelection(new Vector2(1, 0)); + nudgeSelection(new Vector2(1, 0)); return true; case Key.Up: - moveSelection(new Vector2(0, -1)); + nudgeSelection(new Vector2(0, -1)); return true; case Key.Down: - moveSelection(new Vector2(0, 1)); + nudgeSelection(new Vector2(0, 1)); return true; } } @@ -112,12 +120,29 @@ namespace osu.Game.Screens.Edit.Compose.Components return false; } + protected override void OnKeyUp(KeyUpEvent e) + { + base.OnKeyUp(e); + + if (nudgeMovementActive && !e.ControlPressed) + { + Beatmap.EndChange(); + nudgeMovementActive = false; + } + } + /// /// Move the current selection spatially by the specified delta, in gamefield coordinates (ie. the same coordinates as the blueprints). /// /// - private void moveSelection(Vector2 delta) + private void nudgeSelection(Vector2 delta) { + if (!nudgeMovementActive) + { + nudgeMovementActive = true; + Beatmap.BeginChange(); + } + var firstBlueprint = SelectionHandler.SelectedBlueprints.FirstOrDefault(); if (firstBlueprint == null) @@ -131,13 +156,13 @@ namespace osu.Game.Screens.Edit.Compose.Components private void updatePlacementNewCombo() { - if (currentPlacement?.HitObject is IHasComboInformation c) + if (CurrentPlacement?.HitObject is IHasComboInformation c) c.NewCombo = NewCombo.Value == TernaryState.True; } private void updatePlacementSamples() { - if (currentPlacement == null) return; + if (CurrentPlacement == null) return; foreach (var kvp in SelectionHandler.SelectionSampleStates) sampleChanged(kvp.Key, kvp.Value.Value); @@ -145,9 +170,9 @@ namespace osu.Game.Screens.Edit.Compose.Components private void sampleChanged(string sampleName, TernaryState state) { - if (currentPlacement == null) return; + if (CurrentPlacement == null) return; - var samples = currentPlacement.HitObject.Samples; + var samples = CurrentPlacement.HitObject.Samples; var existingSample = samples.FirstOrDefault(s => s.Name == sampleName); @@ -214,12 +239,12 @@ namespace osu.Game.Screens.Edit.Compose.Components private void updatePlacementPosition() { - var snapResult = Composer.FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position); + var snapResult = Composer.FindSnappedPositionAndTime(InputManager.CurrentState.Mouse.Position); // if no time was found from positional snapping, we should still quantize to the beat. snapResult.Time ??= Beatmap.SnapTime(EditorClock.CurrentTime, null); - currentPlacement.UpdateTimeAndPosition(snapResult); + CurrentPlacement.UpdateTimeAndPosition(snapResult); } #endregion @@ -228,9 +253,9 @@ namespace osu.Game.Screens.Edit.Compose.Components { base.Update(); - if (currentPlacement != null) + if (CurrentPlacement != null) { - switch (currentPlacement.PlacementActive) + switch (CurrentPlacement.PlacementActive) { case PlacementBlueprint.PlacementState.Waiting: if (!Composer.CursorInPlacementArea) @@ -246,7 +271,7 @@ namespace osu.Game.Screens.Edit.Compose.Components if (Composer.CursorInPlacementArea) ensurePlacementCreated(); - if (currentPlacement != null) + if (CurrentPlacement != null) updatePlacementPosition(); } @@ -275,13 +300,13 @@ namespace osu.Game.Screens.Edit.Compose.Components private void ensurePlacementCreated() { - if (currentPlacement != null) return; + if (CurrentPlacement != null) return; var blueprint = CurrentTool?.CreatePlacementBlueprint(); if (blueprint != null) { - placementBlueprintContainer.Child = currentPlacement = blueprint; + placementBlueprintContainer.Child = CurrentPlacement = blueprint; // Fixes a 1-frame position discrepancy due to the first mouse move event happening in the next frame updatePlacementPosition(); @@ -294,11 +319,11 @@ namespace osu.Game.Screens.Edit.Compose.Components private void removePlacement() { - if (currentPlacement == null) return; + if (CurrentPlacement == null) return; - currentPlacement.EndPlacement(false); - currentPlacement.Expire(); - currentPlacement = null; + CurrentPlacement.EndPlacement(false); + CurrentPlacement.Expire(); + CurrentPlacement = null; } private HitObjectCompositionTool currentTool; diff --git a/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs index 1f64a50c02..6092ebc08f 100644 --- a/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -19,7 +21,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// A grid which takes user input and returns a quantized ("snapped") position and time. /// - public abstract class DistanceSnapGrid : CompositeDrawable + public abstract partial class DistanceSnapGrid : CompositeDrawable { /// /// The spacing between each tick of the beat snapping grid. @@ -95,7 +97,7 @@ namespace osu.Game.Screens.Edit.Compose.Components private void updateSpacing() { float distanceSpacingMultiplier = (float)DistanceSpacingMultiplier.Value; - float beatSnapDistance = SnapProvider.GetBeatSnapDistanceAt(ReferenceObject); + float beatSnapDistance = SnapProvider.GetBeatSnapDistanceAt(ReferenceObject, false); DistanceBetweenTicks = beatSnapDistance * distanceSpacingMultiplier; diff --git a/osu.Game/Screens/Edit/Compose/Components/DragBox.cs b/osu.Game/Screens/Edit/Compose/Components/DragBox.cs index ecbac82db0..4d1f81228e 100644 --- a/osu.Game/Screens/Edit/Compose/Components/DragBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/DragBox.cs @@ -1,12 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Layout; @@ -17,20 +18,15 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// A box that displays the drag selection and provides selection events for users to handle. /// - public class DragBox : CompositeDrawable, IStateful + public partial class DragBox : CompositeDrawable, IStateful { - protected readonly Action PerformSelection; - - protected Drawable Box; + public Drawable Box { get; private set; } /// /// Creates a new . /// - /// A delegate that performs drag selection. - public DragBox(Action performSelection) + public DragBox() { - PerformSelection = performSelection; - RelativeSizeAxes = Axes.Both; AlwaysPresent = true; Alpha = 0; @@ -44,30 +40,14 @@ namespace osu.Game.Screens.Edit.Compose.Components protected virtual Drawable CreateBox() => new BoxWithBorders(); - private RectangleF? dragRectangle; - /// /// Handle a forwarded mouse event. /// /// The mouse event. - /// Whether the event should be handled and blocking. - public virtual bool HandleDrag(MouseButtonEvent e) + public virtual void HandleDrag(MouseButtonEvent e) { - var dragPosition = e.ScreenSpaceMousePosition; - var dragStartPosition = e.ScreenSpaceMouseDownPosition; - - var dragQuad = new Quad(dragStartPosition.X, dragStartPosition.Y, dragPosition.X - dragStartPosition.X, dragPosition.Y - dragStartPosition.Y); - - // We use AABBFloat instead of RectangleF since it handles negative sizes for us - var rec = dragQuad.AABBFloat; - dragRectangle = rec; - - var topLeft = ToLocalSpace(rec.TopLeft); - var bottomRight = ToLocalSpace(rec.BottomRight); - - Box.Position = topLeft; - Box.Size = bottomRight - topLeft; - return true; + Box.Position = Vector2.ComponentMin(e.MouseDownPosition, e.MousePosition); + Box.Size = Vector2.ComponentMax(e.MouseDownPosition, e.MousePosition) - Box.Position; } private Visibility state; @@ -85,25 +65,13 @@ namespace osu.Game.Screens.Edit.Compose.Components } } - protected override void Update() - { - base.Update(); - - if (dragRectangle != null) - PerformSelection?.Invoke(dragRectangle.Value); - } - - public override void Hide() - { - State = Visibility.Hidden; - dragRectangle = null; - } + public override void Hide() => State = Visibility.Hidden; public override void Show() => State = Visibility.Visible; public event Action StateChanged; - public class BoxWithBorders : CompositeDrawable + public partial class BoxWithBorders : CompositeDrawable { private readonly LayoutValue cache = new LayoutValue(Invalidation.RequiredParentSizeToFit); diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs index 22b211f257..65797a968d 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs @@ -1,11 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; @@ -13,7 +16,7 @@ using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Screens.Edit.Compose.Components { - public class EditorBlueprintContainer : BlueprintContainer + public partial class EditorBlueprintContainer : BlueprintContainer { [Resolved] protected EditorClock EditorClock { get; private set; } @@ -25,6 +28,8 @@ namespace osu.Game.Screens.Edit.Compose.Components private HitObjectUsageEventBuffer usageEventBuffer; + protected InputManager InputManager { get; private set; } + protected EditorBlueprintContainer(HitObjectComposer composer) { Composer = composer; @@ -40,6 +45,8 @@ namespace osu.Game.Screens.Edit.Compose.Components { base.LoadComplete(); + InputManager = GetContainingInputManager(); + Beatmap.HitObjectAdded += AddBlueprintFor; Beatmap.HitObjectRemoved += RemoveBlueprintFor; @@ -64,8 +71,6 @@ namespace osu.Game.Screens.Edit.Compose.Components protected override IEnumerable> SortForMovement(IReadOnlyList> blueprints) => blueprints.OrderBy(b => b.Item.StartTime); - protected override bool AllowDeselectionDuringDrag => !EditorClock.IsRunning; - protected override bool ApplySnapResult(SelectionBlueprint[] blueprints, SnapResult result) { if (!base.ApplySnapResult(blueprints, result)) @@ -131,8 +136,7 @@ namespace osu.Game.Screens.Edit.Compose.Components protected override void SelectAll() { Composer.Playfield.KeepAllAlive(); - - base.SelectAll(); + SelectedItems.AddRange(Beatmap.HitObjects.Except(SelectedItems).ToArray()); } protected override void OnBlueprintSelected(SelectionBlueprint blueprint) diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs index 7f693996a4..357cc940f2 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -16,7 +18,7 @@ using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Screens.Edit.Compose.Components { - public class EditorSelectionHandler : SelectionHandler + public partial class EditorSelectionHandler : SelectionHandler { [Resolved] protected EditorBeatmap EditorBeatmap { get; private set; } @@ -29,7 +31,7 @@ namespace osu.Game.Screens.Edit.Compose.Components // bring in updates from selection changes EditorBeatmap.HitObjectUpdated += _ => Scheduler.AddOnce(UpdateTernaryStates); - SelectedItems.CollectionChanged += (sender, args) => Scheduler.AddOnce(UpdateTernaryStates); + SelectedItems.CollectionChanged += (_, _) => Scheduler.AddOnce(UpdateTernaryStates); } protected override void DeleteItems(IEnumerable items) => EditorBeatmap.RemoveRange(items); diff --git a/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs b/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs index 3fc26fa974..849a526556 100644 --- a/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -13,7 +15,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// A container for ordered by their start times. /// - public sealed class HitObjectOrderedSelectionContainer : Container> + public sealed partial class HitObjectOrderedSelectionContainer : Container> { [Resolved] private EditorBeatmap editorBeatmap { get; set; } @@ -22,21 +24,19 @@ namespace osu.Game.Screens.Edit.Compose.Components { base.LoadComplete(); - editorBeatmap.HitObjectUpdated += hitObjectUpdated; + editorBeatmap.BeatmapReprocessed += SortInternal; } - private void hitObjectUpdated(HitObject _) => SortInternal(); - public override void Add(SelectionBlueprint drawable) { SortInternal(); base.Add(drawable); } - public override bool Remove(SelectionBlueprint drawable) + public override bool Remove(SelectionBlueprint drawable, bool disposeImmediately) { SortInternal(); - return base.Remove(drawable); + return base.Remove(drawable, disposeImmediately); } protected override int Compare(Drawable x, Drawable y) @@ -62,7 +62,7 @@ namespace osu.Game.Screens.Edit.Compose.Components if (result != 0) return result; } - return CompareReverseChildID(y, x); + return CompareReverseChildID(x, y); } protected override void Dispose(bool isDisposing) @@ -70,7 +70,7 @@ namespace osu.Game.Screens.Edit.Compose.Components base.Dispose(isDisposing); if (editorBeatmap != null) - editorBeatmap.HitObjectUpdated -= hitObjectUpdated; + editorBeatmap.BeatmapReprocessed -= SortInternal; } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/MoveSelectionEvent.cs b/osu.Game/Screens/Edit/Compose/Components/MoveSelectionEvent.cs index 2b71bb2f16..46d948f8b6 100644 --- a/osu.Game/Screens/Edit/Compose/Components/MoveSelectionEvent.cs +++ b/osu.Game/Screens/Edit/Compose/Components/MoveSelectionEvent.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets.Edit; using osuTK; diff --git a/osu.Game/Screens/Edit/Compose/Components/RectangularPositionSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/RectangularPositionSnapGrid.cs index f0d26c7b6a..06b73c8af4 100644 --- a/osu.Game/Screens/Edit/Compose/Components/RectangularPositionSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/RectangularPositionSnapGrid.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -13,7 +15,7 @@ using osuTK; namespace osu.Game.Screens.Edit.Compose.Components { - public class RectangularPositionSnapGrid : CompositeDrawable + public partial class RectangularPositionSnapGrid : CompositeDrawable { /// /// The position of the origin of this in local coordinates. diff --git a/osu.Game/Screens/Edit/Compose/Components/ScrollingDragBox.cs b/osu.Game/Screens/Edit/Compose/Components/ScrollingDragBox.cs new file mode 100644 index 0000000000..599e64760d --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/ScrollingDragBox.cs @@ -0,0 +1,64 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + /// + /// A that scrolls along with the scrolling playfield. + /// + public partial class ScrollingDragBox : DragBox + { + public double MinTime { get; private set; } + + public double MaxTime { get; private set; } + + private double? startTime; + + private readonly ScrollingPlayfield playfield; + + public ScrollingDragBox(Playfield playfield) + { + this.playfield = playfield as ScrollingPlayfield ?? throw new ArgumentException("Playfield must be of type {nameof(ScrollingPlayfield)} to use this class.", nameof(playfield)); + } + + public override void HandleDrag(MouseButtonEvent e) + { + base.HandleDrag(e); + + startTime ??= playfield.TimeAtScreenSpacePosition(e.ScreenSpaceMouseDownPosition); + double endTime = playfield.TimeAtScreenSpacePosition(e.ScreenSpaceMousePosition); + + MinTime = Math.Min(startTime.Value, endTime); + MaxTime = Math.Max(startTime.Value, endTime); + + var startPos = ToLocalSpace(playfield.ScreenSpacePositionAtTime(startTime.Value)); + var endPos = ToLocalSpace(playfield.ScreenSpacePositionAtTime(endTime)); + + switch (playfield.ScrollingInfo.Direction.Value) + { + case ScrollingDirection.Up: + case ScrollingDirection.Down: + Box.Y = Math.Min(startPos.Y, endPos.Y); + Box.Height = Math.Max(startPos.Y, endPos.Y) - Box.Y; + break; + + case ScrollingDirection.Left: + case ScrollingDirection.Right: + Box.X = Math.Min(startPos.X, endPos.X); + Box.Width = Math.Max(startPos.X, endPos.X) - Box.X; + break; + } + } + + public override void Hide() + { + base.Hide(); + startTime = null; + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs index cda986c7cd..17790547ed 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -16,7 +18,7 @@ using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components { [Cached] - public class SelectionBox : CompositeDrawable + public partial class SelectionBox : CompositeDrawable { public const float BORDER_RADIUS = 3; diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs index 346bb2b508..832d8b65e5 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -13,7 +15,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Edit.Compose.Components { - public sealed class SelectionBoxButton : SelectionBoxControl, IHasTooltip + public sealed partial class SelectionBoxButton : SelectionBoxControl, IHasTooltip { private SpriteIcon icon; diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxControl.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxControl.cs index 8f22e67922..35c67a1c67 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxControl.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -15,7 +17,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Represents the base appearance for UI controls of the , /// such as scale handles, rotation handles, buttons, etc... /// - public abstract class SelectionBoxControl : CompositeDrawable + public abstract partial class SelectionBoxControl : CompositeDrawable { public const double TRANSFORM_DURATION = 100; diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandle.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandle.cs index c37fefeed4..757ff655f5 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandle.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandle.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Input.Events; namespace osu.Game.Screens.Edit.Compose.Components { - public abstract class SelectionBoxDragHandle : SelectionBoxControl + public abstract partial class SelectionBoxDragHandle : SelectionBoxControl { protected override bool OnDragStart(DragStartEvent e) { diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleContainer.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleContainer.cs index 397158b9f6..5c87271493 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; @@ -13,7 +15,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// Represents a display composite containing and managing the visibility state of the selection box's drag handles. /// - public class SelectionBoxDragHandleContainer : CompositeDrawable + public partial class SelectionBoxDragHandleContainer : CompositeDrawable { private Container scaleHandles; private Container rotationHandles; diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs index f13ed0456a..c2a3f12efd 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs @@ -1,22 +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 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 { - public class SelectionBoxRotationHandle : SelectionBoxDragHandle, IHasTooltip + public partial class SelectionBoxRotationHandle : SelectionBoxDragHandle, IHasTooltip { public Action HandleRotate { get; set; } @@ -24,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] @@ -48,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); @@ -72,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) @@ -98,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/SelectionBoxScaleHandle.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxScaleHandle.cs index 1f82f28380..7943065c82 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxScaleHandle.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxScaleHandle.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -9,7 +11,7 @@ using osuTK; namespace osu.Game.Screens.Edit.Compose.Components { - public class SelectionBoxScaleHandle : SelectionBoxDragHandle + public partial class SelectionBoxScaleHandle : SelectionBoxDragHandle { public Action HandleScale { get; set; } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index 78b98a3649..9e4fb26688 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -19,6 +21,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets.Edit; +using osu.Game.Screens.Edit.Compose.Components.Timeline; using osuTK; using osuTK.Input; @@ -27,8 +30,13 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// A component which outlines items and handles movement of selections. /// - public abstract class SelectionHandler : CompositeDrawable, IKeyBindingHandler, IKeyBindingHandler, IHasContextMenu + public abstract partial class SelectionHandler : CompositeDrawable, IKeyBindingHandler, IKeyBindingHandler, IHasContextMenu { + /// + /// How much padding around the selection area is added. + /// + public const float INFLATE_SIZE = 5; + /// /// The currently selected blueprints. /// Should be used when operations are dealing directly with the visible blueprints. @@ -61,7 +69,7 @@ namespace osu.Game.Screens.Edit.Compose.Components { InternalChild = SelectionBox = CreateSelectionBox(); - SelectedItems.CollectionChanged += (sender, args) => + SelectedItems.CollectionChanged += (_, _) => { Scheduler.AddOnce(updateVisibility); }; @@ -97,6 +105,14 @@ namespace osu.Game.Screens.Edit.Compose.Components #region User Input Handling + /// + /// Positional input must be received outside the container's bounds, + /// in order to handle blueprints which are partially offscreen. + /// + /// + /// + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + /// /// Handles the selected items being moved. /// @@ -294,7 +310,7 @@ namespace osu.Game.Screens.Edit.Compose.Components protected void DeleteSelected() { - DeleteItems(selectedBlueprints.Select(b => b.Item)); + DeleteItems(SelectedItems.ToArray()); } #endregion @@ -335,7 +351,7 @@ namespace osu.Game.Screens.Edit.Compose.Components for (int i = 1; i < selectedBlueprints.Count; i++) selectionRect = RectangleF.Union(selectionRect, ToLocalSpace(selectedBlueprints[i].SelectionQuad).AABBFloat); - selectionRect = selectionRect.Inflate(5f); + selectionRect = selectionRect.Inflate(INFLATE_SIZE); SelectionBox.Position = selectionRect.Location; SelectionBox.Size = selectionRect.Size; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/CentreMarker.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/CentreMarker.cs index 8c8b38d9ea..44daf70577 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/CentreMarker.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/CentreMarker.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,7 +12,7 @@ using osuTK; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class CentreMarker : CompositeDrawable + public partial class CentreMarker : CompositeDrawable { private const float triangle_width = 15; private const float triangle_height = 10; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs index eaaa663fe7..d3cdd461ea 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -20,7 +22,7 @@ using osuTK; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class DifficultyPointPiece : HitObjectPointPiece, IHasPopover + public partial class DifficultyPointPiece : HitObjectPointPiece, IHasPopover { public readonly HitObject HitObject; @@ -49,7 +51,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline public Popover GetPopover() => new DifficultyEditPopover(HitObject); - public class DifficultyEditPopover : OsuPopover + public partial class DifficultyEditPopover : OsuPopover { private readonly HitObject hitObject; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/HitObjectPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/HitObjectPointPiece.cs index 6b62459c97..5b0a5729c8 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/HitObjectPointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/HitObjectPointPiece.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -12,7 +14,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class HitObjectPointPiece : CircularContainer + public partial class HitObjectPointPiece : CircularContainer { private readonly ControlPoint point; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs index 9abea73f6b..314137a565 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.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 enable - using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -22,7 +20,7 @@ using osuTK; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class SamplePointPiece : HitObjectPointPiece, IHasPopover + public partial class SamplePointPiece : HitObjectPointPiece, IHasPopover { public readonly HitObject HitObject; @@ -40,8 +38,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [BackgroundDependencyLoader] private void load() { - volume.BindValueChanged(volume => updateText()); - bank.BindValueChanged(bank => updateText(), true); + volume.BindValueChanged(_ => updateText()); + bank.BindValueChanged(_ => updateText(), true); } protected override bool OnClick(ClickEvent e) @@ -57,7 +55,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline public Popover GetPopover() => new SampleEditPopover(HitObject); - public class SampleEditPopover : OsuPopover + public partial class SampleEditPopover : OsuPopover { private readonly HitObject hitObject; @@ -121,7 +119,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline }); // on commit, ensure that the value is correct by sourcing it from the objects' control points 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(relevantControlPoints); volume.Current.BindValueChanged(val => updateVolumeFor(relevantObjects, val.NewValue)); } @@ -133,7 +131,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } 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 ? (int?)relevantControlPoints.First().SampleVolume : null; + private static int? getCommonVolume(SampleControlPoint[] relevantControlPoints) => relevantControlPoints.Select(point => point.SampleVolume).Distinct().Count() == 1 ? relevantControlPoints.First().SampleVolume : null; private void updateBankFor(IEnumerable objects, string? newBank) { diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs index 89e9fb2404..75de15fe56 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Audio.Track; @@ -21,7 +23,7 @@ using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { [Cached] - public class Timeline : ZoomableScrollContainer, IPositionSnapProvider + public partial class Timeline : ZoomableScrollContainer, IPositionSnapProvider { private const float timeline_height = 72; private const float timeline_expanded_height = 94; @@ -34,11 +36,12 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline public readonly Bindable TicksVisible = new Bindable(); - public readonly IBindable Beatmap = new Bindable(); - [Resolved] private EditorClock editorClock { get; set; } + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } + /// /// The timeline's scroll position in the last frame. /// @@ -59,15 +62,11 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline /// private bool trackWasPlaying; - private Track track; - /// /// The timeline zoom level at a 1x zoom scale. /// private float defaultTimelineZoom; - private readonly Bindable timelineZoomScale = new BindableDouble(1.0); - public Timeline(Drawable userContent) { this.userContent = userContent; @@ -90,8 +89,12 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private Bindable waveformOpacity; + private double trackLengthForZoom; + + private readonly IBindable track = new Bindable(); + [BackgroundDependencyLoader] - private void load(IBindable beatmap, EditorBeatmap editorBeatmap, OsuColour colours, OsuConfigManager config) + private void load(IBindable beatmap, OsuColour colours, OsuConfigManager config) { CentreMarker centreMarker; @@ -137,27 +140,10 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline waveformOpacity = config.GetBindable(OsuSetting.EditorWaveformOpacity); - Beatmap.BindTo(beatmap); - Beatmap.BindValueChanged(b => - { - waveform.Waveform = b.NewValue.Waveform; - track = b.NewValue.Track; + track.BindTo(editorClock.Track); + track.BindValueChanged(_ => waveform.Waveform = beatmap.Value.Waveform, true); - // todo: i don't think this is safe, the track may not be loaded yet. - if (track.Length > 0) - { - MaxZoom = getZoomLevelForVisibleMilliseconds(500); - MinZoom = getZoomLevelForVisibleMilliseconds(10000); - defaultTimelineZoom = getZoomLevelForVisibleMilliseconds(6000); - } - }, true); - - timelineZoomScale.Value = editorBeatmap.BeatmapInfo.TimelineZoom; - timelineZoomScale.BindValueChanged(scale => - { - Zoom = (float)(defaultTimelineZoom * scale.NewValue); - editorBeatmap.BeatmapInfo.TimelineZoom = scale.NewValue; - }, true); + Zoom = (float)(defaultTimelineZoom * editorBeatmap.BeatmapInfo.TimelineZoom); } protected override void LoadComplete() @@ -193,8 +179,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void updateWaveformOpacity() => waveform.FadeTo(WaveformVisible.Value ? waveformOpacity.Value : 0, 200, Easing.OutQuint); - private float getZoomLevelForVisibleMilliseconds(double milliseconds) => Math.Max(1, (float)(track.Length / milliseconds)); - protected override void Update() { base.Update(); @@ -205,6 +189,22 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline // This needs to happen after transforms are updated, but before the scroll position is updated in base.UpdateAfterChildren if (editorClock.IsRunning) scrollToTrackTime(); + + if (editorClock.TrackLength != trackLengthForZoom) + { + defaultTimelineZoom = getZoomLevelForVisibleMilliseconds(6000); + + float minimumZoom = getZoomLevelForVisibleMilliseconds(10000); + float maximumZoom = getZoomLevelForVisibleMilliseconds(500); + + float initialZoom = (float)Math.Clamp(defaultTimelineZoom * (editorBeatmap.BeatmapInfo.TimelineZoom == 0 ? 1 : editorBeatmap.BeatmapInfo.TimelineZoom), minimumZoom, maximumZoom); + + SetupZoom(initialZoom, minimumZoom, maximumZoom); + + float getZoomLevelForVisibleMilliseconds(double milliseconds) => Math.Max(1, (float)(editorClock.TrackLength / milliseconds)); + + trackLengthForZoom = editorClock.TrackLength; + } } protected override bool OnScroll(ScrollEvent e) @@ -219,7 +219,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline protected override void OnZoomChanged() { base.OnZoomChanged(); - timelineZoomScale.Value = Zoom / defaultTimelineZoom; + editorBeatmap.BeatmapInfo.TimelineZoom = Zoom / defaultTimelineZoom; } protected override void UpdateAfterChildren() @@ -249,16 +249,13 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void seekTrackToCurrent() { - if (!track.IsLoaded) - return; - - double target = Current / Content.DrawWidth * track.Length; - editorClock.Seek(Math.Min(track.Length, target)); + double target = TimeAtPosition(Current); + editorClock.Seek(Math.Min(editorClock.TrackLength, target)); } private void scrollToTrackTime() { - if (!track.IsLoaded || track.Length == 0) + if (editorClock.TrackLength == 0) return; // covers the case where the user starts playback after a drag is in progress. @@ -266,7 +263,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (handlingDragInput) editorClock.Stop(); - ScrollTo((float)(editorClock.CurrentTime / track.Length) * Content.DrawWidth, false); + float position = PositionAtTime(editorClock.CurrentTime); + ScrollTo(position, false); } protected override bool OnMouseDown(MouseDownEvent e) @@ -304,12 +302,22 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline /// /// The total amount of time visible on the timeline. /// - public double VisibleRange => track.Length / Zoom; + public double VisibleRange => editorClock.TrackLength / Zoom; - public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) => - new SnapResult(screenSpacePosition, beatSnapProvider.SnapTime(getTimeFromPosition(Content.ToLocalSpace(screenSpacePosition)))); + public double TimeAtPosition(float x) + { + return x / Content.DrawWidth * editorClock.TrackLength; + } - private double getTimeFromPosition(Vector2 localPosition) => - (localPosition.X / Content.DrawWidth) * track.Length; + public float PositionAtTime(double time) + { + return (float)(time / editorClock.TrackLength * Content.DrawWidth); + } + + public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) + { + double time = TimeAtPosition(Content.ToLocalSpace(screenSpacePosition).X); + return new SnapResult(screenSpacePosition, beatSnapProvider.SnapTime(time)); + } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs index 4cffebc57c..0b83258f8b 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs @@ -1,18 +1,22 @@ // 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; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; using osuTK; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class TimelineArea : CompositeDrawable + public partial class TimelineArea : CompositeDrawable { public Timeline Timeline; @@ -73,19 +77,19 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { waveformCheckbox = new OsuCheckbox { - LabelText = "Waveform", - Current = { Value = true }, - }, - controlPointsCheckbox = new OsuCheckbox - { - LabelText = "Control Points", + LabelText = EditorStrings.TimelineWaveform, Current = { Value = true }, }, ticksCheckbox = new OsuCheckbox { - LabelText = "Ticks", + LabelText = EditorStrings.TimelineTicks, Current = { Value = true }, - } + }, + controlPointsCheckbox = new OsuCheckbox + { + LabelText = BeatmapsetsStrings.ShowStatsBpm, + Current = { Value = true }, + }, } } } @@ -116,7 +120,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline RelativeSizeAxes = Axes.Y, Height = 0.5f, Icon = FontAwesome.Solid.SearchPlus, - Action = () => changeZoom(1) + Action = () => Timeline.AdjustZoomRelatively(1) }, new TimelineButton { @@ -125,7 +129,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline RelativeSizeAxes = Axes.Y, Height = 0.5f, Icon = FontAwesome.Solid.SearchMinus, - Action = () => changeZoom(-1) + Action = () => Timeline.AdjustZoomRelatively(-1) }, } } @@ -151,7 +155,5 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline Timeline.ControlPointsVisible.BindTo(controlPointsCheckbox.Current); Timeline.TicksVisible.BindTo(ticksCheckbox.Current); } - - private void changeZoom(float change) => Timeline.Zoom += change; } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs index a9e9ef5001..f93fb0679f 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs @@ -1,7 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -11,7 +12,6 @@ using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Utils; @@ -24,17 +24,21 @@ using osuTK.Graphics; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - internal class TimelineBlueprintContainer : EditorBlueprintContainer + internal partial class TimelineBlueprintContainer : EditorBlueprintContainer { [Resolved(CanBeNull = true)] private Timeline timeline { get; set; } - private DragEvent lastDragEvent; private Bindable placement; private SelectionBlueprint placementBlueprint; - // We want children within the timeline to be interactable - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => timeline.ScreenSpaceDrawQuad.Contains(screenSpacePos); + private bool hitObjectDragged; + + /// + /// Positional input must be received outside the container's bounds, + /// in order to handle timeline blueprints which are stacked offscreen. + /// + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => timeline.ReceivePositionalInputAt(screenSpacePos); public TimelineBlueprintContainer(HitObjectComposer composer) : base(composer) @@ -60,7 +64,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline protected override void LoadComplete() { base.LoadComplete(); - DragBox.Alpha = 0; placement = Beatmap.PlacementObject.GetBoundCopy(); placement.ValueChanged += placementChanged; @@ -72,7 +75,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { if (placementBlueprint != null) { - SelectionBlueprints.Remove(placementBlueprint); + SelectionBlueprints.Remove(placementBlueprint, true); placementBlueprint = null; } } @@ -88,24 +91,18 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline protected override Container> CreateSelectionBlueprintContainer() => new TimelineSelectionBlueprintContainer { RelativeSizeAxes = Axes.Both }; - protected override void OnDrag(DragEvent e) + protected override bool OnDragStart(DragStartEvent e) { - handleScrollViaDrag(e); + if (!base.ReceivePositionalInputAt(e.ScreenSpaceMouseDownPosition)) + return false; - base.OnDrag(e); - } - - protected override void OnDragEnd(DragEndEvent e) - { - base.OnDragEnd(e); - lastDragEvent = null; + return base.OnDragStart(e); } protected override void Update() { - // trigger every frame so drags continue to update selection while playback is scrolling the timeline. - if (lastDragEvent != null) - OnDrag(lastDragEvent); + if (IsDragged || hitObjectDragged) + handleScrollViaDrag(); if (Composer != null && timeline != null) { @@ -160,33 +157,48 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { return new TimelineHitObjectBlueprint(item) { - OnDragHandled = handleScrollViaDrag, + OnDragHandled = e => hitObjectDragged = e != null, }; } - protected override DragBox CreateDragBox(Action performSelect) => new TimelineDragBox(performSelect); + protected sealed override DragBox CreateDragBox() => new TimelineDragBox(); - private void handleScrollViaDrag(DragEvent e) + protected override void UpdateSelectionFromDragBox() { - lastDragEvent = e; + var dragBox = (TimelineDragBox)DragBox; + double minTime = dragBox.MinTime; + double maxTime = dragBox.MaxTime; - if (lastDragEvent == null) - return; + SelectedItems.RemoveAll(hitObject => !shouldBeSelected(hitObject)); - if (timeline != null) + foreach (var hitObject in Beatmap.HitObjects.Except(SelectedItems).Where(shouldBeSelected)) { - var timelineQuad = timeline.ScreenSpaceDrawQuad; - float mouseX = e.ScreenSpaceMousePosition.X; + Composer.Playfield.SetKeepAlive(hitObject, true); + SelectedItems.Add(hitObject); + } - // scroll if in a drag and dragging outside visible extents - if (mouseX > timelineQuad.TopRight.X) - timeline.ScrollBy((float)((mouseX - timelineQuad.TopRight.X) / 10 * Clock.ElapsedFrameTime)); - else if (mouseX < timelineQuad.TopLeft.X) - timeline.ScrollBy((float)((mouseX - timelineQuad.TopLeft.X) / 10 * Clock.ElapsedFrameTime)); + bool shouldBeSelected(HitObject hitObject) + { + double midTime = (hitObject.StartTime + hitObject.GetEndTime()) / 2; + return minTime <= midTime && midTime <= maxTime; } } - private class SelectableAreaBackground : CompositeDrawable + private void handleScrollViaDrag() + { + if (timeline == null) return; + + var timelineQuad = timeline.ScreenSpaceDrawQuad; + float mouseX = InputManager.CurrentState.Mouse.Position.X; + + // scroll if in a drag and dragging outside visible extents + if (mouseX > timelineQuad.TopRight.X) + timeline.ScrollBy((float)((mouseX - timelineQuad.TopRight.X) / 10 * Clock.ElapsedFrameTime)); + else if (mouseX < timelineQuad.TopLeft.X) + timeline.ScrollBy((float)((mouseX - timelineQuad.TopLeft.X) / 10 * Clock.ElapsedFrameTime)); + } + + private partial class SelectableAreaBackground : CompositeDrawable { [Resolved] private OsuColour colours { get; set; } @@ -234,7 +246,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } - protected class TimelineSelectionBlueprintContainer : Container> + protected partial class TimelineSelectionBlueprintContainer : Container> { protected override Container> Content { get; } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineButton.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineButton.cs index e0b21b2e22..c94de0fe67 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineButton.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; @@ -8,7 +10,7 @@ using osu.Game.Screens.Edit.Timing; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class TimelineButton : IconButton + public partial class TimelineButton : IconButton { [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointDisplay.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointDisplay.cs index 8520567fa9..29983c9cbf 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointDisplay.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointDisplay.cs @@ -1,7 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Specialized; +using System.Diagnostics; using System.Linq; using osu.Framework.Bindables; using osu.Game.Beatmaps.ControlPoints; @@ -12,7 +15,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline /// /// The part of the timeline that displays the control points. /// - public class TimelineControlPointDisplay : TimelinePart + public partial class TimelineControlPointDisplay : TimelinePart { private readonly IBindableList controlPointGroups = new BindableList(); @@ -22,7 +25,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline controlPointGroups.UnbindAll(); controlPointGroups.BindTo(beatmap.ControlPointInfo.Groups); - controlPointGroups.BindCollectionChanged((sender, args) => + controlPointGroups.BindCollectionChanged((_, args) => { switch (args.Action) { @@ -31,14 +34,18 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline break; case NotifyCollectionChangedAction.Add: + Debug.Assert(args.NewItems != null); + foreach (var group in args.NewItems.OfType()) Add(new TimelineControlPointGroup(group)); break; case NotifyCollectionChangedAction.Remove: + Debug.Assert(args.OldItems != null); + foreach (var group in args.OldItems.OfType()) { - var matching = Children.SingleOrDefault(gv => gv.Group == group); + var matching = Children.SingleOrDefault(gv => ReferenceEquals(gv.Group, group)); matching?.Expire(); } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs index 9610f6424c..257cc9e635 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -8,7 +10,7 @@ using osu.Game.Beatmaps.ControlPoints; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class TimelineControlPointGroup : CompositeDrawable + public partial class TimelineControlPointGroup : CompositeDrawable { public readonly ControlPointGroup Group; @@ -32,7 +34,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline base.LoadComplete(); controlPoints.BindTo(Group.ControlPoints); - controlPoints.BindCollectionChanged((_, __) => + controlPoints.BindCollectionChanged((_, _) => { ClearInternal(); diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineDragBox.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineDragBox.cs index 8aad8aa6dc..a1dfd0718b 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineDragBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineDragBox.cs @@ -1,79 +1,49 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; -using osu.Framework.Utils; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class TimelineDragBox : DragBox + public partial class TimelineDragBox : DragBox { - // the following values hold the start and end X positions of the drag box in the timeline's local space, - // but with zoom unapplied in order to be able to compensate for positional changes - // while the timeline is being zoomed in/out. - private float? selectionStart; - private float selectionEnd; + public double MinTime { get; private set; } + + public double MaxTime { get; private set; } + + private double? startTime; [Resolved] private Timeline timeline { get; set; } - public TimelineDragBox(Action performSelect) - : base(performSelect) - { - } - protected override Drawable CreateBox() => new Box { RelativeSizeAxes = Axes.Y, Alpha = 0.3f }; - public override bool HandleDrag(MouseButtonEvent e) + public override void HandleDrag(MouseButtonEvent e) { - // The dragbox should only be active if the mouseDownPosition.Y is within this drawable's bounds. - float localY = ToLocalSpace(e.ScreenSpaceMouseDownPosition).Y; - if (DrawRectangle.Top > localY || DrawRectangle.Bottom < localY) - return false; + startTime ??= timeline.TimeAtPosition(e.MouseDownPosition.X); + double endTime = timeline.TimeAtPosition(e.MousePosition.X); - selectionStart ??= e.MouseDownPosition.X / timeline.CurrentZoom; + MinTime = Math.Min(startTime.Value, endTime); + MaxTime = Math.Max(startTime.Value, endTime); - // only calculate end when a transition is not in progress to avoid bouncing. - if (Precision.AlmostEquals(timeline.CurrentZoom, timeline.Zoom)) - selectionEnd = e.MousePosition.X / timeline.CurrentZoom; - - updateDragBoxPosition(); - return true; - } - - private void updateDragBoxPosition() - { - if (selectionStart == null) - return; - - float rescaledStart = selectionStart.Value * timeline.CurrentZoom; - float rescaledEnd = selectionEnd * timeline.CurrentZoom; - - Box.X = Math.Min(rescaledStart, rescaledEnd); - Box.Width = Math.Abs(rescaledStart - rescaledEnd); - - var boxScreenRect = Box.ScreenSpaceDrawQuad.AABBFloat; - - // we don't care about where the hitobjects are vertically. in cases like stacking display, they may be outside the box without this adjustment. - boxScreenRect.Y -= boxScreenRect.Height; - boxScreenRect.Height *= 2; - - PerformSelection?.Invoke(boxScreenRect); + Box.X = timeline.PositionAtTime(MinTime); + Box.Width = timeline.PositionAtTime(MaxTime) - Box.X; } public override void Hide() { base.Hide(); - selectionStart = null; + startTime = null; } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index 33ea137d51..4e5087c004 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -18,6 +18,7 @@ using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; @@ -27,23 +28,23 @@ using osuTK.Graphics; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class TimelineHitObjectBlueprint : SelectionBlueprint + public partial class TimelineHitObjectBlueprint : SelectionBlueprint { private const float circle_size = 38; - private Container repeatsContainer; + private Container? repeatsContainer; - public Action OnDragHandled; + public Action? OnDragHandled = null!; [UsedImplicitly] private readonly Bindable startTime; - private Bindable indexInCurrentComboBindable; + private Bindable? indexInCurrentComboBindable; - private Bindable comboIndexBindable; - private Bindable comboIndexWithOffsetsBindable; + private Bindable? comboIndexBindable; + private Bindable? comboIndexWithOffsetsBindable; - private Bindable displayColourBindable; + private Bindable displayColourBindable = null!; private readonly ExtendableCircle circle; private readonly Border border; @@ -52,7 +53,10 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private readonly OsuSpriteText comboIndexText; [Resolved] - private ISkinSource skin { get; set; } + private ISkinSource skin { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; public TimelineHitObjectBlueprint(HitObject item) : base(item) @@ -122,7 +126,10 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline case IHasComboInformation comboInfo: indexInCurrentComboBindable = comboInfo.IndexInCurrentComboBindable.GetBoundCopy(); - indexInCurrentComboBindable.BindValueChanged(_ => updateComboIndex(), true); + indexInCurrentComboBindable.BindValueChanged(_ => + { + comboIndexText.Text = (indexInCurrentComboBindable.Value + 1).ToString(); + }, true); comboIndexBindable = comboInfo.ComboIndexBindable.GetBoundCopy(); comboIndexWithOffsetsBindable = comboInfo.ComboIndexWithOffsetsBindable.GetBoundCopy(); @@ -147,8 +154,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline updateColour(); } - private void updateComboIndex() => comboIndexText.Text = (indexInCurrentComboBindable.Value + 1).ToString(); - private void updateColour() { Color4 colour; @@ -164,7 +169,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline break; default: - return; + colour = colourProvider.Highlight1; + break; } if (IsSelected) @@ -181,11 +187,11 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline colouredComponents.Colour = OsuColour.ForegroundTextColourFor(averageColour); } - private SamplePointPiece sampleOverrideDisplay; - private DifficultyPointPiece difficultyOverrideDisplay; + private SamplePointPiece? sampleOverrideDisplay; + private DifficultyPointPiece? difficultyOverrideDisplay; - private DifficultyControlPoint difficultyControlPoint; - private SampleControlPoint sampleControlPoint; + private DifficultyControlPoint difficultyControlPoint = null!; + private SampleControlPoint sampleControlPoint = null!; protected override void Update() { @@ -203,7 +209,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline updateRepeats(repeats); } - if (difficultyControlPoint != Item.DifficultyControlPoint) + if (!ReferenceEquals(difficultyControlPoint, Item.DifficultyControlPoint)) { difficultyControlPoint = Item.DifficultyControlPoint; difficultyOverrideDisplay?.Expire(); @@ -218,7 +224,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } - if (sampleControlPoint != Item.SampleControlPoint) + if (!ReferenceEquals(sampleControlPoint, Item.SampleControlPoint)) { sampleControlPoint = Item.SampleControlPoint; sampleOverrideDisplay?.Expire(); @@ -261,7 +267,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline public override Vector2 ScreenSpaceSelectionPoint => ScreenSpaceDrawQuad.TopLeft; - private class Tick : Circle + private partial class Tick : Circle { public Tick() { @@ -272,18 +278,29 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } - public class DragArea : Circle + public partial class DragArea : Circle { - private readonly HitObject hitObject; + private readonly HitObject? hitObject; [Resolved] - private Timeline timeline { get; set; } + private EditorBeatmap beatmap { get; set; } = null!; - public Action OnDragHandled; + [Resolved] + private IBeatSnapProvider beatSnapProvider { get; set; } = null!; + + [Resolved] + private Timeline timeline { get; set; } = null!; + + [Resolved] + private IEditorChangeHandler? changeHandler { get; set; } + + private ScheduledDelegate? dragOperation; + + public Action? OnDragHandled; public override bool HandlePositionalInput => hitObject != null; - public DragArea(HitObject hitObject) + public DragArea(HitObject? hitObject) { this.hitObject = hitObject; @@ -354,23 +371,12 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline this.FadeTo(IsHovered || hasMouseDown ? 1f : 0.9f, 200, Easing.OutQuint); } - [Resolved] - private EditorBeatmap beatmap { get; set; } - - [Resolved] - private IBeatSnapProvider beatSnapProvider { get; set; } - - [Resolved(CanBeNull = true)] - private IEditorChangeHandler changeHandler { get; set; } - protected override bool OnDragStart(DragStartEvent e) { changeHandler?.BeginChange(); return true; } - private ScheduledDelegate dragOperation; - protected override void OnDrag(DragEvent e) { base.OnDrag(e); @@ -391,7 +397,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (e.CurrentState.Keyboard.ShiftPressed) { - if (hitObject.DifficultyControlPoint == DifficultyControlPoint.DEFAULT) + if (ReferenceEquals(hitObject.DifficultyControlPoint, DifficultyControlPoint.DEFAULT)) hitObject.DifficultyControlPoint = new DifficultyControlPoint(); double newVelocity = hitObject.DifficultyControlPoint.SliderVelocity * (repeatHitObject.Duration / proposedDuration); @@ -418,9 +424,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline break; case IHasDuration endTimeHitObject: - double snappedTime = Math.Max(hitObject.StartTime, beatSnapProvider.SnapTime(time)); + double snappedTime = Math.Max(hitObject.StartTime + beatSnapProvider.GetBeatLengthAtTime(hitObject.StartTime), beatSnapProvider.SnapTime(time)); - if (endTimeHitObject.EndTime == snappedTime || Precision.AlmostEquals(snappedTime, hitObject.StartTime, beatmap.GetBeatLengthAtTime(snappedTime))) + if (endTimeHitObject.EndTime == snappedTime) return; endTimeHitObject.Duration = snappedTime - hitObject.StartTime; @@ -435,12 +441,15 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { base.OnDragEnd(e); - OnDragHandled?.Invoke(null); + dragOperation?.Cancel(); + dragOperation = null; + changeHandler?.EndChange(); + OnDragHandled?.Invoke(null); } } - public class Border : ExtendableCircle + public partial class Border : ExtendableCircle { [BackgroundDependencyLoader] private void load(OsuColour colours) @@ -456,7 +465,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline /// /// A circle with externalised end caps so it can take up the full width of a relative width area. /// - public class ExtendableCircle : CompositeDrawable + public partial class ExtendableCircle : CompositeDrawable { protected readonly Circle Content; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineSelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineSelectionHandler.cs index e98cf8332f..0a5a0e99ac 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineSelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineSelectionHandler.cs @@ -1,28 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using JetBrains.Annotations; -using osu.Framework.Allocation; using osu.Framework.Input.Events; using osu.Game.Input.Bindings; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; -using osuTK; using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - internal class TimelineSelectionHandler : EditorSelectionHandler + internal partial class TimelineSelectionHandler : EditorSelectionHandler { - [Resolved] - private Timeline timeline { get; set; } - - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => timeline.ScreenSpaceDrawQuad.Contains(screenSpacePos); - // for now we always allow movement. snapping is provided by the Timeline's "distance" snap implementation public override bool HandleMovement(MoveSelectionEvent moveEvent) => true; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs index 9904d91653..6a0688e19c 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs @@ -1,12 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; -using System.Linq; +using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Caching; using osu.Framework.Graphics; +using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; @@ -15,7 +18,7 @@ using osuTK; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class TimelineTickDisplay : TimelinePart + public partial class TimelineTickDisplay : TimelinePart { [Resolved] private EditorBeatmap beatmap { get; set; } @@ -32,8 +35,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [Resolved] private OsuColour colours { get; set; } - private static readonly int highest_divisor = BindableBeatDivisor.PREDEFINED_DIVISORS.Last(); - public TimelineTickDisplay() { RelativeSizeAxes = Axes.Both; @@ -78,20 +79,19 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { base.Update(); - if (timeline != null) + if (timeline == null || DrawWidth <= 0) return; + + (float, float) newRange = ( + (ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopLeft).X - PointVisualisation.MAX_WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X, + (ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopRight).X + PointVisualisation.MAX_WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X); + + if (visibleRange != newRange) { - var newRange = ( - (ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopLeft).X - PointVisualisation.MAX_WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X, - (ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopRight).X + PointVisualisation.MAX_WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X); + visibleRange = newRange; - if (visibleRange != newRange) - { - visibleRange = newRange; - - // actual regeneration only needs to occur if we've passed one of the known next min/max tick boundaries. - if (nextMinTick == null || nextMaxTick == null || (visibleRange.min < nextMinTick || visibleRange.max > nextMaxTick)) - tickCache.Invalidate(); - } + // actual regeneration only needs to occur if we've passed one of the known next min/max tick boundaries. + if (nextMinTick == null || nextMaxTick == null || (visibleRange.min < nextMinTick || visibleRange.max > nextMaxTick)) + tickCache.Invalidate(); } if (!tickCache.IsValid) @@ -149,6 +149,20 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } + if (Children.Count > 512) + { + // There should always be a sanely small number of ticks rendered. + // If this assertion triggers, either the zoom logic is broken or a beatmap is + // probably doing weird things... + // + // Let's hope the latter never happens. + // If it does, we can choose to either fix it or ignore it as an outlier. + string message = $"Timeline is rendering many ticks ({Children.Count})"; + + Logger.Log(message); + Debug.Fail(message); + } + int usedDrawables = drawableIndex; // save a few drawables beyond the currently used for edge cases. diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimingPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimingPointPiece.cs index 2df4ef001c..4191864e5c 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimingPointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimingPointPiece.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Beatmaps.ControlPoints; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class TimingPointPiece : TopPointPiece + public partial class TimingPointPiece : TopPointPiece { private readonly BindableNumber beatLength; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TopPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TopPointPiece.cs index 60a9e1ed66..69fb001a66 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TopPointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TopPointPiece.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -11,7 +13,7 @@ using osu.Game.Graphics.Sprites; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class TopPointPiece : CompositeDrawable + public partial class TopPointPiece : CompositeDrawable { private readonly ControlPoint point; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs index d008368b69..951f4129d4 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -15,7 +17,7 @@ using osuTK; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class ZoomableScrollContainer : OsuScrollContainer + public partial class ZoomableScrollContainer : OsuScrollContainer { /// /// The time to zoom into/out of a point. @@ -30,65 +32,79 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private readonly Container zoomedContent; protected override Container Content => zoomedContent; - private float currentZoom = 1; /// - /// The current zoom level of . - /// It may differ from during transitions. + /// The current zoom level of . + /// It may differ from during transitions. /// - public float CurrentZoom => currentZoom; + public float CurrentZoom { get; private set; } = 1; + + private bool isZoomSetUp; [Resolved(canBeNull: true)] private IFrameBasedClock editorClock { get; set; } private readonly LayoutValue zoomedContentWidthCache = new LayoutValue(Invalidation.DrawSize); - public ZoomableScrollContainer() + private float minZoom; + private float maxZoom; + + /// + /// Creates a with no zoom range. + /// Functionality will be disabled until zoom is set up via . + /// + protected ZoomableScrollContainer() : base(Direction.Horizontal) { - base.Content.Add(zoomedContent = new Container { RelativeSizeAxes = Axes.Y }); + base.Content.Add(zoomedContent = new Container + { + RelativeSizeAxes = Axes.Y, + // We must hide content until SetupZoom is called. + // If not, a child component that relies on its DrawWidth (via RelativeSizeAxes) may see a very incorrect value + // momentarily, as noticed in the TimelineTickDisplay, which would render thousands of ticks incorrectly. + Alpha = 0, + }); AddLayout(zoomedContentWidthCache); } - private float minZoom = 1; - /// - /// The minimum zoom level allowed. + /// Creates a with a defined zoom range. /// - public float MinZoom + public ZoomableScrollContainer(float minimum, float maximum, float initial) + : this() { - get => minZoom; - set - { - if (value < 1) - throw new ArgumentException($"{nameof(MinZoom)} must be >= 1.", nameof(value)); - - minZoom = value; - - if (Zoom < value) - Zoom = value; - } + SetupZoom(initial, minimum, maximum); } - private float maxZoom = 60; - /// - /// The maximum zoom level allowed. + /// Sets up the minimum and maximum range of this zoomable scroll container, along with the initial zoom value. /// - public float MaxZoom + /// The initial zoom value, applied immediately. + /// The minimum zoom value. + /// The maximum zoom value. + protected void SetupZoom(float initial, float minimum, float maximum) { - get => maxZoom; - set - { - if (value < 1) - throw new ArgumentException($"{nameof(MaxZoom)} must be >= 1.", nameof(value)); + if (minimum < 1) + throw new ArgumentException($"{nameof(minimum)} ({minimum}) must be >= 1.", nameof(maximum)); - maxZoom = value; + if (maximum < 1) + throw new ArgumentException($"{nameof(maximum)} ({maximum}) must be >= 1.", nameof(maximum)); - if (Zoom > value) - Zoom = value; - } + if (minimum > maximum) + throw new ArgumentException($"{nameof(minimum)} ({minimum}) must be less than {nameof(maximum)} ({maximum})"); + + if (initial < minimum || initial > maximum) + throw new ArgumentException($"{nameof(initial)} ({initial}) must be between {nameof(minimum)} ({minimum}) and {nameof(maximum)} ({maximum})"); + + minZoom = minimum; + maxZoom = maximum; + + CurrentZoom = zoomTarget = initial; + zoomedContentWidthCache.Invalidate(); + + isZoomSetUp = true; + zoomedContent.Show(); } /// @@ -97,20 +113,25 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline public float Zoom { get => zoomTarget; - set - { - value = Math.Clamp(value, MinZoom, MaxZoom); - - if (IsLoaded) - setZoomTarget(value, ToSpaceOfOtherDrawable(new Vector2(DrawWidth / 2, 0), zoomedContent).X); - else - currentZoom = zoomTarget = value; - } + set => updateZoom(value); } - protected override void Update() + private void updateZoom(float value) { - base.Update(); + if (!isZoomSetUp) + return; + + float newZoom = Math.Clamp(value, minZoom, maxZoom); + + if (IsLoaded) + setZoomTarget(newZoom, ToSpaceOfOtherDrawable(new Vector2(DrawWidth / 2, 0), zoomedContent).X); + else + CurrentZoom = zoomTarget = newZoom; + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); if (!zoomedContentWidthCache.IsValid) updateZoomedContentWidth(); @@ -121,7 +142,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (e.AltPressed) { // zoom when holding alt. - setZoomTarget(zoomTarget + e.ScrollDelta.Y, zoomedContent.ToLocalSpace(e.ScreenSpaceMousePosition).X); + AdjustZoomRelatively(e.ScrollDelta.Y, zoomedContent.ToLocalSpace(e.ScreenSpaceMousePosition).X); return true; } @@ -135,16 +156,28 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void updateZoomedContentWidth() { - zoomedContent.Width = DrawWidth * currentZoom; + zoomedContent.Width = DrawWidth * CurrentZoom; zoomedContentWidthCache.Validate(); } + public void AdjustZoomRelatively(float change, float? focusPoint = null) + { + if (!isZoomSetUp) + return; + + const float zoom_change_sensitivity = 0.02f; + + setZoomTarget(zoomTarget + change * (maxZoom - minZoom) * zoom_change_sensitivity, focusPoint); + } + private float zoomTarget = 1; - private void setZoomTarget(float newZoom, float focusPoint) + private void setZoomTarget(float newZoom, float? focusPoint = null) { - zoomTarget = Math.Clamp(newZoom, MinZoom, MaxZoom); - transformZoomTo(zoomTarget, focusPoint, ZoomDuration, ZoomEasing); + zoomTarget = Math.Clamp(newZoom, minZoom, maxZoom); + focusPoint ??= zoomedContent.ToLocalSpace(ToScreenSpace(new Vector2(DrawWidth / 2, 0))).X; + + transformZoomTo(zoomTarget, focusPoint.Value, ZoomDuration, ZoomEasing); OnZoomChanged(); } @@ -177,7 +210,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private readonly float scrollOffset; /// - /// Transforms to a new value. + /// Transforms to a new value. /// /// The focus point in absolute coordinates local to the content. /// The size of the content. @@ -189,7 +222,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline this.scrollOffset = scrollOffset; } - public override string TargetMember => nameof(currentZoom); + public override string TargetMember => nameof(CurrentZoom); private float valueAt(double time) { @@ -207,7 +240,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline float expectedWidth = d.DrawWidth * newZoom; float targetOffset = expectedWidth * (focusPoint / contentSize) - focusOffset; - d.currentZoom = newZoom; + d.CurrentZoom = newZoom; d.updateZoomedContentWidth(); // Temporarily here to make sure ScrollTo gets the correct DrawSize for scrollable area. @@ -216,7 +249,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline d.ScrollTo(targetOffset, false); } - protected override void ReadIntoStartValue(ZoomableScrollContainer d) => StartValue = d.currentZoom; + protected override void ReadIntoStartValue(ZoomableScrollContainer d) => StartValue = d.CurrentZoom; } } } diff --git a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs index 2cde962b12..dc026f7eac 100644 --- a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs +++ b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; @@ -9,6 +11,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Platform; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Extensions; using osu.Game.IO.Serialization; using osu.Game.Rulesets; @@ -17,7 +20,7 @@ using osu.Game.Screens.Edit.Compose.Components.Timeline; namespace osu.Game.Screens.Edit.Compose { - public class ComposeScreen : EditorScreenWithTimeline + public partial class ComposeScreen : EditorScreenWithTimeline, IGameplaySettings { [Resolved] private GameHost host { get; set; } @@ -25,6 +28,9 @@ namespace osu.Game.Screens.Edit.Compose [Resolved] private EditorClock clock { get; set; } + [Resolved] + private IGameplaySettings globalGameplaySettings { get; set; } + private Bindable clipboard { get; set; } private HitObjectComposer composer; @@ -87,7 +93,7 @@ namespace osu.Game.Screens.Edit.Compose if (composer == null) return; - EditorBeatmap.SelectedHitObjects.BindCollectionChanged((_, __) => updateClipboardActionAvailability()); + EditorBeatmap.SelectedHitObjects.BindCollectionChanged((_, _) => updateClipboardActionAvailability()); clipboard.BindValueChanged(_ => updateClipboardActionAvailability()); composer.OnLoadComplete += _ => updateClipboardActionAvailability(); updateClipboardActionAvailability(); @@ -146,7 +152,7 @@ namespace osu.Game.Screens.Edit.Compose if (composer == null) return string.Empty; - double displayTime = EditorBeatmap.SelectedHitObjects.OrderBy(h => h.StartTime).FirstOrDefault()?.StartTime ?? clock.CurrentTime; + double displayTime = EditorBeatmap.SelectedHitObjects.MinBy(h => h.StartTime)?.StartTime ?? clock.CurrentTime; string selectionAsString = composer.ConvertSelectionToString(); return !string.IsNullOrEmpty(selectionAsString) @@ -155,5 +161,12 @@ namespace osu.Game.Screens.Edit.Compose } #endregion + + // Combo colour normalisation should not be applied in the editor. + // Note this doesn't affect editor test mode. + IBindable IGameplaySettings.ComboColourNormalisationAmount => new Bindable(); + + // Arguable. + IBindable IGameplaySettings.PositionalHitsoundsLevel => globalGameplaySettings.PositionalHitsoundsLevel; } } diff --git a/osu.Game/Screens/Edit/Compose/HitObjectUsageEventBuffer.cs b/osu.Game/Screens/Edit/Compose/HitObjectUsageEventBuffer.cs index 621c901fb9..3df5139924 100644 --- a/osu.Game/Screens/Edit/Compose/HitObjectUsageEventBuffer.cs +++ b/osu.Game/Screens/Edit/Compose/HitObjectUsageEventBuffer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; diff --git a/osu.Game/Screens/Edit/Compose/IPlacementHandler.cs b/osu.Game/Screens/Edit/Compose/IPlacementHandler.cs index 59eb13cae5..46d9555e0c 100644 --- a/osu.Game/Screens/Edit/Compose/IPlacementHandler.cs +++ b/osu.Game/Screens/Edit/Compose/IPlacementHandler.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Game.Rulesets.Objects; diff --git a/osu.Game/Screens/Edit/CreateNewDifficultyDialog.cs b/osu.Game/Screens/Edit/CreateNewDifficultyDialog.cs index aa6ca280ee..811da5236e 100644 --- a/osu.Game/Screens/Edit/CreateNewDifficultyDialog.cs +++ b/osu.Game/Screens/Edit/CreateNewDifficultyDialog.cs @@ -3,10 +3,11 @@ using osu.Framework.Graphics.Sprites; using osu.Game.Overlays.Dialog; +using osu.Game.Localisation; namespace osu.Game.Screens.Edit { - public class CreateNewDifficultyDialog : PopupDialog + public partial class CreateNewDifficultyDialog : PopupDialog { /// /// Delegate used to create new difficulties. @@ -18,7 +19,7 @@ namespace osu.Game.Screens.Edit public CreateNewDifficultyDialog(CreateNewDifficulty createNewDifficulty) { - HeaderText = "Would you like to create a blank difficulty?"; + HeaderText = EditorDialogsStrings.NewDifficultyDialogHeader; Icon = FontAwesome.Regular.Clone; @@ -26,17 +27,17 @@ namespace osu.Game.Screens.Edit { new PopupDialogOkButton { - Text = "Yeah, let's start from scratch!", + Text = EditorDialogsStrings.CreateNew, Action = () => createNewDifficulty.Invoke(false) }, new PopupDialogCancelButton { - Text = "No, create an exact copy of this difficulty", + Text = EditorDialogsStrings.CreateCopy, Action = () => createNewDifficulty.Invoke(true) }, new PopupDialogCancelButton { - Text = "I changed my mind, I want to keep editing this difficulty", + Text = EditorDialogsStrings.KeepEditing, Action = () => { } } }; diff --git a/osu.Game/Screens/Edit/DeleteDifficultyConfirmationDialog.cs b/osu.Game/Screens/Edit/DeleteDifficultyConfirmationDialog.cs new file mode 100644 index 0000000000..8556949528 --- /dev/null +++ b/osu.Game/Screens/Edit/DeleteDifficultyConfirmationDialog.cs @@ -0,0 +1,18 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Beatmaps; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Screens.Edit +{ + public partial class DeleteDifficultyConfirmationDialog : DangerousActionDialog + { + public DeleteDifficultyConfirmationDialog(BeatmapInfo beatmapInfo, Action deleteAction) + { + BodyText = $"\"{beatmapInfo.DifficultyName}\" difficulty"; + DangerousAction = deleteAction; + } + } +} diff --git a/osu.Game/Screens/Edit/Design/DesignScreen.cs b/osu.Game/Screens/Edit/Design/DesignScreen.cs index f15639733c..c36afcdb6d 100644 --- a/osu.Game/Screens/Edit/Design/DesignScreen.cs +++ b/osu.Game/Screens/Edit/Design/DesignScreen.cs @@ -3,7 +3,7 @@ namespace osu.Game.Screens.Edit.Design { - public class DesignScreen : EditorScreen + public partial class DesignScreen : EditorScreen { public DesignScreen() : base(EditorScreenMode.Design) diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 1414644a54..d89392f757 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using JetBrains.Annotations; using osu.Framework; using osu.Framework.Allocation; +using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -16,9 +18,12 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Input; 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; using osu.Framework.Timing; using osu.Game.Audio; using osu.Game.Beatmaps; @@ -28,15 +33,16 @@ using osu.Game.Database; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; -using osu.Game.Resources.Localisation.Web; +using osu.Game.Overlays.OSD; using osu.Game.Rulesets; using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Objects; using osu.Game.Screens.Edit.Components.Menus; using osu.Game.Screens.Edit.Compose; +using osu.Game.Screens.Edit.Compose.Components.Timeline; using osu.Game.Screens.Edit.Design; using osu.Game.Screens.Edit.GameplayTest; using osu.Game.Screens.Edit.Setup; @@ -44,14 +50,14 @@ using osu.Game.Screens.Edit.Timing; using osu.Game.Screens.Edit.Verify; using osu.Game.Screens.Play; using osu.Game.Users; -using osuTK.Graphics; using osuTK.Input; +using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; namespace osu.Game.Screens.Edit { [Cached(typeof(IBeatSnapProvider))] [Cached] - public class Editor : ScreenWithBeatmapBackground, IKeyBindingHandler, IKeyBindingHandler, IBeatSnapProvider, ISamplePlaybackDisabler, IBeatSyncProvider + public partial class Editor : ScreenWithBeatmapBackground, IKeyBindingHandler, IKeyBindingHandler, IBeatSnapProvider, ISamplePlaybackDisabler, IBeatSyncProvider { public override float BackgroundParallaxAmount => 0.1f; @@ -63,6 +69,8 @@ namespace osu.Game.Screens.Edit public override bool? AllowTrackAdjustments => false; + protected override bool PlayExitSound => !ExitConfirmed && !switchingDifficulty; + protected bool HasUnsavedChanges { get @@ -93,12 +101,38 @@ namespace osu.Game.Screens.Edit public IBindable SamplePlaybackDisabled => samplePlaybackDisabled; + /// + /// Ensure all asynchronously loading pieces of the editor are in a good state. + /// This exists here for convenience for tests, not for actual use. + /// Eventually we'd probably want a better way to signal this. + /// + public bool ReadyForUse + { + get + { + if (!workingBeatmapUpdated) + return false; + + if (currentScreen?.IsLoaded != true) + return false; + + if (currentScreen is EditorScreenWithTimeline) + return currentScreen.ChildrenOfType().FirstOrDefault()?.IsLoaded == true; + + return true; + } + } + + private bool workingBeatmapUpdated; + private readonly Bindable samplePlaybackDisabled = new Bindable(); private bool canSave; protected bool ExitConfirmed { get; private set; } + private bool switchingDifficulty; + private string lastSavedHash; private Container screenContainer; @@ -123,7 +157,16 @@ namespace osu.Game.Screens.Edit private bool isNewBeatmap; - protected override UserActivity InitialActivity => new UserActivity.Editing(Beatmap.Value.BeatmapInfo); + protected override UserActivity InitialActivity + { + get + { + if (Beatmap.Value.Metadata.Author.OnlineID == api.LocalUser.Value.OnlineID) + return new UserActivity.EditingBeatmap(Beatmap.Value.BeatmapInfo); + + return new UserActivity.ModdingBeatmap(Beatmap.Value.BeatmapInfo); + } + } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); @@ -137,6 +180,13 @@ namespace osu.Game.Screens.Edit [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + [Resolved(canBeNull: true)] + private OnScreenDisplay onScreenDisplay { get; set; } + + private Bindable editorBackgroundDim; + private Bindable editorHitMarkers; + private Bindable editorAutoSeekOnPlacement; + public Editor(EditorLoader loader = null) { this.loader = loader; @@ -154,7 +204,7 @@ namespace osu.Game.Screens.Edit loadableBeatmap = beatmapManager.CreateNew(Ruleset.Value, api.LocalUser.Value); // required so we can get the track length in EditorClock. - // this is safe as nothing has yet got a reference to this new beatmap. + // this is ONLY safe because the track being provided is a `TrackVirtual` which we don't really care about disposing. loadableBeatmap.LoadTrack(); // this is a bit haphazard, but guards against setting the lease Beatmap bindable if @@ -180,7 +230,7 @@ namespace osu.Game.Screens.Edit } // Todo: should probably be done at a DrawableRuleset level to share logic with Player. - clock = new EditorClock(playableBeatmap, beatDivisor) { IsCoupled = false }; + clock = new EditorClock(playableBeatmap, beatDivisor); clock.ChangeSource(loadableBeatmap.Track); dependencies.CacheAs(clock); @@ -194,11 +244,13 @@ namespace osu.Game.Screens.Edit AddInternal(editorBeatmap = new EditorBeatmap(playableBeatmap, loadableBeatmap.GetSkin(), loadableBeatmap.BeatmapInfo)); dependencies.CacheAs(editorBeatmap); + editorBeatmap.UpdateInProgress.BindValueChanged(_ => updateSampleDisabledState()); + canSave = editorBeatmap.BeatmapInfo.Ruleset.CreateInstance() is ILegacyRuleset; if (canSave) { - changeHandler = new EditorChangeHandler(editorBeatmap); + changeHandler = new BeatmapEditorChangeHandler(editorBeatmap); dependencies.CacheAs(changeHandler); } @@ -213,11 +265,16 @@ namespace osu.Game.Screens.Edit // this assumes that nothing during the rest of this load() method is accessing Beatmap.Value (loadableBeatmap should be preferred). // generally this is quite safe, as the actual load of editor content comes after menuBar.Mode.ValueChanged is fired in its own LoadComplete. Beatmap.Value = loadableBeatmap; + workingBeatmapUpdated = true; }); OsuMenuItem undoMenuItem; OsuMenuItem redoMenuItem; + editorBackgroundDim = config.GetBindable(OsuSetting.EditorDim); + editorHitMarkers = config.GetBindable(OsuSetting.EditorShowHitMarkers); + editorAutoSeekOnPlacement = config.GetBindable(OsuSetting.EditorAutoSeekOnPlacement); + AddInternal(new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, @@ -248,27 +305,44 @@ namespace osu.Game.Screens.Edit RelativeSizeAxes = Axes.Both, Items = new[] { - new MenuItem("File") + new MenuItem(CommonStrings.MenuBarFile) { Items = createFileMenuItems() }, - new MenuItem(CommonStrings.ButtonsEdit) + new MenuItem(CommonStrings.MenuBarEdit) { Items = new[] { - undoMenuItem = new EditorMenuItem("Undo", MenuItemType.Standard, Undo), - redoMenuItem = new EditorMenuItem("Redo", MenuItemType.Standard, Redo), + undoMenuItem = new EditorMenuItem(CommonStrings.Undo, MenuItemType.Standard, Undo), + redoMenuItem = new EditorMenuItem(CommonStrings.Redo, MenuItemType.Standard, Redo), new EditorMenuItemSpacer(), - cutMenuItem = new EditorMenuItem("Cut", MenuItemType.Standard, Cut), - copyMenuItem = new EditorMenuItem("Copy", MenuItemType.Standard, Copy), - pasteMenuItem = new EditorMenuItem("Paste", MenuItemType.Standard, Paste), + cutMenuItem = new EditorMenuItem(CommonStrings.Cut, MenuItemType.Standard, Cut), + copyMenuItem = new EditorMenuItem(CommonStrings.Copy, MenuItemType.Standard, Copy), + pasteMenuItem = new EditorMenuItem(CommonStrings.Paste, MenuItemType.Standard, Paste), + cloneMenuItem = new EditorMenuItem(CommonStrings.Clone, MenuItemType.Standard, Clone), } }, - new MenuItem("View") + new MenuItem(CommonStrings.MenuBarView) { Items = new MenuItem[] { new WaveformOpacityMenuItem(config.GetBindable(OsuSetting.EditorWaveformOpacity)), + new BackgroundDimMenuItem(editorBackgroundDim), + new ToggleMenuItem(EditorStrings.ShowHitMarkers) + { + State = { BindTarget = editorHitMarkers }, + }, + new ToggleMenuItem(EditorStrings.AutoSeekOnPlacement) + { + State = { BindTarget = editorAutoSeekOnPlacement }, + } + } + }, + new MenuItem(EditorStrings.Timing) + { + Items = new MenuItem[] + { + new EditorMenuItem(EditorStrings.SetPreviewPointToCurrent, MenuItemType.Standard, SetPreviewPointToCurrentTime) } } } @@ -285,11 +359,15 @@ namespace osu.Game.Screens.Edit bottomBar = new BottomBar(), } }); - changeHandler?.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true); changeHandler?.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true); + + editorBackgroundDim.BindValueChanged(_ => dimBackground()); } + [Resolved] + private MusicController musicController { get; set; } + protected override void LoadComplete() { base.LoadComplete(); @@ -297,12 +375,18 @@ namespace osu.Game.Screens.Edit Mode.Value = isNewBeatmap ? EditorScreenMode.SongSetup : EditorScreenMode.Compose; Mode.BindValueChanged(onModeChanged, true); + + musicController.TrackChanged += onTrackChanged; } - /// - /// If the beatmap's track has changed, this method must be called to keep the editor in a valid state. - /// - public void UpdateClockSource() => clock.ChangeSource(Beatmap.Value.Track); + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + musicController.TrackChanged -= onTrackChanged; + } + + private void onTrackChanged(WorkingBeatmap working, TrackChangeDirection direction) => clock.ChangeSource(working.Track); /// /// Creates an instance representing the current state of the editor. @@ -372,6 +456,7 @@ namespace osu.Game.Screens.Edit // no longer new after first user-triggered save. isNewBeatmap = false; updateLastSavedHash(); + onScreenDisplay?.Display(new BeatmapEditorToast(ToastStrings.BeatmapSaved, editorBeatmap.BeatmapInfo.GetDisplayTitle())); return true; } @@ -422,6 +507,8 @@ namespace osu.Game.Screens.Edit protected override bool OnKeyDown(KeyDownEvent e) { + if (e.ControlPressed || e.AltPressed || e.SuperPressed) return false; + switch (e.Key) { case Key.Left: @@ -432,6 +519,15 @@ namespace osu.Game.Screens.Edit seek(e, 1); return true; + // Of those, these two keys are reversed from stable because it feels more natural (and matches mouse wheel scroll directionality). + case Key.Up: + seekControlPoint(-1); + return true; + + case Key.Down: + seekControlPoint(1); + return true; + // Track traversal keys. // Matching osu-stable implementations. case Key.Z: @@ -462,12 +558,14 @@ namespace osu.Game.Screens.Edit // Seek to last object time, or track end if already there. // Note that in osu-stable subsequent presses when at track end won't return to last object. // This has intentionally been changed to make it more useful. - double? lastObjectTime = editorBeatmap.HitObjects.LastOrDefault()?.GetEndTime(); - - if (lastObjectTime == null || clock.CurrentTime == lastObjectTime) + if (!editorBeatmap.HitObjects.Any()) + { clock.Seek(clock.TrackLength); - else - clock.Seek(lastObjectTime.Value); + return true; + } + + double lastObjectTime = editorBeatmap.GetLastObjectTime(); + clock.Seek(clock.CurrentTime == lastObjectTime ? clock.TrackLength : lastObjectTime); return true; } @@ -521,6 +619,10 @@ namespace osu.Game.Screens.Edit this.Exit(); return true; + case GlobalAction.EditorCloneSelection: + Clone(); + return true; + case GlobalAction.EditorComposeMode: Mode.Value = EditorScreenMode.Compose; return true; @@ -571,10 +673,8 @@ namespace osu.Game.Screens.Edit { ApplyToBackground(b => { - // todo: temporary. we want to be applying dim using the UserDimContainer eventually. - b.FadeColour(Color4.DarkGray, 500); - b.IgnoreUserSettings.Value = true; + b.DimWhenUserSettingsIgnored.Value = editorBackgroundDim.Value; b.BlurAmount.Value = 0; }); } @@ -596,13 +696,17 @@ namespace osu.Game.Screens.Edit if (isNewBeatmap || HasUnsavedChanges) { - samplePlaybackDisabled.Value = true; + updateSampleDisabledState(); dialogOverlay?.Push(new PromptForSaveDialog(confirmExit, confirmExitWithSave, cancelExit)); return true; } } - ApplyToBackground(b => b.FadeColour(Color4.White, 500)); + ApplyToBackground(b => + { + b.DimWhenUserSettingsIgnored.Value = 0; + }); + resetTrack(); refetchBeatmap(); @@ -622,13 +726,11 @@ namespace osu.Game.Screens.Edit // To update the game-wide beatmap with any changes, perform a re-fetch on exit/suspend. // This is required as the editor makes its local changes via EditorBeatmap // (which are not propagated outwards to a potentially cached WorkingBeatmap). - ((IWorkingBeatmapCache)beatmapManager).Invalidate(Beatmap.Value.BeatmapInfo); - var refetchedBeatmapInfo = beatmapManager.QueryBeatmap(b => b.ID == Beatmap.Value.BeatmapInfo.ID); - var refetchedBeatmap = beatmapManager.GetWorkingBeatmap(refetchedBeatmapInfo); + var refetchedBeatmap = beatmapManager.GetWorkingBeatmap(Beatmap.Value.BeatmapInfo, true); if (!(refetchedBeatmap is DummyWorkingBeatmap)) { - Logger.Log("Editor providing re-fetched beatmap post edit session"); + Logger.Log(@"Editor providing re-fetched beatmap post edit session"); Beatmap.Value = refetchedBeatmap; } } @@ -668,6 +770,7 @@ namespace osu.Game.Screens.Edit private EditorMenuItem cutMenuItem; private EditorMenuItem copyMenuItem; + private EditorMenuItem cloneMenuItem; private EditorMenuItem pasteMenuItem; private readonly BindableWithCurrent canCut = new BindableWithCurrent(); @@ -677,7 +780,11 @@ namespace osu.Game.Screens.Edit private void setUpClipboardActionAvailability() { canCut.Current.BindValueChanged(cut => cutMenuItem.Action.Disabled = !cut.NewValue, true); - canCopy.Current.BindValueChanged(copy => copyMenuItem.Action.Disabled = !copy.NewValue, true); + canCopy.Current.BindValueChanged(copy => + { + copyMenuItem.Action.Disabled = !copy.NewValue; + cloneMenuItem.Action.Disabled = !copy.NewValue; + }, true); canPaste.Current.BindValueChanged(paste => pasteMenuItem.Action.Disabled = !paste.NewValue, true); } @@ -692,6 +799,21 @@ namespace osu.Game.Screens.Edit protected void Copy() => currentScreen?.Copy(); + protected void Clone() + { + // Avoid attempting to clone if copying is not available (as it may result in pasting something unexpected). + if (!canCopy.Value) + return; + + // This is an initial implementation just to get an idea of how people used this function. + // There are a couple of differences from osu!stable's implementation which will require more work to match: + // - The "clipboard" is not populated during the duplication process. + // - The duplicated hitobjects are inserted after the original pattern (add one beat_length and then quantize using beat snap). + // - The duplicated hitobjects are selected (but this is also applied for all paste operations so should be changed there). + Copy(); + Paste(); + } + protected void Paste() => currentScreen?.Paste(); #endregion @@ -700,6 +822,11 @@ namespace osu.Game.Screens.Edit protected void Redo() => changeHandler?.RestoreState(1); + protected void SetPreviewPointToCurrentTime() + { + editorBeatmap.PreviewTime.Value = (int)clock.CurrentTime; + } + private void resetTrack(bool seekToStart = false) { Beatmap.Value.Track.Stop(); @@ -777,9 +904,38 @@ namespace osu.Game.Screens.Edit } } + [CanBeNull] + private ScheduledDelegate playbackDisabledDebounce; + private void updateSampleDisabledState() { - samplePlaybackDisabled.Value = clock.SeekingOrStopped.Value || !(currentScreen is ComposeScreen); + bool shouldDisableSamples = clock.SeekingOrStopped.Value + || currentScreen is not ComposeScreen + || editorBeatmap.UpdateInProgress.Value + || dialogOverlay?.CurrentDialog != null; + + playbackDisabledDebounce?.Cancel(); + + if (shouldDisableSamples) + { + samplePlaybackDisabled.Value = true; + } + else + { + // Debounce re-enabling arbitrarily high enough to avoid flip-flopping during beatmap updates + // or rapid user seeks. + playbackDisabledDebounce = Scheduler.AddDelayed(() => samplePlaybackDisabled.Value = false, 50); + } + } + + private void seekControlPoint(int direction) + { + var found = direction < 1 + ? editorBeatmap.ControlPointInfo.AllControlPoints.LastOrDefault(p => p.Time < clock.CurrentTime) + : editorBeatmap.ControlPointInfo.AllControlPoints.FirstOrDefault(p => p.Time > clock.CurrentTime); + + if (found != null) + clock.Seek(found.Time); } private void seek(UIEvent e, int direction) @@ -790,10 +946,11 @@ namespace osu.Game.Screens.Edit if (trackPlaying) { - // generally users are not looking to perform tiny seeks when the track is playing, - // so seeks should always be by one full beat, bypassing the beatDivisor. + // generally users are not looking to perform tiny seeks when the track is playing. // this multiplication undoes the division that will be applied in the underlying seek operation. - amount *= beatDivisor.Value; + // scale by BPM to keep the seek amount constant across all BPMs. + var timingPoint = editorBeatmap.ControlPointInfo.TimingPointAt(clock.CurrentTimeAccurate); + amount *= beatDivisor.Value * (timingPoint.BPM / 120); } if (direction < 1) @@ -802,35 +959,61 @@ namespace osu.Game.Screens.Edit clock.SeekForward(!trackPlaying, amount); } + private void updateLastSavedHash() + { + lastSavedHash = changeHandler?.CurrentStateHash; + } + + private List createFileMenuItems() => new List + { + new EditorMenuItem(WebCommonStrings.ButtonsSave, MenuItemType.Standard, () => Save()), + new EditorMenuItem(EditorStrings.ExportPackage, MenuItemType.Standard, exportBeatmap) { Action = { Disabled = !RuntimeInfo.IsDesktop } }, + new EditorMenuItemSpacer(), + createDifficultyCreationMenu(), + createDifficultySwitchMenu(), + new EditorMenuItemSpacer(), + new EditorMenuItem(EditorStrings.DeleteDifficulty, MenuItemType.Standard, deleteDifficulty) { Action = { Disabled = Beatmap.Value.BeatmapSetInfo.Beatmaps.Count < 2 } }, + new EditorMenuItemSpacer(), + new EditorMenuItem(CommonStrings.Exit, MenuItemType.Standard, this.Exit) + }; + private void exportBeatmap() { Save(); new LegacyBeatmapExporter(storage).Export(Beatmap.Value.BeatmapSetInfo); } - private void updateLastSavedHash() - { - lastSavedHash = changeHandler?.CurrentStateHash; - } + /// + /// Beatmaps of the currently edited set, grouped by ruleset and ordered by difficulty. + /// + private IOrderedEnumerable> groupedOrderedBeatmaps => Beatmap.Value.BeatmapSetInfo.Beatmaps + .OrderBy(b => b.StarRating) + .GroupBy(b => b.Ruleset) + .OrderBy(group => group.Key); - private List createFileMenuItems() + private void deleteDifficulty() { - var fileMenuItems = new List + if (dialogOverlay == null) + delete(); + else + dialogOverlay.Push(new DeleteDifficultyConfirmationDialog(Beatmap.Value.BeatmapInfo, delete)); + + void delete() { - new EditorMenuItem("Save", MenuItemType.Standard, () => Save()) - }; + BeatmapInfo difficultyToDelete = playableBeatmap.BeatmapInfo; - if (RuntimeInfo.IsDesktop) - fileMenuItems.Add(new EditorMenuItem("Export package", MenuItemType.Standard, exportBeatmap)); + var difficultiesBeforeDeletion = groupedOrderedBeatmaps.SelectMany(g => g).ToList(); - fileMenuItems.Add(new EditorMenuItemSpacer()); + beatmapManager.DeleteDifficultyImmediately(difficultyToDelete); - fileMenuItems.Add(createDifficultyCreationMenu()); - fileMenuItems.Add(createDifficultySwitchMenu()); + int deletedIndex = difficultiesBeforeDeletion.IndexOf(difficultyToDelete); + // of note, we're still working with the cloned version, so indices are all prior to deletion. + BeatmapInfo nextToShow = difficultiesBeforeDeletion[deletedIndex == 0 ? 1 : deletedIndex - 1]; - fileMenuItems.Add(new EditorMenuItemSpacer()); - fileMenuItems.Add(new EditorMenuItem("Exit", MenuItemType.Standard, this.Exit)); - return fileMenuItems; + Beatmap.Value = beatmapManager.GetWorkingBeatmap(nextToShow); + + SwitchToDifficulty(nextToShow); + } } private EditorMenuItem createDifficultyCreationMenu() @@ -840,7 +1023,7 @@ namespace osu.Game.Screens.Edit foreach (var ruleset in rulesets.AvailableRulesets) rulesetItems.Add(new EditorMenuItem(ruleset.Name, MenuItemType.Standard, () => CreateNewDifficulty(ruleset))); - return new EditorMenuItem("Create new difficulty") { Items = rulesetItems }; + return new EditorMenuItem(EditorStrings.CreateNewDifficulty) { Items = rulesetItems }; } protected void CreateNewDifficulty(RulesetInfo rulesetInfo) @@ -855,36 +1038,35 @@ namespace osu.Game.Screens.Edit } private void switchToNewDifficulty(RulesetInfo rulesetInfo, bool createCopy) - => loader?.ScheduleSwitchToNewDifficulty(editorBeatmap.BeatmapInfo, rulesetInfo, createCopy, GetState(rulesetInfo)); + { + switchingDifficulty = true; + loader?.ScheduleSwitchToNewDifficulty(editorBeatmap.BeatmapInfo, rulesetInfo, createCopy, GetState(rulesetInfo)); + } private EditorMenuItem createDifficultySwitchMenu() { - var beatmapSet = playableBeatmap.BeatmapInfo.BeatmapSet; - - Debug.Assert(beatmapSet != null); - var difficultyItems = new List(); - foreach (var rulesetBeatmaps in beatmapSet.Beatmaps.GroupBy(b => b.Ruleset).OrderBy(group => group.Key)) + foreach (var rulesetBeatmaps in groupedOrderedBeatmaps) { if (difficultyItems.Count > 0) difficultyItems.Add(new EditorMenuItemSpacer()); - foreach (var beatmap in rulesetBeatmaps.OrderBy(b => b.StarRating)) + foreach (var beatmap in rulesetBeatmaps) { bool isCurrentDifficulty = playableBeatmap.BeatmapInfo.Equals(beatmap); difficultyItems.Add(new DifficultyMenuItem(beatmap, isCurrentDifficulty, SwitchToDifficulty)); } } - return new EditorMenuItem("Change difficulty") { Items = difficultyItems }; + return new EditorMenuItem(EditorStrings.ChangeDifficulty) { Items = difficultyItems }; } protected void SwitchToDifficulty(BeatmapInfo nextBeatmap) => loader?.ScheduleSwitchToExistingDifficulty(nextBeatmap, GetState(nextBeatmap.Ruleset)); private void cancelExit() { - samplePlaybackDisabled.Value = false; + updateSampleDisabledState(); loader?.CancelPendingDifficultySwitch(); } @@ -896,6 +1078,14 @@ namespace osu.Game.Screens.Edit ControlPointInfo IBeatSyncProvider.ControlPoints => editorBeatmap.ControlPointInfo; IClock IBeatSyncProvider.Clock => clock; - ChannelAmplitudes? IBeatSyncProvider.Amplitudes => Beatmap.Value.TrackLoaded ? Beatmap.Value.Track.CurrentAmplitudes : (ChannelAmplitudes?)null; + ChannelAmplitudes IHasAmplitudes.CurrentAmplitudes => Beatmap.Value.TrackLoaded ? Beatmap.Value.Track.CurrentAmplitudes : ChannelAmplitudes.Empty; + + private partial class BeatmapEditorToast : Toast + { + public BeatmapEditorToast(LocalisableString value, string beatmapDisplayName) + : base(InputSettingsStrings.EditorSection, value, beatmapDisplayName) + { + } + } } } diff --git a/osu.Game/Screens/Edit/EditorBeatmap.cs b/osu.Game/Screens/Edit/EditorBeatmap.cs index c9449f3259..dc1fda13f4 100644 --- a/osu.Game/Screens/Edit/EditorBeatmap.cs +++ b/osu.Game/Screens/Edit/EditorBeatmap.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections; using System.Collections.Generic; @@ -18,8 +20,19 @@ using osu.Game.Skinning; namespace osu.Game.Screens.Edit { - public class EditorBeatmap : TransactionalCommitComponent, IBeatmap, IBeatSnapProvider + public partial class EditorBeatmap : TransactionalCommitComponent, IBeatmap, IBeatSnapProvider { + /// + /// Will become true when a new update is queued, and false when all updates have been applied. + /// + /// + /// This is intended to be used to avoid performing operations (like playback of samples) + /// while mutating hitobjects. + /// + public IBindable UpdateInProgress => updateInProgress; + + private readonly BindableBool updateInProgress = new BindableBool(); + /// /// Invoked when a is added to this . /// @@ -35,6 +48,15 @@ namespace osu.Game.Screens.Edit /// public event Action HitObjectUpdated; + /// + /// Invoked after any state changes occurred which triggered a beatmap reprocess via an . + /// + /// + /// Beatmap processing may change the order of hitobjects. This event gives external components a chance to handle any changes + /// not covered by the / / events. + /// + public event Action BeatmapReprocessed; + /// /// All currently selected s. /// @@ -64,6 +86,8 @@ namespace osu.Game.Screens.Edit [Resolved] private EditorClock editorClock { get; set; } + public BindableInt PreviewTime { get; } + private readonly IBeatmapProcessor beatmapProcessor; private readonly Dictionary> startTimeBindables = new Dictionary>(); @@ -71,47 +95,67 @@ namespace osu.Game.Screens.Edit public EditorBeatmap(IBeatmap playableBeatmap, ISkin beatmapSkin = null, BeatmapInfo beatmapInfo = null) { PlayableBeatmap = playableBeatmap; - - // ensure we are not working with legacy control points. - // if we leave the legacy points around they will be applied over any local changes on - // ApplyDefaults calls. this should eventually be removed once the default logic is moved to the decoder/converter. - if (PlayableBeatmap.ControlPointInfo is LegacyControlPointInfo) - { - var newControlPoints = new ControlPointInfo(); - - foreach (var controlPoint in PlayableBeatmap.ControlPointInfo.AllControlPoints) - { - switch (controlPoint) - { - case DifficultyControlPoint _: - case SampleControlPoint _: - // skip legacy types. - continue; - - default: - newControlPoints.Add(controlPoint.Time, controlPoint); - break; - } - } - - playableBeatmap.ControlPointInfo = newControlPoints; - } + PlayableBeatmap.ControlPointInfo = ConvertControlPoints(PlayableBeatmap.ControlPointInfo); this.beatmapInfo = beatmapInfo ?? playableBeatmap.BeatmapInfo; if (beatmapSkin is Skin skin) + { BeatmapSkin = new EditorBeatmapSkin(skin); + BeatmapSkin.BeatmapSkinChanged += SaveState; + } beatmapProcessor = playableBeatmap.BeatmapInfo.Ruleset.CreateInstance().CreateBeatmapProcessor(PlayableBeatmap); foreach (var obj in HitObjects) trackStartTime(obj); + + PreviewTime = new BindableInt(BeatmapInfo.Metadata.PreviewTime); + PreviewTime.BindValueChanged(s => + { + BeginChange(); + BeatmapInfo.Metadata.PreviewTime = s.NewValue; + EndChange(); + }); + } + + /// + /// Converts a such that the resultant is non-legacy. + /// + /// The to convert. + /// The non-legacy . is returned if already non-legacy. + public static ControlPointInfo ConvertControlPoints(ControlPointInfo incoming) + { + // ensure we are not working with legacy control points. + // if we leave the legacy points around they will be applied over any local changes on + // ApplyDefaults calls. this should eventually be removed once the default logic is moved to the decoder/converter. + if (!(incoming is LegacyControlPointInfo)) + return incoming; + + var newControlPoints = new ControlPointInfo(); + + foreach (var controlPoint in incoming.AllControlPoints) + { + switch (controlPoint) + { + case DifficultyControlPoint: + case SampleControlPoint: + // skip legacy types. + continue; + + default: + newControlPoints.Add(controlPoint.Time, controlPoint); + break; + } + } + + return newControlPoints; } public BeatmapInfo BeatmapInfo { get => beatmapInfo; - set => throw new InvalidOperationException(); + set => throw new InvalidOperationException($"Can't set {nameof(BeatmapInfo)} on {nameof(EditorBeatmap)}"); } public BeatmapMetadata Metadata => beatmapInfo.Metadata; @@ -214,6 +258,8 @@ namespace osu.Game.Screens.Edit { // updates are debounced regardless of whether a batch is active. batchPendingUpdates.Add(hitObject); + + updateInProgress.Value = true; } /// @@ -223,6 +269,8 @@ namespace osu.Game.Screens.Edit { foreach (var h in HitObjects) batchPendingUpdates.Add(h); + + updateInProgress.Value = true; } /// @@ -266,7 +314,7 @@ namespace osu.Game.Screens.Edit /// The index of the to remove. public void RemoveAt(int index) { - var hitObject = (HitObject)mutableHitObjects[index]; + HitObject hitObject = (HitObject)mutableHitObjects[index]!; mutableHitObjects.RemoveAt(index); @@ -302,6 +350,8 @@ namespace osu.Game.Screens.Edit beatmapProcessor?.PostProcess(); + BeatmapReprocessed?.Invoke(); + // callbacks may modify the lists so let's be safe about it var deletes = batchPendingDeletes.ToArray(); batchPendingDeletes.Clear(); @@ -312,9 +362,13 @@ namespace osu.Game.Screens.Edit var updates = batchPendingUpdates.ToArray(); batchPendingUpdates.Clear(); + foreach (var h in deletes) SelectedHitObjects.Remove(h); + foreach (var h in deletes) HitObjectRemoved?.Invoke(h); foreach (var h in inserts) HitObjectAdded?.Invoke(h); foreach (var h in updates) HitObjectUpdated?.Invoke(h); + + updateInProgress.Value = false; } /// diff --git a/osu.Game/Screens/Edit/EditorBeatmapSkin.cs b/osu.Game/Screens/Edit/EditorBeatmapSkin.cs index f650ffa5a3..80239504d8 100644 --- a/osu.Game/Screens/Edit/EditorBeatmapSkin.cs +++ b/osu.Game/Screens/Edit/EditorBeatmapSkin.cs @@ -1,12 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Game.Audio; using osu.Game.Skinning; @@ -39,7 +40,7 @@ namespace osu.Game.Screens.Edit ComboColours = new BindableList(); if (Skin.Configuration.ComboColours != null) ComboColours.AddRange(Skin.Configuration.ComboColours.Select(c => (Colour4)c)); - ComboColours.BindCollectionChanged((_, __) => updateColours()); + ComboColours.BindCollectionChanged((_, _) => updateColours()); } private void invokeSkinChanged() => BeatmapSkinChanged?.Invoke(); @@ -52,7 +53,7 @@ namespace osu.Game.Screens.Edit #region Delegated ISkin implementation - public Drawable GetDrawableComponent(ISkinComponent component) => Skin.GetDrawableComponent(component); + public Drawable GetDrawableComponent(ISkinComponentLookup lookup) => Skin.GetDrawableComponent(lookup); public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => Skin.GetTexture(componentName, wrapModeS, wrapModeT); public ISample GetSample(ISampleInfo sampleInfo) => Skin.GetSample(sampleInfo); public IBindable GetConfig(TLookup lookup) => Skin.GetConfig(lookup); diff --git a/osu.Game/Screens/Edit/EditorChangeHandler.cs b/osu.Game/Screens/Edit/EditorChangeHandler.cs index 333c518d3a..0bb17e4c5d 100644 --- a/osu.Game/Screens/Edit/EditorChangeHandler.cs +++ b/osu.Game/Screens/Edit/EditorChangeHandler.cs @@ -5,25 +5,21 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text; using osu.Framework.Bindables; using osu.Framework.Extensions; -using osu.Game.Beatmaps.Formats; -using osu.Game.Rulesets.Objects; namespace osu.Game.Screens.Edit { /// /// Tracks changes to the . /// - public class EditorChangeHandler : TransactionalCommitComponent, IEditorChangeHandler + public abstract partial class EditorChangeHandler : TransactionalCommitComponent, IEditorChangeHandler { public readonly Bindable CanUndo = new Bindable(); public readonly Bindable CanRedo = new Bindable(); - public event Action OnStateChange; + public event Action? OnStateChange; - private readonly LegacyEditorBeatmapPatcher patcher; private readonly List savedStates = new List(); private int currentState = -1; @@ -35,32 +31,28 @@ namespace osu.Game.Screens.Edit { get { + ensureStateSaved(); + using (var stream = new MemoryStream(savedStates[currentState])) return stream.ComputeSHA2Hash(); } } - private readonly EditorBeatmap editorBeatmap; private bool isRestoring; public const int MAX_SAVED_STATES = 50; - /// - /// Creates a new . - /// - /// The to track the s of. - public EditorChangeHandler(EditorBeatmap editorBeatmap) + public override void BeginChange() { - this.editorBeatmap = editorBeatmap; + ensureStateSaved(); - editorBeatmap.TransactionBegan += BeginChange; - editorBeatmap.TransactionEnded += EndChange; - editorBeatmap.SaveStateTriggered += SaveState; + base.BeginChange(); + } - patcher = new LegacyEditorBeatmapPatcher(editorBeatmap); - - // Initial state. - SaveState(); + private void ensureStateSaved() + { + if (savedStates.Count == 0) + SaveState(); } protected override void UpdateState() @@ -70,9 +62,7 @@ namespace osu.Game.Screens.Edit using (var stream = new MemoryStream()) { - using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) - new LegacyBeatmapEncoder(editorBeatmap, editorBeatmap.BeatmapSkin).Encode(sw); - + WriteCurrentStateToStream(stream); byte[] newState = stream.ToArray(); // if the previous state is binary equal we don't need to push a new one, unless this is the initial state. @@ -111,7 +101,8 @@ namespace osu.Game.Screens.Edit isRestoring = true; - patcher.Patch(savedStates[currentState], savedStates[newState]); + ApplyStateChange(savedStates[currentState], savedStates[newState]); + currentState = newState; isRestoring = false; @@ -120,6 +111,20 @@ namespace osu.Game.Screens.Edit updateBindables(); } + /// + /// Write a serialised copy of the currently tracked state to the provided stream. + /// This will be stored as a state which can be restored in the future. + /// + /// The stream which the state should be written to. + protected abstract void WriteCurrentStateToStream(MemoryStream stream); + + /// + /// Given a previous and new state, apply any changes required to bring the current state in line with the new state. + /// + /// The previous (current before this call) serialised state. + /// The new state to be applied. + protected abstract void ApplyStateChange(byte[] previousState, byte[] newState); + private void updateBindables() { CanUndo.Value = savedStates.Count > 0 && currentState > 0; diff --git a/osu.Game/Screens/Edit/EditorClipboard.cs b/osu.Game/Screens/Edit/EditorClipboard.cs index f6f0c09e00..f749f4bad6 100644 --- a/osu.Game/Screens/Edit/EditorClipboard.cs +++ b/osu.Game/Screens/Edit/EditorClipboard.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; namespace osu.Game.Screens.Edit diff --git a/osu.Game/Screens/Edit/EditorClock.cs b/osu.Game/Screens/Edit/EditorClock.cs index 86e5729196..e5e88a04d9 100644 --- a/osu.Game/Screens/Edit/EditorClock.cs +++ b/osu.Game/Screens/Edit/EditorClock.cs @@ -1,11 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; +using System.Diagnostics; using System.Linq; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Transforms; using osu.Framework.Timing; using osu.Framework.Utils; @@ -17,13 +21,13 @@ namespace osu.Game.Screens.Edit /// /// A decoupled clock which adds editor-specific functionality, such as snapping to a user-defined beat divisor. /// - public class EditorClock : Component, IFrameBasedClock, IAdjustableClock, ISourceChangeableClock + public partial class EditorClock : CompositeComponent, IFrameBasedClock, IAdjustableClock, ISourceChangeableClock { public IBindable Track => track; private readonly Bindable track = new Bindable(); - public double TrackLength => track.Value?.Length ?? 60000; + public double TrackLength => track.Value?.IsLoaded == true ? track.Value.Length : 60000; public ControlPointInfo ControlPointInfo => Beatmap.ControlPointInfo; @@ -31,7 +35,7 @@ namespace osu.Game.Screens.Edit private readonly BindableBeatDivisor beatDivisor; - private readonly DecoupleableInterpolatingFramedClock underlyingClock; + private readonly FramedBeatmapClock underlyingClock; private bool playbackFinished; @@ -50,7 +54,8 @@ namespace osu.Game.Screens.Edit this.beatDivisor = beatDivisor ?? new BindableBeatDivisor(); - underlyingClock = new DecoupleableInterpolatingFramedClock(); + underlyingClock = new FramedBeatmapClock(applyOffsets: true) { IsCoupled = false }; + AddInternal(underlyingClock); } /// @@ -139,7 +144,7 @@ namespace osu.Game.Screens.Edit seekTime = timingPoint.Time + closestBeat * seekAmount; } - if (seekTime < timingPoint.Time && timingPoint != ControlPointInfo.TimingPoints.First()) + if (seekTime < timingPoint.Time && !ReferenceEquals(timingPoint, ControlPointInfo.TimingPoints.First())) seekTime = timingPoint.Time; SeekSmoothlyTo(seekTime); @@ -153,6 +158,8 @@ namespace osu.Game.Screens.Edit public double CurrentTime => underlyingClock.CurrentTime; + public double TotalAppliedOffset => underlyingClock.TotalAppliedOffset; + public void Reset() { ClearTransforms(); @@ -217,18 +224,7 @@ namespace osu.Game.Screens.Edit public void ProcessFrame() { - underlyingClock.ProcessFrame(); - - playbackFinished = CurrentTime >= TrackLength; - - if (playbackFinished) - { - if (IsRunning) - underlyingClock.Stop(); - - if (CurrentTime > TrackLength) - underlyingClock.Seek(TrackLength); - } + // Noop to ensure an external consumer doesn't process the internal clock an extra time. } public double ElapsedFrameTime => underlyingClock.ElapsedFrameTime; @@ -245,18 +241,26 @@ namespace osu.Game.Screens.Edit public IClock Source => underlyingClock.Source; - public bool IsCoupled - { - get => underlyingClock.IsCoupled; - set => underlyingClock.IsCoupled = value; - } - private const double transform_time = 300; protected override void Update() { base.Update(); + // EditorClock wasn't being added in many places. This gives us more certainty that it is. + Debug.Assert(underlyingClock.LoadState > LoadState.NotLoaded); + + playbackFinished = CurrentTime >= TrackLength; + + if (playbackFinished) + { + if (IsRunning) + underlyingClock.Stop(); + + if (CurrentTime > TrackLength) + underlyingClock.Seek(TrackLength); + } + updateSeekingState(); } @@ -266,7 +270,7 @@ namespace osu.Game.Screens.Edit { IsSeeking &= Transforms.Any(); - if (track.Value?.IsRunning != true) + if (!IsRunning) { // seeking in the editor can happen while the track isn't running. // in this case we always want to expose ourselves as seeking (to avoid sample playback). diff --git a/osu.Game/Screens/Edit/EditorLoader.cs b/osu.Game/Screens/Edit/EditorLoader.cs index 0a2b8437fa..f665b7c511 100644 --- a/osu.Game/Screens/Edit/EditorLoader.cs +++ b/osu.Game/Screens/Edit/EditorLoader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -23,7 +25,7 @@ namespace osu.Game.Screens.Edit /// Transition screen for the editor. /// Used to avoid backing out to main menu/song select when switching difficulties from within the editor. /// - public class EditorLoader : ScreenWithBeatmapBackground + public partial class EditorLoader : ScreenWithBeatmapBackground { /// /// The stored state from the last editor opened. @@ -63,6 +65,8 @@ namespace osu.Game.Screens.Edit base.LoadComplete(); // will be restored via lease, see `DisallowExternalBeatmapRulesetChanges`. + if (!(Beatmap.Value is DummyWorkingBeatmap)) + Ruleset.Value = Beatmap.Value.BeatmapInfo.Ruleset; Mods.Value = Array.Empty(); } diff --git a/osu.Game/Screens/Edit/EditorRoundedScreenSettings.cs b/osu.Game/Screens/Edit/EditorRoundedScreenSettings.cs index 94a83a82aa..1c083b4fab 100644 --- a/osu.Game/Screens/Edit/EditorRoundedScreenSettings.cs +++ b/osu.Game/Screens/Edit/EditorRoundedScreenSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osuTK; namespace osu.Game.Screens.Edit { - public abstract class EditorRoundedScreenSettings : CompositeDrawable + public abstract partial class EditorRoundedScreenSettings : CompositeDrawable { [BackgroundDependencyLoader] private void load(OverlayColourProvider colours) diff --git a/osu.Game/Screens/Edit/EditorRoundedScreenSettingsSection.cs b/osu.Game/Screens/Edit/EditorRoundedScreenSettingsSection.cs index 25d7dfbb4a..751b6f61d1 100644 --- a/osu.Game/Screens/Edit/EditorRoundedScreenSettingsSection.cs +++ b/osu.Game/Screens/Edit/EditorRoundedScreenSettingsSection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,7 +12,7 @@ using osuTK; namespace osu.Game.Screens.Edit { - public abstract class EditorRoundedScreenSettingsSection : CompositeDrawable + public abstract partial class EditorRoundedScreenSettingsSection : CompositeDrawable { private const int header_height = 50; diff --git a/osu.Game/Screens/Edit/EditorScreen.cs b/osu.Game/Screens/Edit/EditorScreen.cs index 31c34edd7c..069a5490bb 100644 --- a/osu.Game/Screens/Edit/EditorScreen.cs +++ b/osu.Game/Screens/Edit/EditorScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ namespace osu.Game.Screens.Edit /// /// TODO: eventually make this inherit Screen and add a local screen stack inside the Editor. /// - public abstract class EditorScreen : VisibilityContainer + public abstract partial class EditorScreen : VisibilityContainer { [Resolved] protected EditorBeatmap EditorBeatmap { get; private set; } diff --git a/osu.Game/Screens/Edit/EditorScreenMode.cs b/osu.Game/Screens/Edit/EditorScreenMode.cs index ecd39f9b57..f787fee1e0 100644 --- a/osu.Game/Screens/Edit/EditorScreenMode.cs +++ b/osu.Game/Screens/Edit/EditorScreenMode.cs @@ -1,25 +1,26 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.ComponentModel; +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Screens.Edit { public enum EditorScreenMode { - [Description("setup")] + [LocalisableDescription(typeof(EditorStrings), nameof(EditorStrings.SetupScreen))] SongSetup, - [Description("compose")] + [LocalisableDescription(typeof(EditorStrings), nameof(EditorStrings.ComposeScreen))] Compose, - [Description("design")] + [LocalisableDescription(typeof(EditorStrings), nameof(EditorStrings.DesignScreen))] Design, - [Description("timing")] + [LocalisableDescription(typeof(EditorStrings), nameof(EditorStrings.TimingScreen))] Timing, - [Description("verify")] + [LocalisableDescription(typeof(EditorStrings), nameof(EditorStrings.VerifyScreen))] Verify, } } diff --git a/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs b/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs index 0b80af68f2..84cfac8f65 100644 --- a/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs +++ b/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -13,7 +15,7 @@ using osu.Game.Screens.Edit.Compose.Components.Timeline; namespace osu.Game.Screens.Edit { - public abstract class EditorScreenWithTimeline : EditorScreen + public abstract partial class EditorScreenWithTimeline : EditorScreen { private const float padding = 10; diff --git a/osu.Game/Screens/Edit/EditorSkinProvidingContainer.cs b/osu.Game/Screens/Edit/EditorSkinProvidingContainer.cs index 694d0253e0..814b5dc18e 100644 --- a/osu.Game/Screens/Edit/EditorSkinProvidingContainer.cs +++ b/osu.Game/Screens/Edit/EditorSkinProvidingContainer.cs @@ -3,15 +3,13 @@ using osu.Game.Skinning; -#nullable enable - namespace osu.Game.Screens.Edit { /// /// A that fires when users have made a change to the beatmap skin /// of the map being edited. /// - public class EditorSkinProvidingContainer : RulesetSkinProvidingContainer + public partial class EditorSkinProvidingContainer : RulesetSkinProvidingContainer { private readonly EditorBeatmapSkin? beatmapSkin; diff --git a/osu.Game/Screens/Edit/EditorState.cs b/osu.Game/Screens/Edit/EditorState.cs index 4690074e3d..1751de4d3e 100644 --- a/osu.Game/Screens/Edit/EditorState.cs +++ b/osu.Game/Screens/Edit/EditorState.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 enable - namespace osu.Game.Screens.Edit { /// diff --git a/osu.Game/Screens/Edit/EditorTable.cs b/osu.Game/Screens/Edit/EditorTable.cs index 26819dcfe7..b79d71b42b 100644 --- a/osu.Game/Screens/Edit/EditorTable.cs +++ b/osu.Game/Screens/Edit/EditorTable.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; @@ -16,8 +17,10 @@ using osuTK.Graphics; namespace osu.Game.Screens.Edit { - public abstract class EditorTable : TableContainer + public abstract partial class EditorTable : TableContainer { + public event Action? OnRowSelected; + private const float horizontal_inset = 20; protected const float ROW_HEIGHT = 25; @@ -43,9 +46,20 @@ namespace osu.Game.Screens.Edit }); } - protected override Drawable CreateHeader(int index, TableColumn column) => new HeaderText(column?.Header ?? default); + protected void SetSelectedRow(object? item) + { + foreach (var b in BackgroundFlow) + { + b.Selected = ReferenceEquals(b.Item, item); - private class HeaderText : OsuSpriteText + if (b.Selected) + OnRowSelected?.Invoke(b); + } + } + + protected override Drawable CreateHeader(int index, TableColumn? column) => new HeaderText(column?.Header ?? default); + + private partial class HeaderText : OsuSpriteText { public HeaderText(LocalisableString text) { @@ -54,7 +68,7 @@ namespace osu.Game.Screens.Edit } } - public class RowBackground : OsuClickableContainer + public partial class RowBackground : OsuClickableContainer { public readonly object Item; @@ -82,11 +96,6 @@ namespace osu.Game.Screens.Edit Alpha = 0, }, }; - - // todo delete - Action = () => - { - }; } private Color4 colourHover; @@ -95,7 +104,7 @@ namespace osu.Game.Screens.Edit [BackgroundDependencyLoader] private void load(OverlayColourProvider colours) { - hoveredBackground.Colour = colourHover = colours.Background1; + colourHover = colours.Background1; colourSelected = colours.Colour3; } @@ -103,8 +112,7 @@ namespace osu.Game.Screens.Edit { base.LoadComplete(); - // Reduce flicker of rows when offset is being changed rapidly. - // Probably need to reconsider this. + updateState(); FinishTransforms(true); } diff --git a/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs index f7e450b0e2..7dff05667d 100644 --- a/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs +++ b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs @@ -1,21 +1,25 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Screens; using osu.Game.Beatmaps; using osu.Game.Overlays; using osu.Game.Screens.Play; +using osu.Game.Users; namespace osu.Game.Screens.Edit.GameplayTest { - public class EditorPlayer : Player + public partial class EditorPlayer : Player { private readonly Editor editor; private readonly EditorState editorState; + protected override UserActivity InitialActivity => new UserActivity.TestingBeatmap(Beatmap.Value.BeatmapInfo, Ruleset.Value); + [Resolved] - private MusicController musicController { get; set; } + private MusicController musicController { get; set; } = null!; public EditorPlayer(Editor editor) : base(new PlayerConfiguration { ShowResults = false }) @@ -25,7 +29,16 @@ namespace osu.Game.Screens.Edit.GameplayTest } protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) - => new MasterGameplayClockContainer(beatmap, gameplayStart) { StartTime = editorState.Time }; + { + var masterGameplayClockContainer = new MasterGameplayClockContainer(beatmap, gameplayStart); + + // Only reset the time to the current point if the editor is later than the normal start time (and the first object). + // This allows more sane test playing from the start of the beatmap (ie. correctly adding lead-in time). + if (editorState.Time > gameplayStart && editorState.Time > DrawableRuleset.Objects.FirstOrDefault()?.StartTime) + masterGameplayClockContainer.Reset(editorState.Time); + + return masterGameplayClockContainer; + } protected override void LoadComplete() { @@ -33,7 +46,13 @@ namespace osu.Game.Screens.Edit.GameplayTest ScoreProcessor.HasCompleted.BindValueChanged(completed => { if (completed.NewValue) - Scheduler.AddDelayed(this.Exit, RESULTS_DISPLAY_DELAY); + { + Scheduler.AddDelayed(() => + { + if (this.IsCurrentScreen()) + this.Exit(); + }, RESULTS_DISPLAY_DELAY); + } }); } @@ -58,7 +77,6 @@ namespace osu.Game.Screens.Edit.GameplayTest { musicController.Stop(); - editorState.Time = GameplayClockContainer.CurrentTime; editor.RestoreState(editorState); return base.OnExiting(e); } diff --git a/osu.Game/Screens/Edit/GameplayTest/EditorPlayerLoader.cs b/osu.Game/Screens/Edit/GameplayTest/EditorPlayerLoader.cs index c16bb8677c..a74d97cdc7 100644 --- a/osu.Game/Screens/Edit/GameplayTest/EditorPlayerLoader.cs +++ b/osu.Game/Screens/Edit/GameplayTest/EditorPlayerLoader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Screens; @@ -9,7 +11,7 @@ using osu.Game.Screens.Play; namespace osu.Game.Screens.Edit.GameplayTest { - public class EditorPlayerLoader : PlayerLoader + public partial class EditorPlayerLoader : PlayerLoader { [Resolved] private OsuLogo osuLogo { get; set; } diff --git a/osu.Game/Screens/Edit/GameplayTest/SaveBeforeGameplayTestDialog.cs b/osu.Game/Screens/Edit/GameplayTest/SaveBeforeGameplayTestDialog.cs index 9334c74706..5a5572b508 100644 --- a/osu.Game/Screens/Edit/GameplayTest/SaveBeforeGameplayTestDialog.cs +++ b/osu.Game/Screens/Edit/GameplayTest/SaveBeforeGameplayTestDialog.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics.Sprites; using osu.Game.Overlays.Dialog; namespace osu.Game.Screens.Edit.GameplayTest { - public class SaveBeforeGameplayTestDialog : PopupDialog + public partial class SaveBeforeGameplayTestDialog : PopupDialog { public SaveBeforeGameplayTestDialog(Action saveAndPreview) { diff --git a/osu.Game/Screens/Edit/HitAnimationsMenuItem.cs b/osu.Game/Screens/Edit/HitAnimationsMenuItem.cs index fb7ab39f7a..3e1e0c4cfe 100644 --- a/osu.Game/Screens/Edit/HitAnimationsMenuItem.cs +++ b/osu.Game/Screens/Edit/HitAnimationsMenuItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using osu.Framework.Bindables; using osu.Game.Graphics.UserInterface; diff --git a/osu.Game/Screens/Edit/IEditorChangeHandler.cs b/osu.Game/Screens/Edit/IEditorChangeHandler.cs index 0283421b8c..9fe40ba1b1 100644 --- a/osu.Game/Screens/Edit/IEditorChangeHandler.cs +++ b/osu.Game/Screens/Edit/IEditorChangeHandler.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Allocation; using osu.Game.Rulesets.Objects; namespace osu.Game.Screens.Edit @@ -9,12 +10,13 @@ namespace osu.Game.Screens.Edit /// /// Interface for a component that manages changes in the . /// + [Cached] public interface IEditorChangeHandler { /// /// Fired whenever a state change occurs. /// - event Action OnStateChange; + event Action? OnStateChange; /// /// Begins a bulk state change event. should be invoked soon after. diff --git a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs index 3ed2a7efe2..b4647c2b64 100644 --- a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs +++ b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs @@ -1,15 +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; using System.Collections.Generic; -using System.Diagnostics; using System.IO; +using System.Linq; using System.Text; using DiffPlex; +using DiffPlex.Model; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Formats; using osu.Game.IO; using osu.Game.Skinning; using Decoder = osu.Game.Beatmaps.Formats.Decoder; @@ -32,61 +37,107 @@ namespace osu.Game.Screens.Edit { // Diff the beatmaps var result = new Differ().CreateLineDiffs(readString(currentState), readString(newState), true, false); + IBeatmap newBeatmap = null; - // Find the index of [HitObject] sections. Lines changed prior to this index are ignored. - int oldHitObjectsIndex = Array.IndexOf(result.PiecesOld, "[HitObjects]"); - int newHitObjectsIndex = Array.IndexOf(result.PiecesNew, "[HitObjects]"); + editorBeatmap.BeginChange(); + processHitObjects(result, () => newBeatmap ??= readBeatmap(newState)); + processTimingPoints(() => newBeatmap ??= readBeatmap(newState)); + editorBeatmap.EndChange(); + } - Debug.Assert(oldHitObjectsIndex >= 0); - Debug.Assert(newHitObjectsIndex >= 0); + private void processTimingPoints(Func getNewBeatmap) + { + ControlPointInfo newControlPoints = EditorBeatmap.ConvertControlPoints(getNewBeatmap().ControlPointInfo); - var toRemove = new List(); - var toAdd = new List(); + // Remove all groups from the current beatmap which don't have a corresponding equal group in the new beatmap. + foreach (var oldGroup in editorBeatmap.ControlPointInfo.Groups.ToArray()) + { + var newGroup = newControlPoints.GroupAt(oldGroup.Time); + + if (!oldGroup.Equals(newGroup)) + editorBeatmap.ControlPointInfo.RemoveGroup(oldGroup); + } + + // Add all groups from the new beatmap which don't have a corresponding equal group in the old beatmap. + foreach (var newGroup in newControlPoints.Groups) + { + var oldGroup = editorBeatmap.ControlPointInfo.GroupAt(newGroup.Time); + + if (!newGroup.Equals(oldGroup)) + { + foreach (var point in newGroup.ControlPoints) + editorBeatmap.ControlPointInfo.Add(newGroup.Time, point); + } + } + } + + private void processHitObjects(DiffResult result, Func getNewBeatmap) + { + findChangedIndices(result, LegacyDecoder.Section.HitObjects, out var removedIndices, out var addedIndices); + + for (int i = removedIndices.Count - 1; i >= 0; i--) + editorBeatmap.RemoveAt(removedIndices[i]); + + if (addedIndices.Count > 0) + { + var newBeatmap = getNewBeatmap(); + + foreach (int i in addedIndices) + editorBeatmap.Insert(i, newBeatmap.HitObjects[i]); + } + } + + private void findChangedIndices(DiffResult result, LegacyDecoder.Section section, out List removedIndices, out List addedIndices) + { + removedIndices = new List(); + addedIndices = new List(); + + // Find the start and end indices of the relevant section headers in both the old and the new beatmap file. Lines changed outside of the modified ranges are ignored. + int oldSectionStartIndex = Array.IndexOf(result.PiecesOld, $"[{section}]"); + if (oldSectionStartIndex == -1) + return; + + int oldSectionEndIndex = Array.FindIndex(result.PiecesOld, oldSectionStartIndex + 1, s => s.StartsWith('[')); + if (oldSectionEndIndex == -1) + oldSectionEndIndex = result.PiecesOld.Length; + + int newSectionStartIndex = Array.IndexOf(result.PiecesNew, $"[{section}]"); + if (newSectionStartIndex == -1) + return; + + int newSectionEndIndex = Array.FindIndex(result.PiecesNew, newSectionStartIndex + 1, s => s.StartsWith('[')); + if (newSectionEndIndex == -1) + newSectionEndIndex = result.PiecesNew.Length; foreach (var block in result.DiffBlocks) { - // Removed hitobjects + // Removed indices for (int i = 0; i < block.DeleteCountA; i++) { - int hoIndex = block.DeleteStartA + i - oldHitObjectsIndex - 1; + int objectIndex = block.DeleteStartA + i; - if (hoIndex < 0) + if (objectIndex <= oldSectionStartIndex || objectIndex >= oldSectionEndIndex) continue; - toRemove.Add(hoIndex); + removedIndices.Add(objectIndex - oldSectionStartIndex - 1); } - // Added hitobjects + // Added indices for (int i = 0; i < block.InsertCountB; i++) { - int hoIndex = block.InsertStartB + i - newHitObjectsIndex - 1; + int objectIndex = block.InsertStartB + i; - if (hoIndex < 0) + if (objectIndex <= newSectionStartIndex || objectIndex >= newSectionEndIndex) continue; - toAdd.Add(hoIndex); + addedIndices.Add(objectIndex - newSectionStartIndex - 1); } } // Sort the indices to ensure that removal + insertion indices don't get jumbled up post-removal or post-insertion. // This isn't strictly required, but the differ makes no guarantees about order. - toRemove.Sort(); - toAdd.Sort(); - - editorBeatmap.BeginChange(); - - // Apply the changes. - for (int i = toRemove.Count - 1; i >= 0; i--) - editorBeatmap.RemoveAt(toRemove[i]); - - if (toAdd.Count > 0) - { - IBeatmap newBeatmap = readBeatmap(newState); - foreach (int i in toAdd) - editorBeatmap.Insert(i, newBeatmap.HitObjects[i]); - } - - editorBeatmap.EndChange(); + removedIndices.Sort(); + addedIndices.Sort(); } private string readString(byte[] state) => Encoding.UTF8.GetString(state); diff --git a/osu.Game/Screens/Edit/PromptForSaveDialog.cs b/osu.Game/Screens/Edit/PromptForSaveDialog.cs index 4f70491ade..7d78465e6c 100644 --- a/osu.Game/Screens/Edit/PromptForSaveDialog.cs +++ b/osu.Game/Screens/Edit/PromptForSaveDialog.cs @@ -4,14 +4,15 @@ using System; using osu.Framework.Graphics.Sprites; using osu.Game.Overlays.Dialog; +using osu.Game.Localisation; namespace osu.Game.Screens.Edit { - public class PromptForSaveDialog : PopupDialog + public partial class PromptForSaveDialog : PopupDialog { public PromptForSaveDialog(Action exit, Action saveAndExit, Action cancel) { - HeaderText = "Did you want to save your changes?"; + HeaderText = EditorDialogsStrings.SaveDialogHeader; Icon = FontAwesome.Regular.Save; @@ -19,17 +20,17 @@ namespace osu.Game.Screens.Edit { new PopupDialogOkButton { - Text = @"Save my masterpiece!", + Text = EditorDialogsStrings.Save, Action = saveAndExit }, new PopupDialogDangerousButton { - Text = @"Forget all changes", + Text = EditorDialogsStrings.ForgetAllChanges, Action = exit }, new PopupDialogCancelButton { - Text = @"Oops, continue editing", + Text = EditorDialogsStrings.ContinueEditing, Action = cancel }, }; diff --git a/osu.Game/Screens/Edit/Setup/ColoursSection.cs b/osu.Game/Screens/Edit/Setup/ColoursSection.cs index 05d9855a24..8cd5c0f779 100644 --- a/osu.Game/Screens/Edit/Setup/ColoursSection.cs +++ b/osu.Game/Screens/Edit/Setup/ColoursSection.cs @@ -5,14 +5,15 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; namespace osu.Game.Screens.Edit.Setup { - internal class ColoursSection : SetupSection + internal partial class ColoursSection : SetupSection { - public override LocalisableString Title => "Colours"; + public override LocalisableString Title => EditorSetupStrings.ColoursHeader; - private LabelledColourPalette comboColours; + private LabelledColourPalette comboColours = null!; [BackgroundDependencyLoader] private void load() @@ -21,9 +22,9 @@ namespace osu.Game.Screens.Edit.Setup { comboColours = new LabelledColourPalette { - Label = "Hitcircle / Slider Combos", + Label = EditorSetupStrings.HitCircleSliderCombos, FixedLabelWidth = LABEL_WIDTH, - ColourNamePrefix = "Combo" + ColourNamePrefix = EditorSetupStrings.ComboColourPrefix } }; diff --git a/osu.Game/Screens/Edit/Setup/DesignSection.cs b/osu.Game/Screens/Edit/Setup/DesignSection.cs index d5d93db050..b05a073146 100644 --- a/osu.Game/Screens/Edit/Setup/DesignSection.cs +++ b/osu.Game/Screens/Edit/Setup/DesignSection.cs @@ -11,23 +11,24 @@ using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterfaceV2; using osuTK; +using osu.Game.Localisation; namespace osu.Game.Screens.Edit.Setup { - internal class DesignSection : SetupSection + internal partial class DesignSection : SetupSection { - protected LabelledSwitchButton EnableCountdown; + protected LabelledSwitchButton EnableCountdown = null!; - protected FillFlowContainer CountdownSettings; - protected LabelledEnumDropdown CountdownSpeed; - protected LabelledNumberBox CountdownOffset; + protected FillFlowContainer CountdownSettings = null!; + protected LabelledEnumDropdown CountdownSpeed = null!; + protected LabelledNumberBox CountdownOffset = null!; - private LabelledSwitchButton widescreenSupport; - private LabelledSwitchButton epilepsyWarning; - private LabelledSwitchButton letterboxDuringBreaks; - private LabelledSwitchButton samplesMatchPlaybackRate; + private LabelledSwitchButton widescreenSupport = null!; + private LabelledSwitchButton epilepsyWarning = null!; + private LabelledSwitchButton letterboxDuringBreaks = null!; + private LabelledSwitchButton samplesMatchPlaybackRate = null!; - public override LocalisableString Title => "Design"; + public override LocalisableString Title => EditorSetupStrings.DesignHeader; [BackgroundDependencyLoader] private void load() @@ -36,9 +37,9 @@ namespace osu.Game.Screens.Edit.Setup { EnableCountdown = new LabelledSwitchButton { - Label = "Enable countdown", + Label = EditorSetupStrings.EnableCountdown, Current = { Value = Beatmap.BeatmapInfo.Countdown != CountdownType.None }, - Description = "If enabled, an \"Are you ready? 3, 2, 1, GO!\" countdown will be inserted at the beginning of the beatmap, assuming there is enough time to do so." + Description = EditorSetupStrings.CountdownDescription }, CountdownSettings = new FillFlowContainer { @@ -50,41 +51,41 @@ namespace osu.Game.Screens.Edit.Setup { CountdownSpeed = new LabelledEnumDropdown { - Label = "Countdown speed", + Label = EditorSetupStrings.CountdownSpeed, Current = { Value = Beatmap.BeatmapInfo.Countdown != CountdownType.None ? Beatmap.BeatmapInfo.Countdown : CountdownType.Normal }, - Items = Enum.GetValues(typeof(CountdownType)).Cast().Where(type => type != CountdownType.None) + Items = Enum.GetValues().Where(type => type != CountdownType.None) }, CountdownOffset = new LabelledNumberBox { - Label = "Countdown offset", + Label = EditorSetupStrings.CountdownOffset, Current = { Value = Beatmap.BeatmapInfo.CountdownOffset.ToString() }, - Description = "If the countdown sounds off-time, use this to make it appear one or more beats early.", + Description = EditorSetupStrings.CountdownOffsetDescription, } } }, Empty(), widescreenSupport = new LabelledSwitchButton { - Label = "Widescreen support", - Description = "Allows storyboards to use the full screen space, rather than be confined to a 4:3 area.", + Label = EditorSetupStrings.WidescreenSupport, + Description = EditorSetupStrings.WidescreenSupportDescription, Current = { Value = Beatmap.BeatmapInfo.WidescreenStoryboard } }, epilepsyWarning = new LabelledSwitchButton { - Label = "Epilepsy warning", - Description = "Recommended if the storyboard or video contain scenes with rapidly flashing colours.", + Label = EditorSetupStrings.EpilepsyWarning, + Description = EditorSetupStrings.EpilepsyWarningDescription, Current = { Value = Beatmap.BeatmapInfo.EpilepsyWarning } }, letterboxDuringBreaks = new LabelledSwitchButton { - Label = "Letterbox during breaks", - Description = "Adds horizontal letterboxing to give a cinematic look during breaks.", + Label = EditorSetupStrings.LetterboxDuringBreaks, + Description = EditorSetupStrings.LetterboxDuringBreaksDescription, Current = { Value = Beatmap.BeatmapInfo.LetterboxInBreaks } }, samplesMatchPlaybackRate = new LabelledSwitchButton { - Label = "Samples match playback rate", - Description = "When enabled, all samples will speed up or slow down when rate-changing mods are enabled.", + Label = EditorSetupStrings.SamplesMatchPlaybackRate, + Description = EditorSetupStrings.SamplesMatchPlaybackRateDescription, Current = { Value = Beatmap.BeatmapInfo.SamplesMatchPlaybackRate } } }; @@ -98,7 +99,7 @@ namespace osu.Game.Screens.Edit.Setup EnableCountdown.Current.BindValueChanged(_ => updateBeatmap()); CountdownSpeed.Current.BindValueChanged(_ => updateBeatmap()); - CountdownOffset.OnCommit += (_, __) => onOffsetCommitted(); + CountdownOffset.OnCommit += (_, _) => onOffsetCommitted(); widescreenSupport.Current.BindValueChanged(_ => updateBeatmap()); epilepsyWarning.Current.BindValueChanged(_ => updateBeatmap()); @@ -124,6 +125,8 @@ namespace osu.Game.Screens.Edit.Setup Beatmap.BeatmapInfo.EpilepsyWarning = epilepsyWarning.Current.Value; Beatmap.BeatmapInfo.LetterboxInBreaks = letterboxDuringBreaks.Current.Value; Beatmap.BeatmapInfo.SamplesMatchPlaybackRate = samplesMatchPlaybackRate.Current.Value; + + Beatmap.SaveState(); } } } diff --git a/osu.Game/Screens/Edit/Setup/DifficultySection.cs b/osu.Game/Screens/Edit/Setup/DifficultySection.cs index e799081115..7026bde681 100644 --- a/osu.Game/Screens/Edit/Setup/DifficultySection.cs +++ b/osu.Game/Screens/Edit/Setup/DifficultySection.cs @@ -9,17 +9,18 @@ using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Resources.Localisation.Web; +using osu.Game.Localisation; namespace osu.Game.Screens.Edit.Setup { - internal class DifficultySection : SetupSection + internal partial class DifficultySection : SetupSection { - private LabelledSliderBar circleSizeSlider; - private LabelledSliderBar healthDrainSlider; - private LabelledSliderBar approachRateSlider; - private LabelledSliderBar overallDifficultySlider; + private LabelledSliderBar circleSizeSlider = null!; + private LabelledSliderBar healthDrainSlider = null!; + private LabelledSliderBar approachRateSlider = null!; + private LabelledSliderBar overallDifficultySlider = null!; - public override LocalisableString Title => "Difficulty"; + public override LocalisableString Title => EditorSetupStrings.DifficultyHeader; [BackgroundDependencyLoader] private void load() @@ -30,7 +31,7 @@ namespace osu.Game.Screens.Edit.Setup { Label = BeatmapsetsStrings.ShowStatsCs, FixedLabelWidth = LABEL_WIDTH, - Description = "The size of all hit objects", + Description = EditorSetupStrings.CircleSizeDescription, Current = new BindableFloat(Beatmap.Difficulty.CircleSize) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, @@ -43,7 +44,7 @@ namespace osu.Game.Screens.Edit.Setup { Label = BeatmapsetsStrings.ShowStatsDrain, FixedLabelWidth = LABEL_WIDTH, - Description = "The rate of passive health drain throughout playable time", + Description = EditorSetupStrings.DrainRateDescription, Current = new BindableFloat(Beatmap.Difficulty.DrainRate) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, @@ -56,7 +57,7 @@ namespace osu.Game.Screens.Edit.Setup { Label = BeatmapsetsStrings.ShowStatsAr, FixedLabelWidth = LABEL_WIDTH, - Description = "The speed at which objects are presented to the player", + Description = EditorSetupStrings.ApproachRateDescription, Current = new BindableFloat(Beatmap.Difficulty.ApproachRate) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, @@ -69,7 +70,7 @@ namespace osu.Game.Screens.Edit.Setup { Label = BeatmapsetsStrings.ShowStatsAccuracy, FixedLabelWidth = LABEL_WIDTH, - Description = "The harshness of hit windows and difficulty of special objects (ie. spinners)", + Description = EditorSetupStrings.OverallDifficultyDescription, Current = new BindableFloat(Beatmap.Difficulty.OverallDifficulty) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, @@ -94,6 +95,7 @@ namespace osu.Game.Screens.Edit.Setup Beatmap.Difficulty.OverallDifficulty = overallDifficultySlider.Current.Value; Beatmap.UpdateAllHitObjects(); + Beatmap.SaveState(); } } } diff --git a/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs b/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs deleted file mode 100644 index fd916894ea..0000000000 --- a/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable enable - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions; -using osu.Framework.Extensions.ObjectExtensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.UserInterface; -using osu.Game.Database; -using osu.Game.Graphics.UserInterfaceV2; -using osuTK; - -namespace osu.Game.Screens.Edit.Setup -{ - /// - /// A labelled textbox which reveals an inline file chooser when clicked. - /// - internal class FileChooserLabelledTextBox : LabelledTextBoxWithPopover, ICanAcceptFiles - { - private readonly string[] handledExtensions; - - public IEnumerable HandledExtensions => handledExtensions; - - private readonly Bindable currentFile = new Bindable(); - - [Resolved] - private OsuGameBase game { get; set; } = null!; - - public FileChooserLabelledTextBox(params string[] handledExtensions) - { - this.handledExtensions = handledExtensions; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - game.RegisterImportHandler(this); - currentFile.BindValueChanged(onFileSelected); - } - - private void onFileSelected(ValueChangedEvent file) - { - if (file.NewValue == null) - return; - - this.HidePopover(); - Current.Value = file.NewValue.FullName; - } - - Task ICanAcceptFiles.Import(params string[] paths) - { - Schedule(() => currentFile.Value = new FileInfo(paths.First())); - return Task.CompletedTask; - } - - Task ICanAcceptFiles.Import(params ImportTask[] tasks) => throw new NotImplementedException(); - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (game.IsNotNull()) - game.UnregisterImportHandler(this); - } - - public override Popover GetPopover() => new FileChooserPopover(handledExtensions, currentFile); - - private class FileChooserPopover : OsuPopover - { - public FileChooserPopover(string[] handledExtensions, Bindable currentFile) - { - Child = new Container - { - Size = new Vector2(600, 400), - Child = new OsuFileSelector(currentFile.Value?.DirectoryName, handledExtensions) - { - RelativeSizeAxes = Axes.Both, - CurrentFile = { BindTarget = currentFile } - }, - }; - } - } - } -} diff --git a/osu.Game/Screens/Edit/Setup/LabelledFileChooser.cs b/osu.Game/Screens/Edit/Setup/LabelledFileChooser.cs new file mode 100644 index 0000000000..d14357e875 --- /dev/null +++ b/osu.Game/Screens/Edit/Setup/LabelledFileChooser.cs @@ -0,0 +1,131 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; +using osu.Framework.Platform; +using osu.Game.Database; +using osu.Game.Graphics.UserInterfaceV2; +using osuTK; + +namespace osu.Game.Screens.Edit.Setup +{ + /// + /// A labelled drawable displaying file chooser on click, with placeholder text support. + /// todo: this should probably not use PopoverTextBox just to display placeholder text, but is the best way for now. + /// + internal partial class LabelledFileChooser : LabelledDrawable, IHasCurrentValue, ICanAcceptFiles, IHasPopover + { + private readonly string[] handledExtensions; + + public IEnumerable HandledExtensions => handledExtensions; + + [Resolved] + private OsuGameBase game { get; set; } = null!; + + /// + /// The initial path to use when displaying the . + /// + /// + /// Uses a value before the first selection is made + /// to ensure that the first selection starts at . + /// + private string? initialChooserPath; + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + public LocalisableString Text + { + get => Component.PlaceholderText; + set => Component.PlaceholderText = value; + } + + public CompositeDrawable TabbableContentContainer + { + set => Component.TabbableContentContainer = value; + } + + public LabelledFileChooser(params string[] handledExtensions) + : base(false) + { + this.handledExtensions = handledExtensions; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + game.RegisterImportHandler(this); + Current.BindValueChanged(onFileSelected); + } + + private void onFileSelected(ValueChangedEvent file) + { + if (file.NewValue != null) + this.HidePopover(); + + initialChooserPath = file.NewValue?.DirectoryName; + } + + Task ICanAcceptFiles.Import(params string[] paths) + { + Schedule(() => Current.Value = new FileInfo(paths.First())); + return Task.CompletedTask; + } + + Task ICanAcceptFiles.Import(ImportTask[] tasks, ImportParameters parameters) => throw new NotImplementedException(); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (game.IsNotNull()) + game.UnregisterImportHandler(this); + } + + protected override LabelledTextBoxWithPopover.PopoverTextBox CreateComponent() => new LabelledTextBoxWithPopover.PopoverTextBox + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + CornerRadius = CORNER_RADIUS, + OnFocused = this.ShowPopover, + }; + + public Popover GetPopover() => new FileChooserPopover(handledExtensions, Current, initialChooserPath); + + private partial class FileChooserPopover : OsuPopover + { + public FileChooserPopover(string[] handledExtensions, Bindable currentFile, string? chooserPath) + { + Child = new Container + { + Size = new Vector2(600, 400), + Child = new OsuFileSelector(chooserPath, handledExtensions) + { + RelativeSizeAxes = Axes.Both, + CurrentFile = { BindTarget = currentFile } + }, + }; + } + } + } +} diff --git a/osu.Game/Screens/Edit/Setup/LabelledRomanisedTextBox.cs b/osu.Game/Screens/Edit/Setup/LabelledRomanisedTextBox.cs index c39b4d6f41..85c697bf14 100644 --- a/osu.Game/Screens/Edit/Setup/LabelledRomanisedTextBox.cs +++ b/osu.Game/Screens/Edit/Setup/LabelledRomanisedTextBox.cs @@ -7,11 +7,11 @@ using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Screens.Edit.Setup { - internal class LabelledRomanisedTextBox : LabelledTextBox + internal partial class LabelledRomanisedTextBox : LabelledTextBox { protected override OsuTextBox CreateTextBox() => new RomanisedTextBox(); - private class RomanisedTextBox : OsuTextBox + private partial class RomanisedTextBox : OsuTextBox { protected override bool AllowIme => false; diff --git a/osu.Game/Screens/Edit/Setup/LabelledTextBoxWithPopover.cs b/osu.Game/Screens/Edit/Setup/LabelledTextBoxWithPopover.cs index 799311dd2d..79288e2977 100644 --- a/osu.Game/Screens/Edit/Setup/LabelledTextBoxWithPopover.cs +++ b/osu.Game/Screens/Edit/Setup/LabelledTextBoxWithPopover.cs @@ -12,7 +12,7 @@ using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Screens.Edit.Setup { - internal abstract class LabelledTextBoxWithPopover : LabelledTextBox, IHasPopover + internal abstract partial class LabelledTextBoxWithPopover : LabelledTextBox, IHasPopover { public abstract Popover GetPopover(); @@ -26,9 +26,9 @@ namespace osu.Game.Screens.Edit.Setup OnFocused = this.ShowPopover }; - internal class PopoverTextBox : OsuTextBox + internal partial class PopoverTextBox : OsuTextBox { - public Action OnFocused; + public Action? OnFocused; protected override bool OnDragStart(DragStartEvent e) { diff --git a/osu.Game/Screens/Edit/Setup/MetadataSection.cs b/osu.Game/Screens/Edit/Setup/MetadataSection.cs index 6262b4c18b..752f590308 100644 --- a/osu.Game/Screens/Edit/Setup/MetadataSection.cs +++ b/osu.Game/Screens/Edit/Setup/MetadataSection.cs @@ -8,23 +8,24 @@ using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Resources.Localisation.Web; +using osu.Game.Localisation; namespace osu.Game.Screens.Edit.Setup { - public class MetadataSection : SetupSection + public partial class MetadataSection : SetupSection { - protected LabelledTextBox ArtistTextBox; - protected LabelledTextBox RomanisedArtistTextBox; + protected LabelledTextBox ArtistTextBox = null!; + protected LabelledTextBox RomanisedArtistTextBox = null!; - protected LabelledTextBox TitleTextBox; - protected LabelledTextBox RomanisedTitleTextBox; + protected LabelledTextBox TitleTextBox = null!; + protected LabelledTextBox RomanisedTitleTextBox = null!; - private LabelledTextBox creatorTextBox; - private LabelledTextBox difficultyTextBox; - private LabelledTextBox sourceTextBox; - private LabelledTextBox tagsTextBox; + private LabelledTextBox creatorTextBox = null!; + private LabelledTextBox difficultyTextBox = null!; + private LabelledTextBox sourceTextBox = null!; + private LabelledTextBox tagsTextBox = null!; - public override LocalisableString Title => "Metadata"; + public override LocalisableString Title => EditorSetupStrings.MetadataHeader; [BackgroundDependencyLoader] private void load() @@ -33,22 +34,22 @@ namespace osu.Game.Screens.Edit.Setup Children = new[] { - ArtistTextBox = createTextBox("Artist", + ArtistTextBox = createTextBox(EditorSetupStrings.Artist, !string.IsNullOrEmpty(metadata.ArtistUnicode) ? metadata.ArtistUnicode : metadata.Artist), - RomanisedArtistTextBox = createTextBox("Romanised Artist", + RomanisedArtistTextBox = createTextBox(EditorSetupStrings.RomanisedArtist, !string.IsNullOrEmpty(metadata.Artist) ? metadata.Artist : MetadataUtils.StripNonRomanisedCharacters(metadata.ArtistUnicode)), Empty(), - TitleTextBox = createTextBox("Title", + TitleTextBox = createTextBox(EditorSetupStrings.Title, !string.IsNullOrEmpty(metadata.TitleUnicode) ? metadata.TitleUnicode : metadata.Title), - RomanisedTitleTextBox = createTextBox("Romanised Title", + RomanisedTitleTextBox = createTextBox(EditorSetupStrings.RomanisedTitle, !string.IsNullOrEmpty(metadata.Title) ? metadata.Title : MetadataUtils.StripNonRomanisedCharacters(metadata.ArtistUnicode)), Empty(), - creatorTextBox = createTextBox("Creator", metadata.Author.Username), - difficultyTextBox = createTextBox("Difficulty Name", Beatmap.BeatmapInfo.DifficultyName), + creatorTextBox = createTextBox(EditorSetupStrings.Creator, metadata.Author.Username), + difficultyTextBox = createTextBox(EditorSetupStrings.DifficultyName, Beatmap.BeatmapInfo.DifficultyName), sourceTextBox = createTextBox(BeatmapsetsStrings.ShowInfoSource, metadata.Source), tagsTextBox = createTextBox(BeatmapsetsStrings.ShowInfoTags, metadata.Tags) }; @@ -85,7 +86,7 @@ namespace osu.Game.Screens.Edit.Setup target.Current.Value = value; updateReadOnlyState(); - updateMetadata(); + Scheduler.AddOnce(updateMetadata); } private void updateReadOnlyState() @@ -100,7 +101,7 @@ namespace osu.Game.Screens.Edit.Setup // for now, update on commit rather than making BeatmapMetadata bindables. // after switching database engines we can reconsider if switching to bindables is a good direction. - updateMetadata(); + Scheduler.AddOnce(updateMetadata); } private void updateMetadata() @@ -115,6 +116,8 @@ namespace osu.Game.Screens.Edit.Setup Beatmap.BeatmapInfo.DifficultyName = difficultyTextBox.Current.Value; Beatmap.Metadata.Source = sourceTextBox.Current.Value; Beatmap.Metadata.Tags = tagsTextBox.Current.Value; + + Beatmap.SaveState(); } } } diff --git a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs index 1e97218074..8c84ad90ba 100644 --- a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs +++ b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs @@ -2,131 +2,157 @@ // See the LICENCE file in the repository root for full licence text. using System.IO; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Beatmaps; -using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Overlays; +using osu.Game.Localisation; namespace osu.Game.Screens.Edit.Setup { - internal class ResourcesSection : SetupSection + internal partial class ResourcesSection : SetupSection { - private LabelledTextBox audioTrackTextBox; - private LabelledTextBox backgroundTextBox; + private LabelledFileChooser audioTrackChooser = null!; + private LabelledFileChooser backgroundChooser = null!; - public override LocalisableString Title => "Resources"; + public override LocalisableString Title => EditorSetupStrings.ResourcesHeader; [Resolved] - private MusicController music { get; set; } + private MusicController music { get; set; } = null!; [Resolved] - private BeatmapManager beatmaps { get; set; } + private BeatmapManager beatmaps { get; set; } = null!; [Resolved] - private IBindable working { get; set; } - - [Resolved(canBeNull: true)] - private Editor editor { get; set; } + private IBindable working { get; set; } = null!; [Resolved] - private SetupScreenHeader header { get; set; } + private EditorBeatmap editorBeatmap { get; set; } = null!; + + [Resolved] + private Editor? editor { get; set; } + + [Resolved] + private SetupScreenHeader header { get; set; } = null!; [BackgroundDependencyLoader] private void load() { Children = new Drawable[] { - backgroundTextBox = new FileChooserLabelledTextBox(".jpg", ".jpeg", ".png") + backgroundChooser = new LabelledFileChooser(".jpg", ".jpeg", ".png") { - Label = "Background", + Label = GameplaySettingsStrings.BackgroundHeader, FixedLabelWidth = LABEL_WIDTH, - PlaceholderText = "Click to select a background image", - Current = { Value = working.Value.Metadata.BackgroundFile }, TabbableContentContainer = this }, - audioTrackTextBox = new FileChooserLabelledTextBox(".mp3", ".ogg") + audioTrackChooser = new LabelledFileChooser(".mp3", ".ogg") { - Label = "Audio Track", + Label = EditorSetupStrings.AudioTrack, FixedLabelWidth = LABEL_WIDTH, - PlaceholderText = "Click to select a track", - Current = { Value = working.Value.Metadata.AudioFile }, TabbableContentContainer = this }, }; - backgroundTextBox.Current.BindValueChanged(backgroundChanged); - audioTrackTextBox.Current.BindValueChanged(audioTrackChanged); + if (!string.IsNullOrEmpty(working.Value.Metadata.BackgroundFile)) + backgroundChooser.Current.Value = new FileInfo(working.Value.Metadata.BackgroundFile); + + if (!string.IsNullOrEmpty(working.Value.Metadata.AudioFile)) + audioTrackChooser.Current.Value = new FileInfo(working.Value.Metadata.AudioFile); + + backgroundChooser.Current.BindValueChanged(backgroundChanged); + audioTrackChooser.Current.BindValueChanged(audioTrackChanged); + + updatePlaceholderText(); } - public bool ChangeBackgroundImage(string path) + public bool ChangeBackgroundImage(FileInfo source) { - var info = new FileInfo(path); - - if (!info.Exists) + if (!source.Exists) return false; var set = working.Value.BeatmapSetInfo; + var destination = new FileInfo($@"bg{source.Extension}"); + // remove the previous background for now. // in the future we probably want to check if this is being used elsewhere (other difficulties?) - var oldFile = set.Files.FirstOrDefault(f => f.Filename == working.Value.Metadata.BackgroundFile); + var oldFile = set.GetFile(working.Value.Metadata.BackgroundFile); - using (var stream = info.OpenRead()) + using (var stream = source.OpenRead()) { if (oldFile != null) beatmaps.DeleteFile(set, oldFile); - beatmaps.AddFile(set, stream, info.Name); + beatmaps.AddFile(set, stream, destination.Name); } - working.Value.Metadata.BackgroundFile = info.Name; + editorBeatmap.SaveState(); + + working.Value.Metadata.BackgroundFile = destination.Name; header.Background.UpdateBackground(); + editor?.ApplyToBackground(bg => bg.RefreshBackground()); + return true; } - public bool ChangeAudioTrack(string path) + public bool ChangeAudioTrack(FileInfo source) { - var info = new FileInfo(path); - - if (!info.Exists) + if (!source.Exists) return false; var set = working.Value.BeatmapSetInfo; + var destination = new FileInfo($@"audio{source.Extension}"); + // remove the previous audio track for now. // in the future we probably want to check if this is being used elsewhere (other difficulties?) - var oldFile = set.Files.FirstOrDefault(f => f.Filename == working.Value.Metadata.AudioFile); + var oldFile = set.GetFile(working.Value.Metadata.AudioFile); - using (var stream = info.OpenRead()) + using (var stream = source.OpenRead()) { if (oldFile != null) beatmaps.DeleteFile(set, oldFile); - beatmaps.AddFile(set, stream, info.Name); + + beatmaps.AddFile(set, stream, destination.Name); } - working.Value.Metadata.AudioFile = info.Name; + working.Value.Metadata.AudioFile = destination.Name; + editorBeatmap.SaveState(); music.ReloadCurrentTrack(); - editor?.UpdateClockSource(); return true; } - private void backgroundChanged(ValueChangedEvent filePath) + private void backgroundChanged(ValueChangedEvent file) { - if (!ChangeBackgroundImage(filePath.NewValue)) - backgroundTextBox.Current.Value = filePath.OldValue; + if (file.NewValue == null || !ChangeBackgroundImage(file.NewValue)) + backgroundChooser.Current.Value = file.OldValue; + + updatePlaceholderText(); } - private void audioTrackChanged(ValueChangedEvent filePath) + private void audioTrackChanged(ValueChangedEvent file) { - if (!ChangeAudioTrack(filePath.NewValue)) - audioTrackTextBox.Current.Value = filePath.OldValue; + if (file.NewValue == null || !ChangeAudioTrack(file.NewValue)) + audioTrackChooser.Current.Value = file.OldValue; + + updatePlaceholderText(); + } + + private void updatePlaceholderText() + { + audioTrackChooser.Text = audioTrackChooser.Current.Value == null + ? EditorSetupStrings.ClickToSelectTrack + : EditorSetupStrings.ClickToReplaceTrack; + + backgroundChooser.Text = backgroundChooser.Current.Value == null + ? EditorSetupStrings.ClickToSelectBackground + : EditorSetupStrings.ClickToReplaceBackground; } } } diff --git a/osu.Game/Screens/Edit/Setup/RulesetSetupSection.cs b/osu.Game/Screens/Edit/Setup/RulesetSetupSection.cs index 935842ff99..af59868f29 100644 --- a/osu.Game/Screens/Edit/Setup/RulesetSetupSection.cs +++ b/osu.Game/Screens/Edit/Setup/RulesetSetupSection.cs @@ -3,12 +3,13 @@ using osu.Framework.Localisation; using osu.Game.Rulesets; +using osu.Game.Localisation; namespace osu.Game.Screens.Edit.Setup { - public abstract class RulesetSetupSection : SetupSection + public abstract partial class RulesetSetupSection : SetupSection { - public sealed override LocalisableString Title => $"Ruleset ({rulesetInfo.Name})"; + public sealed override LocalisableString Title => EditorSetupStrings.RulesetHeader(rulesetInfo.Name); private readonly RulesetInfo rulesetInfo; diff --git a/osu.Game/Screens/Edit/Setup/SetupScreen.cs b/osu.Game/Screens/Edit/Setup/SetupScreen.cs index e0fc5f1aff..ab4299a2f0 100644 --- a/osu.Game/Screens/Edit/Setup/SetupScreen.cs +++ b/osu.Game/Screens/Edit/Setup/SetupScreen.cs @@ -10,7 +10,7 @@ using osu.Game.Overlays; namespace osu.Game.Screens.Edit.Setup { - public class SetupScreen : EditorScreen + public partial class SetupScreen : EditorScreen { [Cached] private SectionsContainer sections { get; } = new SetupScreenSectionsContainer(); @@ -53,7 +53,7 @@ namespace osu.Game.Screens.Edit.Setup })); } - private class SetupScreenSectionsContainer : SectionsContainer + private partial class SetupScreenSectionsContainer : SectionsContainer { protected override UserTrackingScrollContainer CreateScrollContainer() { diff --git a/osu.Game/Screens/Edit/Setup/SetupScreenHeader.cs b/osu.Game/Screens/Edit/Setup/SetupScreenHeader.cs index 2412f1c4ed..1d66830adf 100644 --- a/osu.Game/Screens/Edit/Setup/SetupScreenHeader.cs +++ b/osu.Game/Screens/Edit/Setup/SetupScreenHeader.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -9,17 +10,18 @@ using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.Containers; using osu.Game.Overlays; using osuTK.Graphics; +using osu.Game.Localisation; namespace osu.Game.Screens.Edit.Setup { - internal class SetupScreenHeader : OverlayHeader + internal partial class SetupScreenHeader : OverlayHeader { - public SetupScreenHeaderBackground Background { get; private set; } + public SetupScreenHeaderBackground Background { get; private set; } = null!; [Resolved] - private SectionsContainer sections { get; set; } + private SectionsContainer sections { get; set; } = null!; - private SetupScreenTabControl tabControl; + private SetupScreenTabControl tabControl = null!; protected override OverlayTitle CreateTitle() => new SetupScreenTitle(); @@ -71,17 +73,17 @@ namespace osu.Game.Screens.Edit.Setup }); } - private class SetupScreenTitle : OverlayTitle + private partial class SetupScreenTitle : OverlayTitle { public SetupScreenTitle() { - Title = "beatmap setup"; - Description = "change general settings of your beatmap"; + Title = EditorSetupStrings.BeatmapSetup.ToLower(); + Description = EditorSetupStrings.BeatmapSetupDescription; IconTexture = "Icons/Hexacons/social"; } } - internal class SetupScreenTabControl : OverlayTabControl + internal partial class SetupScreenTabControl : OverlayTabControl { private readonly Box background; @@ -107,7 +109,7 @@ namespace osu.Game.Screens.Edit.Setup AccentColour = AccentColour }; - private class SetupScreenTabItem : OverlayTabItem + private partial class SetupScreenTabItem : OverlayTabItem { public SetupScreenTabItem(SetupSection value) : base(value) diff --git a/osu.Game/Screens/Edit/Setup/SetupScreenHeaderBackground.cs b/osu.Game/Screens/Edit/Setup/SetupScreenHeaderBackground.cs index 17fb97d41f..033e5361bb 100644 --- a/osu.Game/Screens/Edit/Setup/SetupScreenHeaderBackground.cs +++ b/osu.Game/Screens/Edit/Setup/SetupScreenHeaderBackground.cs @@ -10,16 +10,17 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Localisation; namespace osu.Game.Screens.Edit.Setup { - public class SetupScreenHeaderBackground : CompositeDrawable + public partial class SetupScreenHeaderBackground : CompositeDrawable { [Resolved] - private OsuColour colours { get; set; } + private OsuColour colours { get; set; } = null!; [Resolved] - private IBindable working { get; set; } + private IBindable working { get; set; } = null!; private readonly Container content; @@ -61,7 +62,7 @@ namespace osu.Game.Screens.Edit.Setup }, new OsuTextFlowContainer(t => t.Font = OsuFont.Default.With(size: 24)) { - Text = "Drag image here to set beatmap background!", + Text = EditorSetupStrings.DragToSetBackground, Anchor = Anchor.Centre, Origin = Anchor.Centre, AutoSizeAxes = Axes.Both diff --git a/osu.Game/Screens/Edit/Setup/SetupSection.cs b/osu.Game/Screens/Edit/Setup/SetupSection.cs index 02bb05d227..5f676798f1 100644 --- a/osu.Game/Screens/Edit/Setup/SetupSection.cs +++ b/osu.Game/Screens/Edit/Setup/SetupSection.cs @@ -12,9 +12,9 @@ using osuTK; namespace osu.Game.Screens.Edit.Setup { - public abstract class SetupSection : Container + public abstract partial class SetupSection : Container { - private FillFlowContainer flow; + private FillFlowContainer flow = null!; /// /// Used to align some of the child s together to achieve a grid-like look. @@ -22,10 +22,10 @@ namespace osu.Game.Screens.Edit.Setup protected const float LABEL_WIDTH = 160; [Resolved] - protected OsuColour Colours { get; private set; } + protected OsuColour Colours { get; private set; } = null!; [Resolved] - protected EditorBeatmap Beatmap { get; private set; } + protected EditorBeatmap Beatmap { get; private set; } = null!; protected override Container Content => flow; diff --git a/osu.Game/Screens/Edit/Timing/ControlPointList.cs b/osu.Game/Screens/Edit/Timing/ControlPointList.cs new file mode 100644 index 0000000000..555c36aac0 --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/ControlPointList.cs @@ -0,0 +1,209 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.Edit.Timing +{ + public partial class ControlPointList : CompositeDrawable + { + private OsuButton deleteButton = null!; + private ControlPointTable table = null!; + private OsuScrollContainer scroll = null!; + private RoundedButton addButton = null!; + + private readonly IBindableList controlPointGroups = new BindableList(); + + [Resolved] + private EditorClock clock { get; set; } = null!; + + [Resolved] + protected EditorBeatmap Beatmap { get; private set; } = null!; + + [Resolved] + private Bindable selectedGroup { get; set; } = null!; + + [Resolved] + private IEditorChangeHandler? changeHandler { get; set; } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colours) + { + RelativeSizeAxes = Axes.Both; + + const float margins = 10; + InternalChildren = new Drawable[] + { + new Box + { + Colour = colours.Background4, + RelativeSizeAxes = Axes.Both, + }, + new Box + { + Colour = colours.Background3, + RelativeSizeAxes = Axes.Y, + Width = ControlPointTable.TIMING_COLUMN_WIDTH + margins, + }, + scroll = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = table = new ControlPointTable(), + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding(margins), + Spacing = new Vector2(5), + Children = new Drawable[] + { + deleteButton = new RoundedButton + { + Text = "-", + Size = new Vector2(30, 30), + Action = delete, + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + }, + addButton = new RoundedButton + { + Action = addNew, + Size = new Vector2(160, 30), + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + }, + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + selectedGroup.BindValueChanged(selected => + { + deleteButton.Enabled.Value = selected.NewValue != null; + + addButton.Text = selected.NewValue != null + ? "+ Clone to current time" + : "+ Add at current time"; + }, true); + + controlPointGroups.BindTo(Beatmap.ControlPointInfo.Groups); + controlPointGroups.BindCollectionChanged((_, _) => + { + table.ControlGroups = controlPointGroups; + changeHandler?.SaveState(); + }, true); + + table.OnRowSelected += drawable => scroll.ScrollIntoView(drawable); + } + + protected override bool OnClick(ClickEvent e) + { + selectedGroup.Value = null; + return true; + } + + protected override void Update() + { + base.Update(); + + trackActivePoint(); + + addButton.Enabled.Value = clock.CurrentTimeAccurate != selectedGroup.Value?.Time; + } + + private Type? trackedType; + + /// + /// Given the user has selected a control point group, we want to track any group which is + /// active at the current point in time which matches the type the user has selected. + /// + /// So if the user is currently looking at a timing point and seeks into the future, a + /// future timing point would be automatically selected if it is now the new "current" point. + /// + private void trackActivePoint() + { + // For simplicity only match on the first type of the active control point. + if (selectedGroup.Value == null) + trackedType = null; + else + { + // If the selected group only has one control point, update the tracking type. + if (selectedGroup.Value.ControlPoints.Count == 1) + trackedType = selectedGroup.Value?.ControlPoints.Single().GetType(); + // If the selected group has more than one control point, choose the first as the tracking type + // if we don't already have a singular tracked type. + else if (trackedType == null) + trackedType = selectedGroup.Value?.ControlPoints.FirstOrDefault()?.GetType(); + } + + if (trackedType != null) + { + // We don't have an efficient way of looking up groups currently, only individual point types. + // To improve the efficiency of this in the future, we should reconsider the overall structure of ControlPointInfo. + + // Find the next group which has the same type as the selected one. + var found = Beatmap.ControlPointInfo.Groups + .Where(g => g.ControlPoints.Any(cp => cp.GetType() == trackedType)) + .LastOrDefault(g => g.Time <= clock.CurrentTimeAccurate); + + if (found != null) + selectedGroup.Value = found; + } + } + + private void delete() + { + if (selectedGroup.Value == null) + return; + + Beatmap.ControlPointInfo.RemoveGroup(selectedGroup.Value); + + selectedGroup.Value = Beatmap.ControlPointInfo.Groups.FirstOrDefault(g => g.Time >= clock.CurrentTime); + } + + private void addNew() + { + bool isFirstControlPoint = !Beatmap.ControlPointInfo.TimingPoints.Any(); + + var group = Beatmap.ControlPointInfo.GroupAt(clock.CurrentTime, true); + + if (isFirstControlPoint) + group.Add(new TimingControlPoint()); + else + { + // Try and create matching types from the currently selected control point. + var selected = selectedGroup.Value; + + if (selected != null && !ReferenceEquals(selected, group)) + { + foreach (var controlPoint in selected.ControlPoints) + { + group.Add(controlPoint.DeepClone()); + } + } + } + + selectedGroup.Value = group; + } + } +} diff --git a/osu.Game/Screens/Edit/Timing/ControlPointSettings.cs b/osu.Game/Screens/Edit/Timing/ControlPointSettings.cs index 938c7f9cf0..b76723378f 100644 --- a/osu.Game/Screens/Edit/Timing/ControlPointSettings.cs +++ b/osu.Game/Screens/Edit/Timing/ControlPointSettings.cs @@ -6,7 +6,7 @@ using osu.Framework.Graphics; namespace osu.Game.Screens.Edit.Timing { - public class ControlPointSettings : EditorRoundedScreenSettings + public partial class ControlPointSettings : EditorRoundedScreenSettings { protected override IReadOnlyList CreateSections() => new Drawable[] { diff --git a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs index 77d875b67f..b078e3fa44 100644 --- a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs +++ b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs @@ -18,15 +18,15 @@ using osuTK; namespace osu.Game.Screens.Edit.Timing { - public class ControlPointTable : EditorTable + public partial class ControlPointTable : EditorTable { [Resolved] - private Bindable selectedGroup { get; set; } + private Bindable selectedGroup { get; set; } = null!; [Resolved] - private EditorClock clock { get; set; } + private EditorClock clock { get; set; } = null!; - public const float TIMING_COLUMN_WIDTH = 230; + public const float TIMING_COLUMN_WIDTH = 300; public IEnumerable ControlGroups { @@ -35,7 +35,7 @@ namespace osu.Game.Screens.Edit.Timing Content = null; BackgroundFlow.Clear(); - if (value?.Any() != true) + if (!value.Any()) return; foreach (var group in value) @@ -52,6 +52,8 @@ namespace osu.Game.Screens.Edit.Timing Columns = createHeaders(); Content = value.Select(createContent).ToArray().ToRectangular(); + + updateSelectedGroup(); } } @@ -59,13 +61,11 @@ namespace osu.Game.Screens.Edit.Timing { base.LoadComplete(); - selectedGroup.BindValueChanged(group => - { - // TODO: This should scroll the selected row into view. - foreach (var b in BackgroundFlow) b.Selected = b.Item == group.NewValue; - }, true); + selectedGroup.BindValueChanged(_ => updateSelectedGroup(), true); } + private void updateSelectedGroup() => SetSelectedRow(selectedGroup.Value); + private TableColumn[] createHeaders() { var columns = new List @@ -81,33 +81,39 @@ namespace osu.Game.Screens.Edit.Timing { return new Drawable[] { - new FillFlowContainer - { - RelativeSizeAxes = Axes.Y, - Width = TIMING_COLUMN_WIDTH, - Spacing = new Vector2(5), - Children = new Drawable[] - { - new OsuSpriteText - { - Text = group.Time.ToEditorFormattedString(), - Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Bold), - Width = 70, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - }, - new ControlGroupAttributes(group, c => c is TimingControlPoint) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - } - } - }, - new ControlGroupAttributes(group, c => !(c is TimingControlPoint)) + new ControlGroupTiming(group), + new ControlGroupAttributes(group, c => c is not TimingControlPoint) }; } - private class ControlGroupAttributes : CompositeDrawable + private partial class ControlGroupTiming : FillFlowContainer + { + public ControlGroupTiming(ControlPointGroup group) + { + Name = @"ControlGroupTiming"; + RelativeSizeAxes = Axes.Y; + Width = TIMING_COLUMN_WIDTH; + Spacing = new Vector2(5); + Children = new Drawable[] + { + new OsuSpriteText + { + Text = group.Time.ToEditorFormattedString(), + Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Bold), + Width = 70, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + new ControlGroupAttributes(group, c => c is TimingControlPoint) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + } + }; + } + } + + private partial class ControlGroupAttributes : CompositeDrawable { private readonly Func matchFunction; @@ -121,6 +127,7 @@ namespace osu.Game.Screens.Edit.Timing AutoSizeAxes = Axes.X; RelativeSizeAxes = Axes.Y; + Name = @"ControlGroupAttributes"; InternalChild = fill = new FillFlowContainer { @@ -142,7 +149,7 @@ namespace osu.Game.Screens.Edit.Timing protected override void LoadComplete() { base.LoadComplete(); - controlPoints.CollectionChanged += (_, __) => createChildren(); + controlPoints.CollectionChanged += (_, _) => createChildren(); } private void createChildren() @@ -150,7 +157,6 @@ namespace osu.Game.Screens.Edit.Timing fill.ChildrenEnumerable = controlPoints .Where(matchFunction) .Select(createAttribute) - .Where(c => c != null) // arbitrary ordering to make timing points first. // probably want to explicitly define order in the future. .OrderByDescending(c => c.GetType().Name); @@ -173,7 +179,7 @@ namespace osu.Game.Screens.Edit.Timing return new SampleRowAttribute(sample); } - return null; + throw new ArgumentOutOfRangeException(nameof(controlPoint), $"Control point type {controlPoint.GetType()} is not supported"); } } } diff --git a/osu.Game/Screens/Edit/Timing/EffectSection.cs b/osu.Game/Screens/Edit/Timing/EffectSection.cs index c9f73411f1..7e484433f7 100644 --- a/osu.Game/Screens/Edit/Timing/EffectSection.cs +++ b/osu.Game/Screens/Edit/Timing/EffectSection.cs @@ -5,16 +5,17 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Configuration; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Rulesets.UI.Scrolling; namespace osu.Game.Screens.Edit.Timing { - internal class EffectSection : Section + internal partial class EffectSection : Section { - private LabelledSwitchButton kiai; - private LabelledSwitchButton omitBarLine; + private LabelledSwitchButton kiai = null!; - private SliderWithTextBoxInput scrollSpeedSlider; + private SliderWithTextBoxInput scrollSpeedSlider = null!; [BackgroundDependencyLoader] private void load() @@ -22,7 +23,6 @@ namespace osu.Game.Screens.Edit.Timing Flow.AddRange(new Drawable[] { kiai = new LabelledSwitchButton { Label = "Kiai Time" }, - omitBarLine = new LabelledSwitchButton { Label = "Skip Bar Line" }, scrollSpeedSlider = new SliderWithTextBoxInput("Scroll Speed") { Current = new EffectControlPoint().ScrollSpeedBindable, @@ -36,9 +36,12 @@ namespace osu.Game.Screens.Edit.Timing base.LoadComplete(); kiai.Current.BindValueChanged(_ => saveChanges()); - omitBarLine.Current.BindValueChanged(_ => saveChanges()); scrollSpeedSlider.Current.BindValueChanged(_ => saveChanges()); + var drawableRuleset = Beatmap.BeatmapInfo.Ruleset.CreateInstance().CreateDrawableRulesetWith(Beatmap.PlayableBeatmap); + if (drawableRuleset is not IDrawableScrollingRuleset scrollingRuleset || scrollingRuleset.VisualisationMethod == ScrollVisualisationMethod.Constant) + scrollSpeedSlider.Hide(); + void saveChanges() { if (!isRebinding) ChangeHandler?.SaveState(); @@ -47,14 +50,13 @@ namespace osu.Game.Screens.Edit.Timing private bool isRebinding; - protected override void OnControlPointChanged(ValueChangedEvent point) + protected override void OnControlPointChanged(ValueChangedEvent point) { if (point.NewValue != null) { isRebinding = true; kiai.Current = point.NewValue.KiaiModeBindable; - omitBarLine.Current = point.NewValue.OmitFirstBarLineBindable; scrollSpeedSlider.Current = point.NewValue.ScrollSpeedBindable; isRebinding = false; @@ -68,7 +70,6 @@ namespace osu.Game.Screens.Edit.Timing return new EffectControlPoint { KiaiMode = reference.KiaiMode, - OmitFirstBarLine = reference.OmitFirstBarLine, ScrollSpeed = reference.ScrollSpeed, }; } diff --git a/osu.Game/Screens/Edit/Timing/GroupSection.cs b/osu.Game/Screens/Edit/Timing/GroupSection.cs index f613488aae..487a871881 100644 --- a/osu.Game/Screens/Edit/Timing/GroupSection.cs +++ b/osu.Game/Screens/Edit/Timing/GroupSection.cs @@ -13,23 +13,23 @@ using osuTK; namespace osu.Game.Screens.Edit.Timing { - internal class GroupSection : CompositeDrawable + internal partial class GroupSection : CompositeDrawable { - private LabelledTextBox textBox; + private LabelledTextBox textBox = null!; - private OsuButton button; + private OsuButton button = null!; [Resolved] - protected Bindable SelectedGroup { get; private set; } + protected Bindable SelectedGroup { get; private set; } = null!; [Resolved] - protected EditorBeatmap Beatmap { get; private set; } + protected EditorBeatmap Beatmap { get; private set; } = null!; [Resolved] - private EditorClock clock { get; set; } + private EditorClock clock { get; set; } = null!; - [Resolved(canBeNull: true)] - private IEditorChangeHandler changeHandler { get; set; } + [Resolved] + private IEditorChangeHandler? changeHandler { get; set; } [BackgroundDependencyLoader] private void load() diff --git a/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs b/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs index 16a04982f5..eabe9b9f64 100644 --- a/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs +++ b/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs @@ -21,7 +21,7 @@ namespace osu.Game.Screens.Edit.Timing /// where multiple objects with multiple different property values are selected /// by providing an "indeterminate state". /// - public class IndeterminateSliderWithTextBoxInput : CompositeDrawable, IHasCurrentValue + public partial class IndeterminateSliderWithTextBoxInput : CompositeDrawable, IHasCurrentValue where T : struct, IEquatable, IComparable, IConvertible { /// @@ -92,7 +92,20 @@ namespace osu.Game.Screens.Edit.Timing try { - slider.Current.Parse(t.Text); + switch (slider.Current) + { + case Bindable bindableInt: + bindableInt.Value = int.Parse(t.Text); + break; + + case Bindable bindableDouble: + bindableDouble.Value = double.Parse(t.Text); + break; + + default: + slider.Current.Parse(t.Text); + break; + } } catch { diff --git a/osu.Game/Screens/Edit/Timing/LabelledTimeSignature.cs b/osu.Game/Screens/Edit/Timing/LabelledTimeSignature.cs index 51b58bd3dc..d0e1737f78 100644 --- a/osu.Game/Screens/Edit/Timing/LabelledTimeSignature.cs +++ b/osu.Game/Screens/Edit/Timing/LabelledTimeSignature.cs @@ -14,7 +14,7 @@ using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Screens.Edit.Timing { - public class LabelledTimeSignature : LabelledComponent + public partial class LabelledTimeSignature : LabelledComponent { public LabelledTimeSignature() : base(false) @@ -23,7 +23,7 @@ namespace osu.Game.Screens.Edit.Timing protected override TimeSignatureBox CreateComponent() => new TimeSignatureBox(); - public class TimeSignatureBox : CompositeDrawable, IHasCurrentValue + public partial class TimeSignatureBox : CompositeDrawable, IHasCurrentValue { private readonly BindableWithCurrent current = new BindableWithCurrent(TimeSignature.SimpleQuadruple); @@ -33,7 +33,7 @@ namespace osu.Game.Screens.Edit.Timing set => current.Current = value; } - private OsuNumberBox numeratorBox; + private OsuNumberBox numeratorBox = null!; [BackgroundDependencyLoader] private void load() @@ -74,7 +74,7 @@ namespace osu.Game.Screens.Edit.Timing base.LoadComplete(); Current.BindValueChanged(_ => updateFromCurrent(), true); - numeratorBox.OnCommit += (_, __) => updateFromNumeratorBox(); + numeratorBox.OnCommit += (_, _) => updateFromNumeratorBox(); } private void updateFromCurrent() diff --git a/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs b/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs index 2ecd66a05f..f4a39405a1 100644 --- a/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs +++ b/osu.Game/Screens/Edit/Timing/MetronomeDisplay.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Threading; using osu.Framework.Timing; using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; @@ -22,28 +23,39 @@ using osuTK; namespace osu.Game.Screens.Edit.Timing { - public class MetronomeDisplay : BeatSyncedContainer + public partial class MetronomeDisplay : BeatSyncedContainer { - private Container swing; + private Container swing = null!; - private OsuSpriteText bpmText; + private OsuSpriteText bpmText = null!; - private Drawable weight; - private Drawable stick; + private Drawable weight = null!; + private Drawable stick = null!; - private IAdjustableClock metronomeClock; + private IAdjustableClock metronomeClock = null!; - private Sample clunk; + private Sample? sampleTick; + private Sample? sampleTickDownbeat; + private Sample? sampleLatch; + + private ScheduledDelegate? tickPlaybackDelegate; [Resolved] - private OverlayColourProvider overlayColourProvider { get; set; } + private OverlayColourProvider overlayColourProvider { get; set; } = null!; public bool EnableClicking { get; set; } = true; + public MetronomeDisplay() + { + AllowMistimedEventFiring = false; + } + [BackgroundDependencyLoader] private void load(AudioManager audio) { - clunk = audio.Samples.Get(@"Multiplayer/countdown-tick"); + sampleTick = audio.Samples.Get(@"UI/metronome-tick"); + sampleTickDownbeat = audio.Samples.Get(@"UI/metronome-tick-downbeat"); + sampleLatch = audio.Samples.Get(@"UI/metronome-latch"); const float taper = 25; const float swing_vertical_offset = -23; @@ -209,12 +221,14 @@ namespace osu.Game.Screens.Edit.Timing private double beatLength; - private TimingControlPoint timingPoint; + private TimingControlPoint timingPoint = null!; private bool isSwinging; private readonly BindableInt interpolatedBpm = new BindableInt(); + private ScheduledDelegate? latchDelegate; + protected override void LoadComplete() { base.LoadComplete(); @@ -249,13 +263,28 @@ namespace osu.Game.Screens.Edit.Timing { swing.ClearTransforms(true); - using (swing.BeginDelayedSequence(350)) + isSwinging = false; + + tickPlaybackDelegate?.Cancel(); + tickPlaybackDelegate = null; + + // instantly latch if pendulum arm is close enough to center (to prevent awkward delayed playback of latch sound) + if (Precision.AlmostEquals(swing.Rotation, 0, 1)) + { + swing.RotateTo(0, 60, Easing.OutQuint); + stick.FadeColour(overlayColourProvider.Colour2, 1000, Easing.OutQuint); + sampleLatch?.Play(); + return; + } + + using (BeginDelayedSequence(350)) { swing.RotateTo(0, 1000, Easing.OutQuint); stick.FadeColour(overlayColourProvider.Colour2, 1000, Easing.OutQuint); - } - isSwinging = false; + using (BeginDelayedSequence(380)) + latchDelegate = Schedule(() => sampleLatch?.Play()); + } } } @@ -270,6 +299,9 @@ namespace osu.Game.Screens.Edit.Timing isSwinging = true; + latchDelegate?.Cancel(); + latchDelegate = null; + float currentAngle = swing.Rotation; float targetAngle = currentAngle > 0 ? -angle : angle; @@ -281,18 +313,18 @@ namespace osu.Game.Screens.Edit.Timing { stick.FlashColour(overlayColourProvider.Content1, beatLength, Easing.OutQuint); - Schedule(() => + tickPlaybackDelegate = Schedule(() => { if (!EnableClicking) return; - var channel = clunk?.GetChannel(); + var channel = beatIndex % timingPoint.TimeSignature.Numerator == 0 ? sampleTickDownbeat?.GetChannel() : sampleTick?.GetChannel(); - if (channel != null) - { - channel.Frequency.Value = RNG.NextDouble(0.98f, 1.02f); - channel.Play(); - } + if (channel == null) + return; + + channel.Frequency.Value = RNG.NextDouble(0.98f, 1.02f); + channel.Play(); }); } } diff --git a/osu.Game/Screens/Edit/Timing/RepeatingButtonBehaviour.cs b/osu.Game/Screens/Edit/Timing/RepeatingButtonBehaviour.cs index 595305b20f..0437118f81 100644 --- a/osu.Game/Screens/Edit/Timing/RepeatingButtonBehaviour.cs +++ b/osu.Game/Screens/Edit/Timing/RepeatingButtonBehaviour.cs @@ -14,14 +14,17 @@ namespace osu.Game.Screens.Edit.Timing /// /// Represents a component that provides the behaviour of triggering button clicks repeatedly while holding with mouse. /// - public class RepeatingButtonBehaviour : Component + public partial class RepeatingButtonBehaviour : Component { private const double initial_delay = 300; private const double minimum_delay = 80; private readonly Drawable button; - private Sample sample; + private Sample? sample; + + public Action? RepeatBegan; + public Action? RepeatEnded; /// /// An additive modifier for the frequency of the sample played on next actuation. @@ -44,6 +47,7 @@ namespace osu.Game.Screens.Edit.Timing protected override bool OnMouseDown(MouseDownEvent e) { + RepeatBegan?.Invoke(); beginRepeat(); return true; } @@ -51,10 +55,11 @@ namespace osu.Game.Screens.Edit.Timing protected override void OnMouseUp(MouseUpEvent e) { adjustDelegate?.Cancel(); + RepeatEnded?.Invoke(); base.OnMouseUp(e); } - private ScheduledDelegate adjustDelegate; + private ScheduledDelegate? adjustDelegate; private double adjustDelay = initial_delay; private void beginRepeat() diff --git a/osu.Game/Screens/Edit/Timing/RowAttribute.cs b/osu.Game/Screens/Edit/Timing/RowAttribute.cs index 46bb62c9e0..71407701db 100644 --- a/osu.Game/Screens/Edit/Timing/RowAttribute.cs +++ b/osu.Game/Screens/Edit/Timing/RowAttribute.cs @@ -13,15 +13,15 @@ using osuTK; namespace osu.Game.Screens.Edit.Timing { - public class RowAttribute : CompositeDrawable + public partial class RowAttribute : CompositeDrawable { protected readonly ControlPoint Point; private readonly string label; - protected Drawable Background { get; private set; } + protected Drawable Background { get; private set; } = null!; - protected FillFlowContainer Content { get; private set; } + protected FillFlowContainer Content { get; private set; } = null!; public RowAttribute(ControlPoint point, string label) { diff --git a/osu.Game/Screens/Edit/Timing/RowAttributes/AttributeProgressBar.cs b/osu.Game/Screens/Edit/Timing/RowAttributes/AttributeProgressBar.cs index 6f7e790489..4cae774078 100644 --- a/osu.Game/Screens/Edit/Timing/RowAttributes/AttributeProgressBar.cs +++ b/osu.Game/Screens/Edit/Timing/RowAttributes/AttributeProgressBar.cs @@ -11,7 +11,7 @@ using osuTK; namespace osu.Game.Screens.Edit.Timing.RowAttributes { - public class AttributeProgressBar : ProgressBar + public partial class AttributeProgressBar : ProgressBar { private readonly ControlPoint controlPoint; diff --git a/osu.Game/Screens/Edit/Timing/RowAttributes/AttributeText.cs b/osu.Game/Screens/Edit/Timing/RowAttributes/AttributeText.cs index d0a51f9faa..d735c93523 100644 --- a/osu.Game/Screens/Edit/Timing/RowAttributes/AttributeText.cs +++ b/osu.Game/Screens/Edit/Timing/RowAttributes/AttributeText.cs @@ -9,7 +9,7 @@ using osu.Game.Graphics.Sprites; namespace osu.Game.Screens.Edit.Timing.RowAttributes { - public class AttributeText : OsuSpriteText + public partial class AttributeText : OsuSpriteText { private readonly ControlPoint controlPoint; diff --git a/osu.Game/Screens/Edit/Timing/RowAttributes/DifficultyRowAttribute.cs b/osu.Game/Screens/Edit/Timing/RowAttributes/DifficultyRowAttribute.cs index a8de476d67..43f3739503 100644 --- a/osu.Game/Screens/Edit/Timing/RowAttributes/DifficultyRowAttribute.cs +++ b/osu.Game/Screens/Edit/Timing/RowAttributes/DifficultyRowAttribute.cs @@ -9,11 +9,11 @@ using osu.Game.Graphics.Sprites; namespace osu.Game.Screens.Edit.Timing.RowAttributes { - public class DifficultyRowAttribute : RowAttribute + public partial class DifficultyRowAttribute : RowAttribute { private readonly BindableNumber speedMultiplier; - private OsuSpriteText text; + private OsuSpriteText text = null!; public DifficultyRowAttribute(DifficultyControlPoint difficulty) : base(difficulty, "difficulty") diff --git a/osu.Game/Screens/Edit/Timing/RowAttributes/EffectRowAttribute.cs b/osu.Game/Screens/Edit/Timing/RowAttributes/EffectRowAttribute.cs index 1b33fd62aa..ad22aa81fc 100644 --- a/osu.Game/Screens/Edit/Timing/RowAttributes/EffectRowAttribute.cs +++ b/osu.Game/Screens/Edit/Timing/RowAttributes/EffectRowAttribute.cs @@ -8,21 +8,18 @@ using osu.Game.Beatmaps.ControlPoints; namespace osu.Game.Screens.Edit.Timing.RowAttributes { - public class EffectRowAttribute : RowAttribute + public partial class EffectRowAttribute : RowAttribute { private readonly Bindable kiaiMode; - private readonly Bindable omitBarLine; private readonly BindableNumber scrollSpeed; - private AttributeText kiaiModeBubble; - private AttributeText omitBarLineBubble; - private AttributeText text; + private AttributeText kiaiModeBubble = null!; + private AttributeText text = null!; public EffectRowAttribute(EffectControlPoint effect) : base(effect, "effect") { kiaiMode = effect.KiaiModeBindable.GetBoundCopy(); - omitBarLine = effect.OmitFirstBarLineBindable.GetBoundCopy(); scrollSpeed = effect.ScrollSpeedBindable.GetBoundCopy(); } @@ -37,11 +34,9 @@ namespace osu.Game.Screens.Edit.Timing.RowAttributes }, text = new AttributeText(Point) { Width = 45 }, kiaiModeBubble = new AttributeText(Point) { Text = "kiai" }, - omitBarLineBubble = new AttributeText(Point) { Text = "no barline" }, }); kiaiMode.BindValueChanged(enabled => kiaiModeBubble.FadeTo(enabled.NewValue ? 1 : 0), true); - omitBarLine.BindValueChanged(enabled => omitBarLineBubble.FadeTo(enabled.NewValue ? 1 : 0), true); scrollSpeed.BindValueChanged(_ => updateText(), true); } diff --git a/osu.Game/Screens/Edit/Timing/RowAttributes/SampleRowAttribute.cs b/osu.Game/Screens/Edit/Timing/RowAttributes/SampleRowAttribute.cs index ac0797dba1..e86a991521 100644 --- a/osu.Game/Screens/Edit/Timing/RowAttributes/SampleRowAttribute.cs +++ b/osu.Game/Screens/Edit/Timing/RowAttributes/SampleRowAttribute.cs @@ -9,10 +9,10 @@ using osu.Game.Graphics.Sprites; namespace osu.Game.Screens.Edit.Timing.RowAttributes { - public class SampleRowAttribute : RowAttribute + public partial class SampleRowAttribute : RowAttribute { - private AttributeText sampleText; - private OsuSpriteText volumeText; + private AttributeText sampleText = null!; + private OsuSpriteText volumeText = null!; private readonly Bindable sampleBank; private readonly BindableNumber volume; diff --git a/osu.Game/Screens/Edit/Timing/RowAttributes/TimingRowAttribute.cs b/osu.Game/Screens/Edit/Timing/RowAttributes/TimingRowAttribute.cs index 8a07088545..577e7a3134 100644 --- a/osu.Game/Screens/Edit/Timing/RowAttributes/TimingRowAttribute.cs +++ b/osu.Game/Screens/Edit/Timing/RowAttributes/TimingRowAttribute.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Graphics; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Timing; using osu.Game.Graphics.Sprites; @@ -11,27 +12,35 @@ using osu.Game.Overlays; namespace osu.Game.Screens.Edit.Timing.RowAttributes { - public class TimingRowAttribute : RowAttribute + public partial class TimingRowAttribute : RowAttribute { private readonly BindableNumber beatLength; + private readonly Bindable omitBarLine; private readonly Bindable timeSignature; - private OsuSpriteText text; + private AttributeText omitBarLineBubble = null!; + private OsuSpriteText text = null!; public TimingRowAttribute(TimingControlPoint timing) : base(timing, "timing") { timeSignature = timing.TimeSignatureBindable.GetBoundCopy(); + omitBarLine = timing.OmitFirstBarLineBindable.GetBoundCopy(); beatLength = timing.BeatLengthBindable.GetBoundCopy(); } [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) { - Content.Add(text = new AttributeText(Point)); + Content.AddRange(new[] + { + text = new AttributeText(Point), + omitBarLineBubble = new AttributeText(Point) { Text = "no barline" }, + }); Background.Colour = colourProvider.Background4; timeSignature.BindValueChanged(_ => updateText()); + omitBarLine.BindValueChanged(enabled => omitBarLineBubble.FadeTo(enabled.NewValue ? 1 : 0), true); beatLength.BindValueChanged(_ => updateText(), true); } diff --git a/osu.Game/Screens/Edit/Timing/Section.cs b/osu.Game/Screens/Edit/Timing/Section.cs index 17147c21f4..ba3874dcee 100644 --- a/osu.Game/Screens/Edit/Timing/Section.cs +++ b/osu.Game/Screens/Edit/Timing/Section.cs @@ -14,26 +14,26 @@ using osuTK; namespace osu.Game.Screens.Edit.Timing { - internal abstract class Section : CompositeDrawable + internal abstract partial class Section : CompositeDrawable where T : ControlPoint { - private OsuCheckbox checkbox; - private Container content; + private OsuCheckbox checkbox = null!; + private Container content = null!; - protected FillFlowContainer Flow { get; private set; } + protected FillFlowContainer Flow { get; private set; } = null!; - protected Bindable ControlPoint { get; } = new Bindable(); + protected Bindable ControlPoint { get; } = new Bindable(); private const float header_height = 50; [Resolved] - protected EditorBeatmap Beatmap { get; private set; } + protected EditorBeatmap Beatmap { get; private set; } = null!; [Resolved] - protected Bindable SelectedGroup { get; private set; } + protected Bindable SelectedGroup { get; private set; } = null!; - [Resolved(canBeNull: true)] - protected IEditorChangeHandler ChangeHandler { get; private set; } + [Resolved] + protected IEditorChangeHandler? ChangeHandler { get; private set; } [BackgroundDependencyLoader] private void load(OverlayColourProvider colours) @@ -126,7 +126,7 @@ namespace osu.Game.Screens.Edit.Timing ControlPoint.BindValueChanged(OnControlPointChanged, true); } - protected abstract void OnControlPointChanged(ValueChangedEvent point); + protected abstract void OnControlPointChanged(ValueChangedEvent point); protected abstract T CreatePoint(); } diff --git a/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs b/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs index 9f036f0215..1bf0e5299d 100644 --- a/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs +++ b/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs @@ -15,7 +15,7 @@ using osuTK; namespace osu.Game.Screens.Edit.Timing { - public class SliderWithTextBoxInput : CompositeDrawable, IHasCurrentValue + public partial class SliderWithTextBoxInput : CompositeDrawable, IHasCurrentValue where T : struct, IEquatable, IComparable, IConvertible { private readonly SettingsSlider slider; @@ -56,7 +56,20 @@ namespace osu.Game.Screens.Edit.Timing try { - slider.Current.Parse(t.Text); + switch (slider.Current) + { + case Bindable bindableInt: + bindableInt.Value = int.Parse(t.Text); + break; + + case Bindable bindableDouble: + bindableDouble.Value = double.Parse(t.Text); + break; + + default: + slider.Current.Parse(t.Text); + break; + } } catch { @@ -68,7 +81,7 @@ namespace osu.Game.Screens.Edit.Timing Current.TriggerChange(); }; - Current.BindValueChanged(val => + Current.BindValueChanged(_ => { decimal decimalValue = slider.Current.Value.ToDecimal(NumberFormatInfo.InvariantInfo); textBox.Text = decimalValue.ToString($@"N{FormatUtils.FindPrecision(decimalValue)}"); diff --git a/osu.Game/Screens/Edit/Timing/TapButton.cs b/osu.Game/Screens/Edit/Timing/TapButton.cs index a6227cbe27..f28c6ccf0a 100644 --- a/osu.Game/Screens/Edit/Timing/TapButton.cs +++ b/osu.Game/Screens/Edit/Timing/TapButton.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 enable - using System; using System.Collections.Generic; using System.Linq; @@ -30,7 +28,7 @@ using osuTK.Input; namespace osu.Game.Screens.Edit.Timing { - internal class TapButton : CircularContainer, IKeyBindingHandler + internal partial class TapButton : CircularContainer, IKeyBindingHandler { public const float SIZE = 140; @@ -39,10 +37,10 @@ namespace osu.Game.Screens.Edit.Timing [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; - [Resolved(canBeNull: true)] + [Resolved] private Bindable? selectedGroup { get; set; } - [Resolved(canBeNull: true)] + [Resolved] private IBeatSyncProvider? beatSyncSource { get; set; } private Circle hoverLayer = null!; @@ -297,6 +295,9 @@ namespace osu.Game.Screens.Edit.Timing private void handleTap() { + if (selectedGroup?.Value == null) + return; + tapTimings.Add(Clock.CurrentTime); if (tapTimings.Count > initial_taps_to_ignore + max_taps_to_consider) @@ -342,7 +343,7 @@ namespace osu.Game.Screens.Edit.Timing IsHandlingTapping.Value = false; } - private class Light : CompositeDrawable + private partial class Light : CompositeDrawable { public Drawable Glow { get; private set; } = null!; diff --git a/osu.Game/Screens/Edit/Timing/TapTimingControl.cs b/osu.Game/Screens/Edit/Timing/TapTimingControl.cs index 9b5574d3cb..bb7a3b8be3 100644 --- a/osu.Game/Screens/Edit/Timing/TapTimingControl.cs +++ b/osu.Game/Screens/Edit/Timing/TapTimingControl.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 enable - using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -20,7 +18,7 @@ using osuTK; namespace osu.Game.Screens.Edit.Timing { - public class TapTimingControl : CompositeDrawable + public partial class TapTimingControl : CompositeDrawable { [Resolved] private EditorClock editorClock { get; set; } = null!; @@ -90,7 +88,7 @@ namespace osu.Game.Screens.Edit.Timing Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, }, - new WaveformComparisonDisplay(), + new WaveformComparisonDisplay() } }, } @@ -185,18 +183,29 @@ namespace osu.Game.Screens.Edit.Timing private void start() { + if (selectedGroup.Value == null) + return; + editorClock.Seek(selectedGroup.Value.Time); editorClock.Start(); } private void reset() { + if (selectedGroup.Value == null) + return; + editorClock.Stop(); editorClock.Seek(selectedGroup.Value.Time); } private void adjustOffset(double adjust) { + if (selectedGroup.Value == null) + return; + + bool wasAtStart = editorClock.CurrentTimeAccurate == selectedGroup.Value.Time; + // VERY TEMPORARY var currentGroupItems = selectedGroup.Value.ControlPoints.ToArray(); @@ -210,13 +219,13 @@ namespace osu.Game.Screens.Edit.Timing // the control point might not necessarily exist yet, if currentGroupItems was empty. selectedGroup.Value = beatmap.ControlPointInfo.GroupAt(newOffset, true); - if (!editorClock.IsRunning) + if (!editorClock.IsRunning && wasAtStart) editorClock.Seek(newOffset); } private void adjustBpm(double adjust) { - var timing = selectedGroup.Value.ControlPoints.OfType().FirstOrDefault(); + var timing = selectedGroup.Value?.ControlPoints.OfType().FirstOrDefault(); if (timing == null) return; @@ -224,7 +233,7 @@ namespace osu.Game.Screens.Edit.Timing timing.BeatLength = 60000 / (timing.BPM + adjust); } - private class InlineButton : OsuButton + private partial class InlineButton : OsuButton { private readonly IconUsage icon; private readonly Anchor anchor; diff --git a/osu.Game/Screens/Edit/Timing/TimingAdjustButton.cs b/osu.Game/Screens/Edit/Timing/TimingAdjustButton.cs index 9540547d89..fac168c70c 100644 --- a/osu.Game/Screens/Edit/Timing/TimingAdjustButton.cs +++ b/osu.Game/Screens/Edit/Timing/TimingAdjustButton.cs @@ -18,9 +18,9 @@ namespace osu.Game.Screens.Edit.Timing /// /// A button with variable constant output based on hold position and length. /// - public class TimingAdjustButton : CompositeDrawable + public partial class TimingAdjustButton : CompositeDrawable { - public Action Action; + public Action? Action; private readonly double adjustAmount; @@ -42,7 +42,10 @@ namespace osu.Game.Screens.Edit.Timing private readonly RepeatingButtonBehaviour repeatBehaviour; [Resolved] - private OverlayColourProvider colourProvider { get; set; } + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } = null!; public TimingAdjustButton(double adjustAmount) { @@ -72,7 +75,11 @@ namespace osu.Game.Screens.Edit.Timing } }); - AddInternal(repeatBehaviour = new RepeatingButtonBehaviour(this)); + AddInternal(repeatBehaviour = new RepeatingButtonBehaviour(this) + { + RepeatBegan = () => editorBeatmap.BeginChange(), + RepeatEnded = () => editorBeatmap.EndChange() + }); } [BackgroundDependencyLoader] @@ -95,7 +102,7 @@ namespace osu.Game.Screens.Edit.Timing if (hoveredBox == null) return false; - Action(adjustAmount * hoveredBox.Multiplier); + Action?.Invoke(adjustAmount * hoveredBox.Multiplier); hoveredBox.Flash(); @@ -103,13 +110,16 @@ namespace osu.Game.Screens.Edit.Timing return true; } - private class IncrementBox : CompositeDrawable + private partial class IncrementBox : CompositeDrawable { public readonly float Multiplier; private readonly Box box; private readonly OsuSpriteText text; + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + public IncrementBox(int index, double amount) { Multiplier = Math.Sign(index) * convertMultiplier(index); @@ -147,9 +157,6 @@ namespace osu.Game.Screens.Edit.Timing }; } - [Resolved] - private OverlayColourProvider colourProvider { get; set; } - protected override void LoadComplete() { base.LoadComplete(); diff --git a/osu.Game/Screens/Edit/Timing/TimingScreen.cs b/osu.Game/Screens/Edit/Timing/TimingScreen.cs index f498aa917e..3f911f5067 100644 --- a/osu.Game/Screens/Edit/Timing/TimingScreen.cs +++ b/osu.Game/Screens/Edit/Timing/TimingScreen.cs @@ -1,28 +1,22 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Events; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; -using osu.Game.Graphics.UserInterfaceV2; -using osu.Game.Overlays; -using osuTK; namespace osu.Game.Screens.Edit.Timing { - public class TimingScreen : EditorScreenWithTimeline + public partial class TimingScreen : EditorScreenWithTimeline { [Cached] public readonly Bindable SelectedGroup = new Bindable(); + [Resolved] + private EditorClock? editorClock { get; set; } + public TimingScreen() : base(EditorScreenMode.Timing) { @@ -46,190 +40,17 @@ namespace osu.Game.Screens.Edit.Timing } }; - public class ControlPointList : CompositeDrawable + protected override void LoadComplete() { - private OsuButton deleteButton; - private ControlPointTable table; + base.LoadComplete(); - private readonly IBindableList controlPointGroups = new BindableList(); - - private RoundedButton addButton; - - [Resolved] - private EditorClock clock { get; set; } - - [Resolved] - protected EditorBeatmap Beatmap { get; private set; } - - [Resolved] - private Bindable selectedGroup { get; set; } - - [Resolved(canBeNull: true)] - private IEditorChangeHandler changeHandler { get; set; } - - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colours) + if (editorClock != null) { - RelativeSizeAxes = Axes.Both; - - const float margins = 10; - InternalChildren = new Drawable[] - { - new Box - { - Colour = colours.Background4, - RelativeSizeAxes = Axes.Both, - }, - new Box - { - Colour = colours.Background3, - RelativeSizeAxes = Axes.Y, - Width = ControlPointTable.TIMING_COLUMN_WIDTH + margins, - }, - new OsuScrollContainer - { - RelativeSizeAxes = Axes.Both, - Child = table = new ControlPointTable(), - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Direction = FillDirection.Horizontal, - Margin = new MarginPadding(margins), - Spacing = new Vector2(5), - Children = new Drawable[] - { - deleteButton = new RoundedButton - { - Text = "-", - Size = new Vector2(30, 30), - Action = delete, - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - }, - addButton = new RoundedButton - { - Action = addNew, - Size = new Vector2(160, 30), - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - }, - } - }, - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - selectedGroup.BindValueChanged(selected => - { - deleteButton.Enabled.Value = selected.NewValue != null; - - addButton.Text = selected.NewValue != null - ? "+ Clone to current time" - : "+ Add at current time"; - }, true); - - controlPointGroups.BindTo(Beatmap.ControlPointInfo.Groups); - controlPointGroups.BindCollectionChanged((sender, args) => - { - table.ControlGroups = controlPointGroups; - changeHandler?.SaveState(); - }, true); - } - - protected override bool OnClick(ClickEvent e) - { - selectedGroup.Value = null; - return true; - } - - protected override void Update() - { - base.Update(); - - trackActivePoint(); - - addButton.Enabled.Value = clock.CurrentTimeAccurate != selectedGroup.Value?.Time; - } - - private Type trackedType; - - /// - /// Given the user has selected a control point group, we want to track any group which is - /// active at the current point in time which matches the type the user has selected. - /// - /// So if the user is currently looking at a timing point and seeks into the future, a - /// future timing point would be automatically selected if it is now the new "current" point. - /// - private void trackActivePoint() - { - // For simplicity only match on the first type of the active control point. - if (selectedGroup.Value == null) - trackedType = null; - else - { - // If the selected group only has one control point, update the tracking type. - if (selectedGroup.Value.ControlPoints.Count == 1) - trackedType = selectedGroup.Value?.ControlPoints.Single().GetType(); - // If the selected group has more than one control point, choose the first as the tracking type - // if we don't already have a singular tracked type. - else if (trackedType == null) - trackedType = selectedGroup.Value?.ControlPoints.FirstOrDefault()?.GetType(); - } - - if (trackedType != null) - { - // We don't have an efficient way of looking up groups currently, only individual point types. - // To improve the efficiency of this in the future, we should reconsider the overall structure of ControlPointInfo. - - // Find the next group which has the same type as the selected one. - var found = Beatmap.ControlPointInfo.Groups - .Where(g => g.ControlPoints.Any(cp => cp.GetType() == trackedType)) - .LastOrDefault(g => g.Time <= clock.CurrentTimeAccurate); - - if (found != null) - selectedGroup.Value = found; - } - } - - private void delete() - { - if (selectedGroup.Value == null) - return; - - Beatmap.ControlPointInfo.RemoveGroup(selectedGroup.Value); - - selectedGroup.Value = Beatmap.ControlPointInfo.Groups.FirstOrDefault(g => g.Time >= clock.CurrentTime); - } - - private void addNew() - { - bool isFirstControlPoint = !Beatmap.ControlPointInfo.TimingPoints.Any(); - - var group = Beatmap.ControlPointInfo.GroupAt(clock.CurrentTime, true); - - if (isFirstControlPoint) - group.Add(new TimingControlPoint()); - else - { - // Try and create matching types from the currently selected control point. - var selected = selectedGroup.Value; - - if (selected != null && selected != group) - { - foreach (var controlPoint in selected.ControlPoints) - { - group.Add(controlPoint.DeepClone()); - } - } - } - - selectedGroup.Value = group; + // When entering the timing screen, let's choose the closest valid timing point. + // This will emulate the osu-stable behaviour where a metronome and timing information + // are presented on entering the screen. + var nearestTimingPoint = EditorBeatmap.ControlPointInfo.TimingPointAt(editorClock.CurrentTime); + SelectedGroup.Value = EditorBeatmap.ControlPointInfo.GroupAt(nearestTimingPoint.Time); } } } diff --git a/osu.Game/Screens/Edit/Timing/TimingSection.cs b/osu.Game/Screens/Edit/Timing/TimingSection.cs index 1a97058d73..2757753b07 100644 --- a/osu.Game/Screens/Edit/Timing/TimingSection.cs +++ b/osu.Game/Screens/Edit/Timing/TimingSection.cs @@ -9,10 +9,11 @@ using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Screens.Edit.Timing { - internal class TimingSection : Section + internal partial class TimingSection : Section { - private LabelledTimeSignature timeSignature; - private BPMTextBox bpmTextEntry; + private LabelledTimeSignature timeSignature = null!; + private LabelledSwitchButton omitBarLine = null!; + private BPMTextBox bpmTextEntry = null!; [BackgroundDependencyLoader] private void load() @@ -24,7 +25,8 @@ namespace osu.Game.Screens.Edit.Timing timeSignature = new LabelledTimeSignature { Label = "Time Signature" - } + }, + omitBarLine = new LabelledSwitchButton { Label = "Skip Bar Line" }, }); } @@ -33,6 +35,7 @@ namespace osu.Game.Screens.Edit.Timing base.LoadComplete(); bpmTextEntry.Current.BindValueChanged(_ => saveChanges()); + omitBarLine.Current.BindValueChanged(_ => saveChanges()); timeSignature.Current.BindValueChanged(_ => saveChanges()); void saveChanges() @@ -43,7 +46,7 @@ namespace osu.Game.Screens.Edit.Timing private bool isRebinding; - protected override void OnControlPointChanged(ValueChangedEvent point) + protected override void OnControlPointChanged(ValueChangedEvent point) { if (point.NewValue != null) { @@ -51,6 +54,7 @@ namespace osu.Game.Screens.Edit.Timing bpmTextEntry.Bindable = point.NewValue.BeatLengthBindable; timeSignature.Current = point.NewValue.TimeSignatureBindable; + omitBarLine.Current = point.NewValue.OmitFirstBarLineBindable; isRebinding = false; } @@ -63,11 +67,12 @@ namespace osu.Game.Screens.Edit.Timing return new TimingControlPoint { BeatLength = reference.BeatLength, - TimeSignature = reference.TimeSignature + TimeSignature = reference.TimeSignature, + OmitFirstBarLine = reference.OmitFirstBarLine, }; } - private class BPMTextBox : LabelledTextBox + private partial class BPMTextBox : LabelledTextBox { private readonly BindableNumber beatLengthBindable = new TimingControlPoint().BeatLengthBindable; @@ -75,7 +80,7 @@ namespace osu.Game.Screens.Edit.Timing { Label = "BPM"; - OnCommit += (val, isNew) => + OnCommit += (_, isNew) => { if (!isNew) return; diff --git a/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs b/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs index 0745187e43..3b3acea935 100644 --- a/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs +++ b/osu.Game/Screens/Edit/Timing/WaveformComparisonDisplay.cs @@ -1,11 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - using System; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Audio; @@ -23,7 +22,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Edit.Timing { - internal class WaveformComparisonDisplay : CompositeDrawable + internal partial class WaveformComparisonDisplay : CompositeDrawable { private const int total_waveforms = 8; @@ -93,7 +92,7 @@ namespace osu.Game.Screens.Edit.Timing selectedGroup.BindValueChanged(_ => updateTimingGroup(), true); controlPointGroups.BindTo(editorBeatmap.ControlPointInfo.Groups); - controlPointGroups.BindCollectionChanged((_, __) => updateTimingGroup()); + controlPointGroups.BindCollectionChanged((_, _) => updateTimingGroup()); beatLength.BindValueChanged(_ => regenerateDisplay(true), true); @@ -130,7 +129,7 @@ namespace osu.Game.Screens.Edit.Timing double? offsetChange = newStartTime - selectedGroupStartTime; var nextGroup = editorBeatmap.ControlPointInfo.TimingPoints - .SkipWhile(g => g != tcp) + .SkipWhile(g => !ReferenceEquals(g, tcp)) .Skip(1) .FirstOrDefault(); @@ -152,7 +151,7 @@ namespace osu.Game.Screens.Edit.Timing if (!displayLocked.Value) { float trackLength = (float)beatmap.Value.Track.Length; - int totalBeatsAvailable = (int)(trackLength / timingPoint.BeatLength); + int totalBeatsAvailable = (int)((trackLength - timingPoint.Time) / timingPoint.BeatLength); Scheduler.AddOnce(showFromBeat, (int)(e.MousePosition.X / DrawWidth * totalBeatsAvailable)); } @@ -224,7 +223,7 @@ namespace osu.Game.Screens.Edit.Timing } } - internal class LockedOverlay : CompositeDrawable + internal partial class LockedOverlay : CompositeDrawable { private OsuSpriteText text = null!; @@ -287,7 +286,7 @@ namespace osu.Game.Screens.Edit.Timing } } - internal class WaveformRow : CompositeDrawable + internal partial class WaveformRow : CompositeDrawable { private readonly bool isMainRow; private OsuSpriteText beatIndexText = null!; @@ -296,13 +295,18 @@ namespace osu.Game.Screens.Edit.Timing [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; + [Resolved] + private IBindable beatmap { get; set; } = null!; + + private readonly IBindable track = new Bindable(); + public WaveformRow(bool isMainRow) { this.isMainRow = isMainRow; } [BackgroundDependencyLoader] - private void load(IBindable beatmap) + private void load(EditorClock clock) { InternalChildren = new Drawable[] { @@ -332,6 +336,13 @@ namespace osu.Game.Screens.Edit.Timing Colour = colourProvider.Content2 } }; + + track.BindTo(clock.Track); + } + + protected override void LoadComplete() + { + track.ValueChanged += _ => waveformGraph.Waveform = beatmap.Value.Waveform; } public int BeatIndex { set => beatIndexText.Text = value.ToString(); } diff --git a/osu.Game/Screens/Edit/TransactionalCommitComponent.cs b/osu.Game/Screens/Edit/TransactionalCommitComponent.cs index 3d3539ee2f..92f1e19e6f 100644 --- a/osu.Game/Screens/Edit/TransactionalCommitComponent.cs +++ b/osu.Game/Screens/Edit/TransactionalCommitComponent.cs @@ -9,22 +9,22 @@ namespace osu.Game.Screens.Edit /// /// A component that tracks a batch change, only applying after all active changes are completed. /// - public abstract class TransactionalCommitComponent : Component + public abstract partial class TransactionalCommitComponent : Component { /// /// Fires whenever a transaction begins. Will not fire on nested transactions. /// - public event Action TransactionBegan; + public event Action? TransactionBegan; /// /// Fires when the last transaction completes. /// - public event Action TransactionEnded; + public event Action? TransactionEnded; /// /// Fires when is called and results in a non-transactional state save. /// - public event Action SaveStateTriggered; + public event Action? SaveStateTriggered; public bool TransactionActive => bulkChangesStarted > 0; @@ -33,7 +33,7 @@ namespace osu.Game.Screens.Edit /// /// Signal the beginning of a change. /// - public void BeginChange() + public virtual void BeginChange() { if (bulkChangesStarted++ == 0) TransactionBegan?.Invoke(); diff --git a/osu.Game/Screens/Edit/Verify/InterpretationSection.cs b/osu.Game/Screens/Edit/Verify/InterpretationSection.cs index 9548f8aaa9..5b6eea098c 100644 --- a/osu.Game/Screens/Edit/Verify/InterpretationSection.cs +++ b/osu.Game/Screens/Edit/Verify/InterpretationSection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Beatmaps; @@ -8,7 +10,7 @@ using osu.Game.Overlays.Settings; namespace osu.Game.Screens.Edit.Verify { - internal class InterpretationSection : EditorRoundedScreenSettingsSection + internal partial class InterpretationSection : EditorRoundedScreenSettingsSection { protected override string HeaderText => "Interpretation"; diff --git a/osu.Game/Screens/Edit/Verify/IssueList.cs b/osu.Game/Screens/Edit/Verify/IssueList.cs index 84b2609a61..907949aee8 100644 --- a/osu.Game/Screens/Edit/Verify/IssueList.cs +++ b/osu.Game/Screens/Edit/Verify/IssueList.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -19,7 +21,7 @@ using osuTK; namespace osu.Game.Screens.Edit.Verify { [Cached] - public class IssueList : CompositeDrawable + public partial class IssueList : CompositeDrawable { private IssueTable table; @@ -85,7 +87,7 @@ namespace osu.Game.Screens.Edit.Verify base.LoadComplete(); verify.InterpretedDifficulty.BindValueChanged(_ => refresh()); - verify.HiddenIssueTypes.BindCollectionChanged((_, __) => refresh()); + verify.HiddenIssueTypes.BindCollectionChanged((_, _) => refresh()); refresh(); } diff --git a/osu.Game/Screens/Edit/Verify/IssueSettings.cs b/osu.Game/Screens/Edit/Verify/IssueSettings.cs index ae3ef7e0b0..e8275c3684 100644 --- a/osu.Game/Screens/Edit/Verify/IssueSettings.cs +++ b/osu.Game/Screens/Edit/Verify/IssueSettings.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Graphics; namespace osu.Game.Screens.Edit.Verify { - public class IssueSettings : EditorRoundedScreenSettings + public partial class IssueSettings : EditorRoundedScreenSettings { protected override IReadOnlyList CreateSections() => new Drawable[] { diff --git a/osu.Game/Screens/Edit/Verify/IssueTable.cs b/osu.Game/Screens/Edit/Verify/IssueTable.cs index b38a62d838..ba5f98a772 100644 --- a/osu.Game/Screens/Edit/Verify/IssueTable.cs +++ b/osu.Game/Screens/Edit/Verify/IssueTable.cs @@ -16,21 +16,21 @@ using osu.Game.Rulesets.Edit.Checks.Components; namespace osu.Game.Screens.Edit.Verify { - public class IssueTable : EditorTable + public partial class IssueTable : EditorTable { - [Resolved] - private VerifyScreen verify { get; set; } - - private Bindable selectedIssue; + private Bindable selectedIssue = null!; [Resolved] - private EditorClock clock { get; set; } + private VerifyScreen verify { get; set; } = null!; [Resolved] - private EditorBeatmap editorBeatmap { get; set; } + private EditorClock clock { get; set; } = null!; [Resolved] - private Editor editor { get; set; } + private EditorBeatmap editorBeatmap { get; set; } = null!; + + [Resolved] + private Editor editor { get; set; } = null!; public IEnumerable Issues { @@ -39,7 +39,7 @@ namespace osu.Game.Screens.Edit.Verify Content = null; BackgroundFlow.Clear(); - if (value == null) + if (!value.Any()) return; foreach (var issue in value) @@ -77,7 +77,7 @@ namespace osu.Game.Screens.Edit.Verify selectedIssue = verify.SelectedIssue.GetBoundCopy(); selectedIssue.BindValueChanged(issue => { - foreach (var b in BackgroundFlow) b.Selected = b.Item == issue.NewValue; + SetSelectedRow(issue.NewValue); }, true); } diff --git a/osu.Game/Screens/Edit/Verify/VerifyScreen.cs b/osu.Game/Screens/Edit/Verify/VerifyScreen.cs index 56e16bb746..b17cf3379e 100644 --- a/osu.Game/Screens/Edit/Verify/VerifyScreen.cs +++ b/osu.Game/Screens/Edit/Verify/VerifyScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osu.Game.Rulesets.Edit.Checks.Components; namespace osu.Game.Screens.Edit.Verify { [Cached] - public class VerifyScreen : EditorScreen + public partial class VerifyScreen : EditorScreen { public readonly Bindable SelectedIssue = new Bindable(); @@ -29,7 +31,7 @@ namespace osu.Game.Screens.Edit.Verify [BackgroundDependencyLoader] private void load() { - InterpretedDifficulty.Default = BeatmapDifficultyCache.GetDifficultyRating(EditorBeatmap.BeatmapInfo.StarRating); + InterpretedDifficulty.Default = StarDifficulty.GetDifficultyRating(EditorBeatmap.BeatmapInfo.StarRating); InterpretedDifficulty.SetDefault(); Child = new Container diff --git a/osu.Game/Screens/Edit/Verify/VisibilitySection.cs b/osu.Game/Screens/Edit/Verify/VisibilitySection.cs index 0bdc8c0efd..e2e2e518ae 100644 --- a/osu.Game/Screens/Edit/Verify/VisibilitySection.cs +++ b/osu.Game/Screens/Edit/Verify/VisibilitySection.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -9,7 +11,7 @@ using osu.Game.Rulesets.Edit.Checks.Components; namespace osu.Game.Screens.Edit.Verify { - internal class VisibilitySection : EditorRoundedScreenSettingsSection + internal partial class VisibilitySection : EditorRoundedScreenSettingsSection { private readonly IssueType[] configurableIssueTypes = { diff --git a/osu.Game/Screens/Edit/WaveformOpacityMenuItem.cs b/osu.Game/Screens/Edit/WaveformOpacityMenuItem.cs index 7e095f526e..5b1d7142e4 100644 --- a/osu.Game/Screens/Edit/WaveformOpacityMenuItem.cs +++ b/osu.Game/Screens/Edit/WaveformOpacityMenuItem.cs @@ -1,10 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Bindables; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; namespace osu.Game.Screens.Edit { @@ -15,7 +18,7 @@ namespace osu.Game.Screens.Edit private readonly Dictionary menuItemLookup = new Dictionary(); public WaveformOpacityMenuItem(Bindable waveformOpacity) - : base("Waveform opacity") + : base(EditorStrings.WaveformOpacity) { Items = new[] { diff --git a/osu.Game/Screens/IHandlePresentBeatmap.cs b/osu.Game/Screens/IHandlePresentBeatmap.cs index 60801fb3eb..62cd2c3d3e 100644 --- a/osu.Game/Screens/IHandlePresentBeatmap.cs +++ b/osu.Game/Screens/IHandlePresentBeatmap.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Beatmaps; using osu.Game.Rulesets; diff --git a/osu.Game/Screens/IHasSubScreenStack.cs b/osu.Game/Screens/IHasSubScreenStack.cs index c5e2015109..325702313b 100644 --- a/osu.Game/Screens/IHasSubScreenStack.cs +++ b/osu.Game/Screens/IHasSubScreenStack.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Screens; namespace osu.Game.Screens diff --git a/osu.Game/Screens/IOsuScreen.cs b/osu.Game/Screens/IOsuScreen.cs index 910a0c7d61..a5739a41b1 100644 --- a/osu.Game/Screens/IOsuScreen.cs +++ b/osu.Game/Screens/IOsuScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Screens; using osu.Game.Beatmaps; @@ -39,6 +41,11 @@ namespace osu.Game.Screens /// bool HideOverlaysOnEnter { get; } + /// + /// Whether the menu cursor should be hidden when non-mouse input is received. + /// + bool HideMenuCursorOnNonMouseInput { get; } + /// /// Whether overlays should be able to be opened when this screen is current. /// diff --git a/osu.Game/Screens/IPerformFromScreenRunner.cs b/osu.Game/Screens/IPerformFromScreenRunner.cs index 655bebdeb0..c12a52a784 100644 --- a/osu.Game/Screens/IPerformFromScreenRunner.cs +++ b/osu.Game/Screens/IPerformFromScreenRunner.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using osu.Framework.Allocation; diff --git a/osu.Game/Screens/Import/FileImportScreen.cs b/osu.Game/Screens/Import/FileImportScreen.cs index 32ce54aa29..6b7a269d12 100644 --- a/osu.Game/Screens/Import/FileImportScreen.cs +++ b/osu.Game/Screens/Import/FileImportScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.IO; using System.Linq; using System.Threading.Tasks; @@ -12,13 +14,12 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Screens; using osu.Game.Graphics; using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osuTK; namespace osu.Game.Screens.Import { - public class FileImportScreen : OsuScreen + public partial class FileImportScreen : OsuScreen { public override bool HideOverlaysOnEnter => true; @@ -26,7 +27,7 @@ namespace osu.Game.Screens.Import private Container contentContainer; private TextFlowContainer currentFileText; - private TriangleButton importButton; + private RoundedButton importButton; private const float duration = 300; private const float button_height = 50; @@ -98,7 +99,7 @@ namespace osu.Game.Screens.Import } }, }, - importButton = new TriangleButton + importButton = new RoundedButton { Text = "Import", Anchor = Anchor.BottomCentre, diff --git a/osu.Game/Screens/Loader.cs b/osu.Game/Screens/Loader.cs index 52e83c9e98..372cfe748e 100644 --- a/osu.Game/Screens/Loader.cs +++ b/osu.Game/Screens/Loader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -12,13 +14,12 @@ using osu.Game.Screens.Menu; using osu.Framework.Screens; using osu.Framework.Threading; using osu.Game.Configuration; -using osu.Game.Database; using osu.Game.Graphics.UserInterface; using IntroSequence = osu.Game.Configuration.IntroSequence; namespace osu.Game.Screens { - public class Loader : StartupScreen + public partial class Loader : StartupScreen { private bool showDisclaimer; @@ -64,32 +65,13 @@ namespace osu.Game.Screens protected virtual ShaderPrecompiler CreateShaderPrecompiler() => new ShaderPrecompiler(); - [Resolved(canBeNull: true)] - private DatabaseContextFactory efContextFactory { get; set; } - - private EFToRealmMigrator realmMigrator; - public override void OnEntering(ScreenTransitionEvent e) { base.OnEntering(e); LoadComponentAsync(precompiler = CreateShaderPrecompiler(), AddInternal); - // A non-null context factory means there's still content to migrate. - if (efContextFactory != null) - { - LoadComponentAsync(realmMigrator = new EFToRealmMigrator(), AddInternal); - realmMigrator.MigrationCompleted.ContinueWith(_ => Schedule(() => - { - // Delay initial screen loading to ensure that the migration is in a complete and sane state - // before the intro screen may import the game intro beatmap. - LoadComponentAsync(loadableScreen = CreateLoadableScreen()); - })); - } - else - { - LoadComponentAsync(loadableScreen = CreateLoadableScreen()); - } + LoadComponentAsync(loadableScreen = CreateLoadableScreen()); LoadComponentAsync(spinner = new LoadingSpinner(true, true) { @@ -134,7 +116,7 @@ namespace osu.Game.Screens /// /// Compiles a set of shaders before continuing. Attempts to draw some frames between compilation by limiting to one compile per draw frame. /// - public class ShaderPrecompiler : Drawable + public partial class ShaderPrecompiler : Drawable { private readonly List loadTargets = new List(); @@ -143,13 +125,13 @@ namespace osu.Game.Screens [BackgroundDependencyLoader] private void load(ShaderManager manager) { - loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE_ROUNDED)); - loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.BLUR)); loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE)); + loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.BLUR)); loadTargets.Add(manager.Load(@"CursorTrail", FragmentShaderDescriptor.TEXTURE)); - loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_3, FragmentShaderDescriptor.TEXTURE_ROUNDED)); + loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_2, "TriangleBorder")); + loadTargets.Add(manager.Load(VertexShaderDescriptor.TEXTURE_3, FragmentShaderDescriptor.TEXTURE)); } diff --git a/osu.Game/Screens/Menu/ButtonArea.cs b/osu.Game/Screens/Menu/ButtonArea.cs index d59996a4eb..69ba68442f 100644 --- a/osu.Game/Screens/Menu/ButtonArea.cs +++ b/osu.Game/Screens/Menu/ButtonArea.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osuTK; namespace osu.Game.Screens.Menu { - public class ButtonArea : Container, IStateful + public partial class ButtonArea : Container, IStateful { public FlowContainerWithOrigin Flow; @@ -88,7 +90,7 @@ namespace osu.Game.Screens.Menu public event Action StateChanged; - private class ButtonAreaBackground : Box, IStateful + private partial class ButtonAreaBackground : Box, IStateful { private ButtonAreaBackgroundState state; diff --git a/osu.Game/Screens/Menu/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs index b48aef330a..2ead18c3d6 100644 --- a/osu.Game/Screens/Menu/ButtonSystem.cs +++ b/osu.Game/Screens/Menu/ButtonSystem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -32,7 +34,7 @@ using osuTK.Input; namespace osu.Game.Screens.Menu { - public class ButtonSystem : Container, IStateful, IKeyBindingHandler + public partial class ButtonSystem : Container, IStateful, IKeyBindingHandler { public event Action StateChanged; @@ -189,20 +191,48 @@ namespace osu.Game.Screens.Menu State = ButtonSystemState.Initial; } - protected override bool OnKeyDown(KeyDownEvent e) + /// + /// Triggers the if the current is . + /// + /// true if the was triggered, false otherwise. + private bool triggerInitialOsuLogo() { - if (e.Repeat || e.ControlPressed || e.ShiftPressed || e.AltPressed || e.SuperPressed) - return false; - if (State == ButtonSystemState.Initial) { logo?.TriggerClick(); return true; } + return false; + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Repeat || e.ControlPressed || e.ShiftPressed || e.AltPressed || e.SuperPressed) + return false; + + if (triggerInitialOsuLogo()) + return true; + return base.OnKeyDown(e); } + protected override bool OnJoystickPress(JoystickPressEvent e) + { + if (triggerInitialOsuLogo()) + return true; + + return base.OnJoystickPress(e); + } + + protected override bool OnMidiDown(MidiDownEvent e) + { + if (triggerInitialOsuLogo()) + return true; + + return base.OnMidiDown(e); + } + public bool OnPressed(KeyBindingPressEvent e) { if (e.Repeat) diff --git a/osu.Game/Screens/Menu/ConfirmDiscardChangesDialog.cs b/osu.Game/Screens/Menu/ConfirmDiscardChangesDialog.cs new file mode 100644 index 0000000000..0cd3e9ce71 --- /dev/null +++ b/osu.Game/Screens/Menu/ConfirmDiscardChangesDialog.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics.Sprites; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Screens.Menu +{ + public partial class ConfirmDiscardChangesDialog : PopupDialog + { + /// + /// Construct a new discard changes confirmation dialog. + /// + /// An action to perform on confirmation. + /// An optional action to perform on cancel. + public ConfirmDiscardChangesDialog(Action onConfirm, Action? onCancel = null) + { + HeaderText = "Are you sure you want to go back?"; + BodyText = "This will discard any unsaved changes"; + + Icon = FontAwesome.Solid.ExclamationTriangle; + + Buttons = new PopupDialogButton[] + { + new PopupDialogDangerousButton + { + Text = @"Yes", + Action = onConfirm + }, + new PopupDialogCancelButton + { + Text = @"No I didn't mean to", + Action = onCancel + }, + }; + } + } +} diff --git a/osu.Game/Screens/Menu/ConfirmExitDialog.cs b/osu.Game/Screens/Menu/ConfirmExitDialog.cs index 6488a2fd63..4906232d21 100644 --- a/osu.Game/Screens/Menu/ConfirmExitDialog.cs +++ b/osu.Game/Screens/Menu/ConfirmExitDialog.cs @@ -7,14 +7,14 @@ using osu.Game.Overlays.Dialog; namespace osu.Game.Screens.Menu { - public class ConfirmExitDialog : PopupDialog + public partial class ConfirmExitDialog : PopupDialog { /// /// Construct a new exit confirmation dialog. /// /// An action to perform on confirmation. /// An optional action to perform on cancel. - public ConfirmExitDialog(Action onConfirm, Action onCancel = null) + public ConfirmExitDialog(Action onConfirm, Action? onCancel = null) { HeaderText = "Are you sure you want to exit osu!?"; BodyText = "Last chance to turn back"; diff --git a/osu.Game/Screens/Menu/Disclaimer.cs b/osu.Game/Screens/Menu/Disclaimer.cs index 24412cd85e..539d58d2d7 100644 --- a/osu.Game/Screens/Menu/Disclaimer.cs +++ b/osu.Game/Screens/Menu/Disclaimer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -20,7 +22,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Menu { - public class Disclaimer : StartupScreen + public partial class Disclaimer : StartupScreen { private SpriteIcon icon; private Color4 iconColour; @@ -146,16 +148,17 @@ namespace osu.Game.Screens.Menu supportFlow.AddText(" to help support osu!'s development", formatSemiBold); } - heart = supportFlow.AddIcon(FontAwesome.Solid.Heart, t => + supportFlow.AddIcon(FontAwesome.Solid.Heart, t => { + heart = t; + t.Padding = new MarginPadding { Left = 5, Top = 3 }; t.Font = t.Font.With(size: 20); t.Origin = Anchor.Centre; t.Colour = colours.Pink; - }).Drawables.First(); - if (IsLoaded) - animateHeart(); + Schedule(() => heart?.FlashColour(Color4.White, 750, Easing.OutQuint).Loop()); + }); if (supportFlow.IsPresent) supportFlow.FadeInFromZero(500); @@ -171,6 +174,14 @@ namespace osu.Game.Screens.Menu ((IBindable)currentUser).BindTo(api.LocalUser); } + public override void OnSuspending(ScreenTransitionEvent e) + { + base.OnSuspending(e); + + // Once this screen has finished being displayed, we don't want to unnecessarily handle user change events. + currentUser.UnbindAll(); + } + public override void OnEntering(ScreenTransitionEvent e) { base.OnEntering(e); @@ -206,14 +217,12 @@ namespace osu.Game.Screens.Menu foreach (var c in textFlow.Children) c.FadeTo(0.001f).Delay(delay += 20).FadeIn(500); - animateHeart(); - this .FadeInFromZero(500) .Then(5500) .FadeOut(250) .ScaleTo(0.9f, 250, Easing.InQuint) - .Finally(d => + .Finally(_ => { if (nextScreen != null) this.Push(nextScreen); @@ -230,7 +239,7 @@ namespace osu.Game.Screens.Menu "New features are coming online every update. Make sure to stay up-to-date!", "If you find the UI too large or small, try adjusting UI scale in settings!", "Try adjusting the \"Screen Scaling\" mode to change your gameplay or UI area, even in fullscreen!", - "What used to be \"osu!direct\" is available to all users just like on the website. You can access it anywhere using Ctrl-D!", + "What used to be \"osu!direct\" is available to all users just like on the website. You can access it anywhere using Ctrl-B!", "Seeking in replays is available by dragging on the difficulty bar at the bottom of the screen!", "Multithreading support means that even with low \"FPS\" your input and judgements will be accurate!", "Try scrolling down in the mod select panel to find a bunch of new fun mods!", @@ -244,10 +253,5 @@ namespace osu.Game.Screens.Menu return tips[RNG.Next(0, tips.Length)]; } - - private void animateHeart() - { - heart.FlashColour(Color4.White, 750, Easing.OutQuint).Loop(); - } } } diff --git a/osu.Game/Screens/Menu/ExitConfirmOverlay.cs b/osu.Game/Screens/Menu/ExitConfirmOverlay.cs index a90b83c5fe..bc2f6ea00f 100644 --- a/osu.Game/Screens/Menu/ExitConfirmOverlay.cs +++ b/osu.Game/Screens/Menu/ExitConfirmOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Input.Bindings; @@ -8,7 +10,7 @@ using osu.Game.Overlays; namespace osu.Game.Screens.Menu { - public class ExitConfirmOverlay : HoldToConfirmOverlay, IKeyBindingHandler + public partial class ExitConfirmOverlay : HoldToConfirmOverlay, IKeyBindingHandler { protected override bool AllowMultipleFires => true; diff --git a/osu.Game/Screens/Menu/FlowContainerWithOrigin.cs b/osu.Game/Screens/Menu/FlowContainerWithOrigin.cs index 8310ab06eb..e36cc4a152 100644 --- a/osu.Game/Screens/Menu/FlowContainerWithOrigin.cs +++ b/osu.Game/Screens/Menu/FlowContainerWithOrigin.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osuTK; @@ -10,7 +12,7 @@ namespace osu.Game.Screens.Menu /// /// A flow container with an origin based on one of its contained drawables. /// - public class FlowContainerWithOrigin : FillFlowContainer + public partial class FlowContainerWithOrigin : FillFlowContainer { /// /// A target drawable which this flowcontainer should be centered around. diff --git a/osu.Game/Screens/Menu/IntroCircles.cs b/osu.Game/Screens/Menu/IntroCircles.cs index 00e2de62f0..57a3fd9c38 100644 --- a/osu.Game/Screens/Menu/IntroCircles.cs +++ b/osu.Game/Screens/Menu/IntroCircles.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -11,14 +13,15 @@ using osu.Framework.Graphics; namespace osu.Game.Screens.Menu { - public class IntroCircles : IntroScreen + public partial class IntroCircles : IntroScreen { protected override string BeatmapHash => "3c8b1fcc9434dbb29e2fb613d3b9eada9d7bb6c125ceb32396c3b53437280c83"; protected override string BeatmapFile => "circles.osz"; - private const double delay_step_one = 2300; - private const double delay_step_two = 600; + public const double TRACK_START_DELAY = 600; + + private const double delay_for_menu = 2900; private Sample welcome; @@ -48,8 +51,8 @@ namespace osu.Game.Screens.Menu PrepareMenuLoad(); - Scheduler.AddDelayed(LoadMenu, delay_step_one); - }, delay_step_two); + Scheduler.AddDelayed(LoadMenu, delay_for_menu - TRACK_START_DELAY); + }, TRACK_START_DELAY); logo.ScaleTo(1); logo.FadeIn(); diff --git a/osu.Game/Screens/Menu/IntroScreen.cs b/osu.Game/Screens/Menu/IntroScreen.cs index d4072d6202..de7732dd5e 100644 --- a/osu.Game/Screens/Menu/IntroScreen.cs +++ b/osu.Game/Screens/Menu/IntroScreen.cs @@ -1,7 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; +using System.Diagnostics; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -17,7 +20,7 @@ using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; -using osu.Game.IO.Archives; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; @@ -30,7 +33,7 @@ using Realms; namespace osu.Game.Screens.Menu { - public abstract class IntroScreen : StartupScreen + public abstract partial class IntroScreen : StartupScreen { /// /// Whether we have loaded the menu previously. @@ -63,6 +66,8 @@ namespace osu.Game.Screens.Menu protected virtual string SeeyaSampleName => "Intro/seeya"; + protected override bool PlayExitSound => false; + private LeasedBindable beatmap; private OsuScreen nextScreen; @@ -85,6 +90,11 @@ namespace osu.Game.Screens.Menu /// protected bool UsingThemedIntro { get; private set; } + protected override BackgroundScreen CreateBackground() => new BackgroundScreenDefault(false) + { + Colour = Color4.Black + }; + protected IntroScreen([CanBeNull] Func createNextScreen = null) { this.createNextScreen = createNextScreen; @@ -137,7 +147,7 @@ namespace osu.Game.Screens.Menu { // if we detect that the theme track or beatmap is unavailable this is either first startup or things are in a bad state. // this could happen if a user has nuked their files store. for now, reimport to repair this. - var import = beatmaps.Import(new ZipArchiveReader(game.Resources.GetStream($"Tracks/{BeatmapFile}"), BeatmapFile)).GetResultSafely(); + var import = beatmaps.Import(new ImportTask(game.Resources.GetStream($"Tracks/{BeatmapFile}"), BeatmapFile)).GetResultSafely(); import?.PerformWrite(b => b.Protected = true); @@ -187,10 +197,14 @@ namespace osu.Game.Screens.Menu PrepareMenuLoad(); LoadMenu(); - notifications.Post(new SimpleErrorNotification + + if (!Debugger.IsAttached) { - Text = "osu! doesn't seem to be able to play audio correctly.\n\nPlease try changing your audio device to a working setting." - }); + notifications.Post(new SimpleErrorNotification + { + Text = NotificationsStrings.AudioPlaybackIssue + }); + } }, 5000); } @@ -198,6 +212,8 @@ namespace osu.Game.Screens.Menu { this.FadeIn(300); + ApplyToBackground(b => b.FadeColour(Color4.Black, 100)); + double fadeOutTime = exit_delay; var track = musicController.CurrentTrack; @@ -240,19 +256,39 @@ namespace osu.Game.Screens.Menu base.OnResuming(e); } + private bool backgroundFaded; + + protected void FadeInBackground(float duration = 0) + { + ApplyToBackground(b => b.FadeColour(Color4.White, duration)); + backgroundFaded = true; + } + public override void OnSuspending(ScreenTransitionEvent e) { base.OnSuspending(e); initialBeatmap = null; + + if (!backgroundFaded) + FadeInBackground(200); } - protected override BackgroundScreen CreateBackground() => new BackgroundScreenBlack(); - - protected virtual void StartTrack() + protected void StartTrack() { - // Only start the current track if it is the menu music. A beatmap's track is started when entering the Main Menu. - if (UsingThemedIntro) - Track.Start(); + var drawableTrack = musicController.CurrentTrack; + + if (!UsingThemedIntro) + { + initialBeatmap?.PrepareTrackForPreview(false, -2600); + + drawableTrack.VolumeTo(0); + drawableTrack.Restart(); + drawableTrack.VolumeTo(1, 2600, Easing.InCubic); + } + else + { + drawableTrack.Restart(); + } } protected override void LogoArriving(OsuLogo logo, bool resuming) diff --git a/osu.Game/Screens/Menu/IntroSequence.cs b/osu.Game/Screens/Menu/IntroSequence.cs index 3a5cd6857a..722f884ac5 100644 --- a/osu.Game/Screens/Menu/IntroSequence.cs +++ b/osu.Game/Screens/Menu/IntroSequence.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osuTK; using osuTK.Graphics; @@ -14,7 +16,7 @@ using osu.Game.Graphics.Sprites; namespace osu.Game.Screens.Menu { - public class IntroSequence : Container + public partial class IntroSequence : Container { private const float logo_size = 460; //todo: this should probably be 480 @@ -264,7 +266,7 @@ namespace osu.Game.Screens.Menu } } - private class Ring : Container + private partial class Ring : Container { public readonly Circle Foreground; diff --git a/osu.Game/Screens/Menu/IntroTriangles.cs b/osu.Game/Screens/Menu/IntroTriangles.cs index ba8314f103..a9c86b10c4 100644 --- a/osu.Game/Screens/Menu/IntroTriangles.cs +++ b/osu.Game/Screens/Menu/IntroTriangles.cs @@ -1,45 +1,39 @@ // 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 JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; -using osu.Framework.Screens; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; -using osu.Framework.Utils; +using osu.Framework.Logging; +using osu.Framework.Screens; using osu.Framework.Timing; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets; -using osu.Game.Screens.Backgrounds; using osuTK; using osuTK.Graphics; namespace osu.Game.Screens.Menu { - public class IntroTriangles : IntroScreen + public partial class IntroTriangles : IntroScreen { protected override string BeatmapHash => "a1556d0801b3a6b175dda32ef546f0ec812b400499f575c44fccbe9c67f9b1e5"; protected override string BeatmapFile => "triangles.osz"; - protected override BackgroundScreen CreateBackground() => background = new BackgroundScreenDefault(false) - { - Alpha = 0, - }; - [Resolved] private AudioManager audio { get; set; } - private BackgroundScreenDefault background; - private Sample welcome; private DecoupleableInterpolatingFramedClock decoupledClock; @@ -73,18 +67,34 @@ namespace osu.Game.Screens.Menu if (UsingThemedIntro) decoupledClock.ChangeSource(Track); - LoadComponentAsync(intro = new TrianglesIntroSequence(logo, background) + LoadComponentAsync(intro = new TrianglesIntroSequence(logo, () => FadeInBackground()) { RelativeSizeAxes = Axes.Both, Clock = decoupledClock, LoadMenu = LoadMenu - }, t => + }, _ => { - AddInternal(t); - if (!UsingThemedIntro) - welcome?.Play(); + AddInternal(intro); - StartTrack(); + // There is a chance that the intro timed out before being displayed, and this scheduled callback could + // happen during the outro rather than intro. + // In such a scenario, we don't want to play the intro sample, nor attempt to start the intro track + // (that may have already been since disposed by MusicController). + if (DidLoadMenu) + return; + + if (!UsingThemedIntro) + { + // If the user has requested no theme, fallback to the same intro voice and delay as IntroCircles. + // The triangles intro voice and theme are combined which makes it impossible to use. + welcome?.Play(); + Scheduler.AddDelayed(StartTrack, IntroCircles.TRACK_START_DELAY); + } + else + StartTrack(); + + // no-op for the case of themed intro, no harm in calling for both scenarios as a safety measure. + decoupledClock.Start(); }); } } @@ -93,28 +103,14 @@ namespace osu.Game.Screens.Menu { base.OnSuspending(e); - // ensure the background is shown, even if the TriangleIntroSequence failed to do so. - background.ApplyToBackground(b => b.Show()); - // important as there is a clock attached to a track which will likely be disposed before returning to this screen. intro.Expire(); } - public override void OnResuming(ScreenTransitionEvent e) - { - base.OnResuming(e); - background.FadeOut(100); - } - - protected override void StartTrack() - { - decoupledClock.Start(); - } - - private class TrianglesIntroSequence : CompositeDrawable + private partial class TrianglesIntroSequence : CompositeDrawable { private readonly OsuLogo logo; - private readonly BackgroundScreenDefault background; + private readonly Action showBackgroundAction; private OsuSpriteText welcomeText; private RulesetFlow rulesets; @@ -126,10 +122,10 @@ namespace osu.Game.Screens.Menu public Action LoadMenu; - public TrianglesIntroSequence(OsuLogo logo, BackgroundScreenDefault background) + public TrianglesIntroSequence(OsuLogo logo, Action showBackgroundAction) { this.logo = logo; - this.background = background; + this.showBackgroundAction = showBackgroundAction; } [Resolved] @@ -203,7 +199,6 @@ namespace osu.Game.Screens.Menu rulesets.Hide(); lazerLogo.Hide(); - background.ApplyToBackground(b => b.Hide()); using (BeginAbsoluteSequence(0)) { @@ -229,8 +224,8 @@ namespace osu.Game.Screens.Menu { rulesetsScale.ScaleTo(0.8f, 1000); rulesets.FadeIn().ScaleTo(1).TransformSpacingTo(new Vector2(200, 0)); - welcomeText.FadeOut(); - triangles.FadeOut(); + welcomeText.FadeOut().Expire(); + triangles.FadeOut().Expire(); } using (BeginDelayedSequence(rulesets_2)) @@ -260,12 +255,11 @@ namespace osu.Game.Screens.Menu { lazerLogo.FadeOut().OnComplete(_ => { - logoContainerSecondary.Remove(lazerLogo); - lazerLogo.Dispose(); // explicit disposal as we are pushing a new screen and the expire may not get run. + logoContainerSecondary.Remove(lazerLogo, true); logo.FadeIn(); - background.ApplyToBackground(b => b.Show()); + showBackgroundAction(); game.Add(new GameWideFlash()); @@ -275,7 +269,7 @@ namespace osu.Game.Screens.Menu } } - private class GameWideFlash : Box + private partial class GameWideFlash : Box { private const double flash_length = 1000; @@ -293,7 +287,7 @@ namespace osu.Game.Screens.Menu } } - private class LazerLogo : CompositeDrawable + private partial class LazerLogo : CompositeDrawable { private LogoAnimation highlight, background; @@ -313,7 +307,7 @@ namespace osu.Game.Screens.Menu } [BackgroundDependencyLoader] - private void load(TextureStore textures) + private void load(LargeTextureStore textures) { InternalChildren = new Drawable[] { @@ -333,33 +327,37 @@ namespace osu.Game.Screens.Menu } } - private class RulesetFlow : FillFlowContainer + private partial class RulesetFlow : FillFlowContainer { [BackgroundDependencyLoader] private void load(RulesetStore rulesets) { - var modes = new List(); - - foreach (var ruleset in rulesets.AvailableRulesets) - { - var icon = new ConstrainedIconContainer - { - Icon = ruleset.CreateInstance().CreateIcon(), - Size = new Vector2(30), - }; - - modes.Add(icon); - } - AutoSizeAxes = Axes.Both; - Children = modes; Anchor = Anchor.Centre; Origin = Anchor.Centre; + + foreach (var ruleset in rulesets.AvailableRulesets) + { + try + { + var icon = new ConstrainedIconContainer + { + Icon = ruleset.CreateInstance().CreateIcon(), + Size = new Vector2(30), + }; + + Add(icon); + } + catch + { + Logger.Log($"Could not create ruleset icon for {ruleset.Name}. Please check for an update from the developer.", level: LogLevel.Error); + } + } } } - private class GlitchingTriangles : CompositeDrawable + private partial class GlitchingTriangles : CompositeDrawable { public GlitchingTriangles() { @@ -393,7 +391,7 @@ namespace osu.Game.Screens.Menu /// /// Represents a sprite that is drawn in a triangle shape, instead of a rectangle shape. /// - public class OutlineTriangle : BufferedContainer + public partial class OutlineTriangle : BufferedContainer { public OutlineTriangle(bool outlineOnly, float size) : base(cachedFrameBuffer: true) diff --git a/osu.Game/Screens/Menu/IntroWelcome.cs b/osu.Game/Screens/Menu/IntroWelcome.cs index 9a6c949cad..da44161507 100644 --- a/osu.Game/Screens/Menu/IntroWelcome.cs +++ b/osu.Game/Screens/Menu/IntroWelcome.cs @@ -1,13 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using JetBrains.Annotations; -using osuTK; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; -using osu.Framework.Screens; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -15,13 +15,13 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Audio; using osu.Game.Online.API; -using osu.Game.Screens.Backgrounds; using osu.Game.Skinning; +using osuTK; using osuTK.Graphics; namespace osu.Game.Screens.Menu { - public class IntroWelcome : IntroScreen + public partial class IntroWelcome : IntroScreen { protected override string BeatmapHash => "64e00d7022195959bfa3109d09c2e2276c8f12f486b91fcf6175583e973b48f2"; protected override string BeatmapFile => "welcome.osz"; @@ -33,13 +33,6 @@ namespace osu.Game.Screens.Menu private ISample pianoReverb; protected override string SeeyaSampleName => "Intro/Welcome/seeya"; - protected override BackgroundScreen CreateBackground() => background = new BackgroundScreenDefault(false) - { - Alpha = 0, - }; - - private BackgroundScreenDefault background; - public IntroWelcome([CanBeNull] Func createNextScreen = null) : base(createNextScreen) { @@ -85,20 +78,24 @@ namespace osu.Game.Screens.Menu if (reverbChannel != null) intro.LogoVisualisation.AddAmplitudeSource(reverbChannel); - Scheduler.AddDelayed(() => - { + if (!UsingThemedIntro) StartTrack(); - // this classic intro loops forever. + Scheduler.AddDelayed(() => + { if (UsingThemedIntro) + { + StartTrack(); + // this classic intro loops forever. Track.Looping = true; + } const float fade_in_time = 200; logo.ScaleTo(1); logo.FadeIn(fade_in_time); - background.FadeIn(fade_in_time); + FadeInBackground(fade_in_time); LoadMenu(); }, delay_step_two); @@ -106,13 +103,7 @@ namespace osu.Game.Screens.Menu } } - public override void OnResuming(ScreenTransitionEvent e) - { - base.OnResuming(e); - background.FadeOut(100); - } - - private class WelcomeIntroSequence : Container + private partial class WelcomeIntroSequence : Container { private Drawable welcomeText; private Container scaleContainer; diff --git a/osu.Game/Screens/Menu/LogoVisualisation.cs b/osu.Game/Screens/Menu/LogoVisualisation.cs index 34f1d46067..5000a97b3d 100644 --- a/osu.Game/Screens/Menu/LogoVisualisation.cs +++ b/osu.Game/Screens/Menu/LogoVisualisation.cs @@ -1,35 +1,31 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osuTK; -using osuTK.Graphics; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Batches; -using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.OpenGL.Vertices; -using osu.Framework.Graphics.Primitives; -using osu.Framework.Graphics.Shaders; -using osu.Framework.Graphics.Textures; -using osu.Game.Beatmaps; using System; using System.Collections.Generic; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Track; -using osu.Framework.Bindables; -using osu.Framework.Utils; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Rendering.Vertices; +using osu.Framework.Graphics.Shaders; +using osu.Framework.Graphics.Textures; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.Menu { /// /// A visualiser that reacts to music coming from beatmaps. /// - public class LogoVisualisation : Drawable + public partial class LogoVisualisation : Drawable { - private readonly IBindable beatmap = new Bindable(); - /// /// The number of bars to jump each update iteration. /// @@ -74,12 +70,11 @@ namespace osu.Game.Screens.Menu private readonly float[] frequencyAmplitudes = new float[256]; - private IShader shader; - private readonly Texture texture; + private IShader shader = null!; + private Texture texture = null!; public LogoVisualisation() { - texture = Texture.WhitePixel; Blending = BlendingParameters.Additive; } @@ -91,32 +86,31 @@ namespace osu.Game.Screens.Menu } [BackgroundDependencyLoader] - private void load(ShaderManager shaders, IBindable beatmap) + private void load(IRenderer renderer, ShaderManager shaders) { - this.beatmap.BindTo(beatmap); - shader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE_ROUNDED); + texture = renderer.WhitePixel; + shader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE); } private readonly float[] temporalAmplitudes = new float[ChannelAmplitudes.AMPLITUDES_SIZE]; + [Resolved] + private IBeatSyncProvider beatSyncProvider { get; set; } = null!; + private void updateAmplitudes() { - var effect = beatmap.Value.BeatmapLoaded && beatmap.Value.TrackLoaded - ? beatmap.Value.Beatmap?.ControlPointInfo.EffectPointAt(beatmap.Value.Track.CurrentTime) - : null; - for (int i = 0; i < temporalAmplitudes.Length; i++) temporalAmplitudes[i] = 0; - if (beatmap.Value.TrackLoaded) - addAmplitudesFromSource(beatmap.Value.Track); + if (beatSyncProvider.Clock != null) + addAmplitudesFromSource(beatSyncProvider); foreach (var source in amplitudeSources) addAmplitudesFromSource(source); for (int i = 0; i < bars_per_visualiser; i++) { - float targetAmplitude = (temporalAmplitudes[(i + indexOffset) % bars_per_visualiser]) * (effect?.KiaiMode == true ? 1 : 0.5f); + float targetAmplitude = (temporalAmplitudes[(i + indexOffset) % bars_per_visualiser]) * (beatSyncProvider.CheckIsKiaiTime() ? 1 : 0.5f); if (targetAmplitude > frequencyAmplitudes[i]) frequencyAmplitudes[i] = targetAmplitude; } @@ -151,9 +145,9 @@ namespace osu.Game.Screens.Menu protected override DrawNode CreateDrawNode() => new VisualisationDrawNode(this); - private void addAmplitudesFromSource([NotNull] IHasAmplitudes source) + private void addAmplitudesFromSource(IHasAmplitudes source) { - if (source == null) throw new ArgumentNullException(nameof(source)); + ArgumentNullException.ThrowIfNull(source); var amplitudes = source.CurrentAmplitudes.FrequencyAmplitudes.Span; @@ -168,8 +162,8 @@ namespace osu.Game.Screens.Menu { protected new LogoVisualisation Source => (LogoVisualisation)base.Source; - private IShader shader; - private Texture texture; + private IShader shader = null!; + private Texture texture = null!; // Assuming the logo is a circle, we don't need a second dimension. private float size; @@ -178,7 +172,7 @@ namespace osu.Game.Screens.Menu private readonly float[] audioData = new float[256]; - private readonly QuadBatch vertexBatch = new QuadBatch(100, 10); + private IVertexBatch? vertexBatch; public VisualisationDrawNode(LogoVisualisation source) : base(source) @@ -196,9 +190,11 @@ namespace osu.Game.Screens.Menu Source.frequencyAmplitudes.AsSpan().CopyTo(audioData); } - public override void Draw(Action vertexAction) + public override void Draw(IRenderer renderer) { - base.Draw(vertexAction); + base.Draw(renderer); + + vertexBatch ??= renderer.CreateQuadBatch(100, 10); shader.Bind(); @@ -207,43 +203,40 @@ namespace osu.Game.Screens.Menu ColourInfo colourInfo = DrawColourInfo.Colour; colourInfo.ApplyChild(transparent_white); - if (audioData != null) + for (int j = 0; j < visualiser_rounds; j++) { - for (int j = 0; j < visualiser_rounds; j++) + for (int i = 0; i < bars_per_visualiser; i++) { - for (int i = 0; i < bars_per_visualiser; i++) - { - if (audioData[i] < amplitude_dead_zone) - continue; + if (audioData[i] < amplitude_dead_zone) + continue; - float rotation = MathUtils.DegreesToRadians(i / (float)bars_per_visualiser * 360 + j * 360 / visualiser_rounds); - float rotationCos = MathF.Cos(rotation); - float rotationSin = MathF.Sin(rotation); - // taking the cos and sin to the 0..1 range - var barPosition = new Vector2(rotationCos / 2 + 0.5f, rotationSin / 2 + 0.5f) * size; + float rotation = MathUtils.DegreesToRadians(i / (float)bars_per_visualiser * 360 + j * 360 / visualiser_rounds); + float rotationCos = MathF.Cos(rotation); + float rotationSin = MathF.Sin(rotation); + // taking the cos and sin to the 0..1 range + var barPosition = new Vector2(rotationCos / 2 + 0.5f, rotationSin / 2 + 0.5f) * size; - var barSize = new Vector2(size * MathF.Sqrt(2 * (1 - MathF.Cos(MathUtils.DegreesToRadians(360f / bars_per_visualiser)))) / 2f, bar_length * audioData[i]); - // The distance between the position and the sides of the bar. - var bottomOffset = new Vector2(-rotationSin * barSize.X / 2, rotationCos * barSize.X / 2); - // The distance between the bottom side of the bar and the top side. - var amplitudeOffset = new Vector2(rotationCos * barSize.Y, rotationSin * barSize.Y); + var barSize = new Vector2(size * MathF.Sqrt(2 * (1 - MathF.Cos(MathUtils.DegreesToRadians(360f / bars_per_visualiser)))) / 2f, bar_length * audioData[i]); + // The distance between the position and the sides of the bar. + var bottomOffset = new Vector2(-rotationSin * barSize.X / 2, rotationCos * barSize.X / 2); + // The distance between the bottom side of the bar and the top side. + var amplitudeOffset = new Vector2(rotationCos * barSize.Y, rotationSin * barSize.Y); - var rectangle = new Quad( - Vector2Extensions.Transform(barPosition - bottomOffset, DrawInfo.Matrix), - Vector2Extensions.Transform(barPosition - bottomOffset + amplitudeOffset, DrawInfo.Matrix), - Vector2Extensions.Transform(barPosition + bottomOffset, DrawInfo.Matrix), - Vector2Extensions.Transform(barPosition + bottomOffset + amplitudeOffset, DrawInfo.Matrix) - ); + var rectangle = new Quad( + Vector2Extensions.Transform(barPosition - bottomOffset, DrawInfo.Matrix), + Vector2Extensions.Transform(barPosition - bottomOffset + amplitudeOffset, DrawInfo.Matrix), + Vector2Extensions.Transform(barPosition + bottomOffset, DrawInfo.Matrix), + Vector2Extensions.Transform(barPosition + bottomOffset + amplitudeOffset, DrawInfo.Matrix) + ); - DrawQuad( - texture, - rectangle, - colourInfo, - null, - vertexBatch.AddAction, - // barSize by itself will make it smooth more in the X axis than in the Y axis, this reverts that. - Vector2.Divide(inflation, barSize.Yx)); - } + renderer.DrawQuad( + texture, + rectangle, + colourInfo, + null, + vertexBatch.AddAction, + // barSize by itself will make it smooth more in the X axis than in the Y axis, this reverts that. + Vector2.Divide(inflation, barSize.Yx)); } } @@ -254,7 +247,7 @@ namespace osu.Game.Screens.Menu { base.Dispose(isDisposing); - vertexBatch.Dispose(); + vertexBatch?.Dispose(); } } } diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index 6fc8039413..69b8596474 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using osu.Framework.Allocation; @@ -8,6 +10,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Game.Beatmaps; @@ -29,7 +32,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Menu { - public class MainMenu : OsuScreen, IHandlePresentBeatmap, IKeyBindingHandler + public partial class MainMenu : OsuScreen, IHandlePresentBeatmap, IKeyBindingHandler { public const float FADE_IN_DURATION = 300; @@ -62,9 +65,9 @@ namespace osu.Game.Screens.Menu [Resolved(canBeNull: true)] private IDialogOverlay dialogOverlay { get; set; } - private BackgroundScreenDefault background; + protected override BackgroundScreen CreateBackground() => new BackgroundScreenDefault(); - protected override BackgroundScreen CreateBackground() => background; + protected override bool PlayExitSound => false; private Bindable holdDelay; private Bindable loginDisplayed; @@ -143,7 +146,6 @@ namespace osu.Game.Screens.Menu Buttons.OnSettings = () => settings?.ToggleVisibility(); Buttons.OnBeatmapListing = () => beatmapListing?.ToggleVisibility(); - LoadComponentAsync(background = new BackgroundScreenDefault()); preloadSongSelect(); } @@ -190,7 +192,7 @@ namespace osu.Game.Screens.Menu // presume the track is the current beatmap's track. not sure how correct this assumption is but it has worked until now. if (!track.IsRunning) { - Beatmap.Value.PrepareTrackForPreviewLooping(); + Beatmap.Value.PrepareTrackForPreview(false); track.Restart(); } } @@ -298,6 +300,8 @@ namespace osu.Game.Screens.Menu public void PresentBeatmap(WorkingBeatmap beatmap, RulesetInfo ruleset) { + Logger.Log($"{nameof(MainMenu)} completing {nameof(PresentBeatmap)} with beatmap {beatmap} ruleset {ruleset}"); + Beatmap.Value = beatmap; Ruleset.Value = ruleset; diff --git a/osu.Game/Screens/Menu/MainMenuButton.cs b/osu.Game/Screens/Menu/MainMenuButton.cs index c07ada9419..cd3795711e 100644 --- a/osu.Game/Screens/Menu/MainMenuButton.cs +++ b/osu.Game/Screens/Menu/MainMenuButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework; using osu.Framework.Allocation; @@ -28,7 +30,7 @@ namespace osu.Game.Screens.Menu /// Button designed specifically for the osu!next main menu. /// In order to correctly flow, we have to use a negative margin on the parent container (due to the parallelogram shape). /// - public class MainMenuButton : BeatSyncedContainer, IStateful + public partial class MainMenuButton : BeatSyncedContainer, IStateful { public event Action StateChanged; diff --git a/osu.Game/Screens/Menu/MenuLogoVisualisation.cs b/osu.Game/Screens/Menu/MenuLogoVisualisation.cs index f461136022..f4e992be9a 100644 --- a/osu.Game/Screens/Menu/MenuLogoVisualisation.cs +++ b/osu.Game/Screens/Menu/MenuLogoVisualisation.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK.Graphics; using osu.Game.Skinning; using osu.Game.Online.API; @@ -10,7 +12,7 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Screens.Menu { - internal class MenuLogoVisualisation : LogoVisualisation + internal partial class MenuLogoVisualisation : LogoVisualisation { private IBindable user; private Bindable skin; diff --git a/osu.Game/Screens/Menu/MenuSideFlashes.cs b/osu.Game/Screens/Menu/MenuSideFlashes.cs index cd0c75c1a1..533c39826c 100644 --- a/osu.Game/Screens/Menu/MenuSideFlashes.cs +++ b/osu.Game/Screens/Menu/MenuSideFlashes.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -20,7 +22,7 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Screens.Menu { - public class MenuSideFlashes : BeatSyncedContainer + public partial class MenuSideFlashes : BeatSyncedContainer { private readonly IBindable beatmap = new Bindable(); diff --git a/osu.Game/Screens/Menu/OsuLogo.cs b/osu.Game/Screens/Menu/OsuLogo.cs index f5743c7d5a..9430a1cda8 100644 --- a/osu.Game/Screens/Menu/OsuLogo.cs +++ b/osu.Game/Screens/Menu/OsuLogo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -27,7 +29,7 @@ namespace osu.Game.Screens.Menu /// /// osu! logo and its attachments (pulsing, visualiser etc.) /// - public class OsuLogo : BeatSyncedContainer + public partial class OsuLogo : BeatSyncedContainer { public readonly Color4 OsuPink = Color4Extensions.FromHex(@"e967a1"); @@ -88,6 +90,8 @@ namespace osu.Game.Screens.Menu private const double early_activation = 60; + private const float triangles_paused_velocity = 0.5f; + public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; public OsuLogo() @@ -109,7 +113,7 @@ namespace osu.Game.Screens.Menu AutoSizeAxes = Axes.Both, Children = new Drawable[] { - logoBounceContainer = new DragContainer + logoBounceContainer = new Container { AutoSizeAxes = Axes.Both, Children = new Drawable[] @@ -278,7 +282,7 @@ namespace osu.Game.Screens.Menu if (beatIndex < 0) return; - if (IsHovered) + if (Action != null && IsHovered) { this.Delay(early_activation).Schedule(() => { @@ -317,6 +321,11 @@ namespace osu.Game.Screens.Menu .FadeTo(visualizer_default_alpha * 1.8f * amplitudeAdjust, early_activation, Easing.Out).Then() .FadeTo(visualizer_default_alpha, beatLength); } + + this.Delay(early_activation).Schedule(() => + { + triangles.Velocity += amplitudeAdjust * (effectPoint.KiaiMode ? 6 : 3); + }); } public void PlayIntro() @@ -338,30 +347,25 @@ namespace osu.Game.Screens.Menu base.Update(); const float scale_adjust_cutoff = 0.4f; - const float velocity_adjust_cutoff = 0.98f; - const float paused_velocity = 0.5f; if (musicController.CurrentTrack.IsRunning) { float maxAmplitude = lastBeatIndex >= 0 ? musicController.CurrentTrack.CurrentAmplitudes.Maximum : 0; logoAmplitudeContainer.Scale = new Vector2((float)Interpolation.Damp(logoAmplitudeContainer.Scale.X, 1 - Math.Max(0, maxAmplitude - scale_adjust_cutoff) * 0.04f, 0.9f, Time.Elapsed)); - if (maxAmplitude > velocity_adjust_cutoff) - triangles.Velocity = 1 + Math.Max(0, maxAmplitude - velocity_adjust_cutoff) * 50; - else - triangles.Velocity = (float)Interpolation.Damp(triangles.Velocity, 1, 0.995f, Time.Elapsed); + triangles.Velocity = (float)Interpolation.Damp(triangles.Velocity, triangles_paused_velocity * (IsKiaiTime ? 4 : 2), 0.995f, Time.Elapsed); } else { - triangles.Velocity = paused_velocity; + triangles.Velocity = (float)Interpolation.Damp(triangles.Velocity, triangles_paused_velocity, 0.9f, Time.Elapsed); } } - public override bool HandlePositionalInput => base.HandlePositionalInput && Action != null && Alpha > 0.2f; + public override bool HandlePositionalInput => base.HandlePositionalInput && Alpha > 0.2f; protected override bool OnMouseDown(MouseDownEvent e) { - if (e.Button != MouseButton.Left) return false; + if (e.Button != MouseButton.Left) return true; logoBounceContainer.ScaleTo(0.9f, 1000, Easing.Out); return true; @@ -376,18 +380,21 @@ namespace osu.Game.Screens.Menu protected override bool OnClick(ClickEvent e) { - if (Action?.Invoke() ?? true) - sampleClick.Play(); - flashLayer.ClearTransforms(); flashLayer.Alpha = 0.4f; flashLayer.FadeOut(1500, Easing.OutExpo); + + if (Action?.Invoke() == true) + sampleClick.Play(); + return true; } protected override bool OnHover(HoverEvent e) { - logoHoverContainer.ScaleTo(1.1f, 500, Easing.OutElastic); + if (Action != null) + logoHoverContainer.ScaleTo(1.1f, 500, Easing.OutElastic); + return true; } @@ -403,27 +410,24 @@ namespace osu.Game.Screens.Menu impactContainer.ScaleTo(1.12f, 250); } - private class DragContainer : Container + public override bool DragBlocksClick => false; + + protected override bool OnDragStart(DragStartEvent e) => true; + + protected override void OnDrag(DragEvent e) { - public override bool DragBlocksClick => false; + Vector2 change = e.MousePosition - e.MouseDownPosition; - protected override bool OnDragStart(DragStartEvent e) => true; + // Diminish the drag distance as we go further to simulate "rubber band" feeling. + change *= change.Length <= 0 ? 0 : MathF.Pow(change.Length, 0.6f) / change.Length; - protected override void OnDrag(DragEvent e) - { - Vector2 change = e.MousePosition - e.MouseDownPosition; + logoBounceContainer.MoveTo(change); + } - // Diminish the drag distance as we go further to simulate "rubber band" feeling. - change *= change.Length <= 0 ? 0 : MathF.Pow(change.Length, 0.6f) / change.Length; - - this.MoveTo(change); - } - - protected override void OnDragEnd(DragEndEvent e) - { - this.MoveTo(Vector2.Zero, 800, Easing.OutElastic); - base.OnDragEnd(e); - } + protected override void OnDragEnd(DragEndEvent e) + { + logoBounceContainer.MoveTo(Vector2.Zero, 800, Easing.OutElastic); + base.OnDragEnd(e); } } } diff --git a/osu.Game/Screens/Menu/SongTicker.cs b/osu.Game/Screens/Menu/SongTicker.cs index 237fe43168..bac7e15461 100644 --- a/osu.Game/Screens/Menu/SongTicker.cs +++ b/osu.Game/Screens/Menu/SongTicker.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -13,7 +15,7 @@ using osu.Game.Beatmaps; namespace osu.Game.Screens.Menu { - public class SongTicker : Container + public partial class SongTicker : Container { private const int fade_duration = 800; diff --git a/osu.Game/Screens/Menu/StorageErrorDialog.cs b/osu.Game/Screens/Menu/StorageErrorDialog.cs index f4c77d5d8f..ba05ad8b76 100644 --- a/osu.Game/Screens/Menu/StorageErrorDialog.cs +++ b/osu.Game/Screens/Menu/StorageErrorDialog.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; @@ -10,7 +12,7 @@ using osu.Game.Overlays.Dialog; namespace osu.Game.Screens.Menu { - public class StorageErrorDialog : PopupDialog + public partial class StorageErrorDialog : PopupDialog { [Resolved] private IDialogOverlay dialogOverlay { get; set; } diff --git a/osu.Game/Screens/OnlinePlay/Components/BeatmapDetailAreaPlaylistTabItem.cs b/osu.Game/Screens/OnlinePlay/Components/BeatmapDetailAreaPlaylistTabItem.cs index fb927411e6..7c48fc0871 100644 --- a/osu.Game/Screens/OnlinePlay/Components/BeatmapDetailAreaPlaylistTabItem.cs +++ b/osu.Game/Screens/OnlinePlay/Components/BeatmapDetailAreaPlaylistTabItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Screens.Select; namespace osu.Game.Screens.OnlinePlay.Components diff --git a/osu.Game/Screens/OnlinePlay/Components/BeatmapTitle.cs b/osu.Game/Screens/OnlinePlay/Components/BeatmapTitle.cs index 7cbe1a9017..ebcc08360e 100644 --- a/osu.Game/Screens/OnlinePlay/Components/BeatmapTitle.cs +++ b/osu.Game/Screens/OnlinePlay/Components/BeatmapTitle.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osu.Game.Online.Chat; namespace osu.Game.Screens.OnlinePlay.Components { - public class BeatmapTitle : OnlinePlayComposite + public partial class BeatmapTitle : OnlinePlayComposite { private readonly LinkFlowContainer textFlow; @@ -25,7 +27,7 @@ namespace osu.Game.Screens.OnlinePlay.Components [BackgroundDependencyLoader] private void load() { - Playlist.CollectionChanged += (_, __) => updateText(); + Playlist.CollectionChanged += (_, _) => updateText(); updateText(); } diff --git a/osu.Game/Screens/OnlinePlay/Components/DisableableTabControl.cs b/osu.Game/Screens/OnlinePlay/Components/DisableableTabControl.cs index 2b596da361..3f7f38f3bc 100644 --- a/osu.Game/Screens/OnlinePlay/Components/DisableableTabControl.cs +++ b/osu.Game/Screens/OnlinePlay/Components/DisableableTabControl.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; namespace osu.Game.Screens.OnlinePlay.Components { - public abstract class DisableableTabControl : TabControl + public abstract partial class DisableableTabControl : TabControl { public readonly BindableBool Enabled = new BindableBool(true); @@ -18,7 +20,7 @@ namespace osu.Game.Screens.OnlinePlay.Components base.AddTabItem(tab, addToDropdown); } - protected abstract class DisableableTabItem : TabItem + protected abstract partial class DisableableTabItem : TabItem { protected DisableableTabItem(T value) : base(value) diff --git a/osu.Game/Screens/OnlinePlay/Components/DrawableGameType.cs b/osu.Game/Screens/OnlinePlay/Components/DrawableGameType.cs index f360a80599..77e461ce41 100644 --- a/osu.Game/Screens/OnlinePlay/Components/DrawableGameType.cs +++ b/osu.Game/Screens/OnlinePlay/Components/DrawableGameType.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; @@ -17,7 +19,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Components { - public class DrawableGameType : CircularContainer, IHasTooltip + public partial class DrawableGameType : CircularContainer, IHasTooltip { private readonly MatchType type; @@ -130,7 +132,7 @@ namespace osu.Game.Screens.OnlinePlay.Components } } - private class VersusRow : FillFlowContainer + private partial class VersusRow : FillFlowContainer { public VersusRow(Color4 first, Color4 second, float size) { diff --git a/osu.Game/Screens/OnlinePlay/Components/ListingPollingComponent.cs b/osu.Game/Screens/OnlinePlay/Components/ListingPollingComponent.cs index 666d425f62..c296e2a86b 100644 --- a/osu.Game/Screens/OnlinePlay/Components/ListingPollingComponent.cs +++ b/osu.Game/Screens/OnlinePlay/Components/ListingPollingComponent.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; @@ -13,7 +15,7 @@ namespace osu.Game.Screens.OnlinePlay.Components /// /// A that polls for the lounge listing. /// - public class ListingPollingComponent : RoomPollingComponent + public partial class ListingPollingComponent : RoomPollingComponent { public IBindable InitialRoomsReceived => initialRoomsReceived; private readonly Bindable initialRoomsReceived = new Bindable(); diff --git a/osu.Game/Screens/OnlinePlay/Components/MatchBeatmapDetailArea.cs b/osu.Game/Screens/OnlinePlay/Components/MatchBeatmapDetailArea.cs index 89842e933b..dec91d8a37 100644 --- a/osu.Game/Screens/OnlinePlay/Components/MatchBeatmapDetailArea.cs +++ b/osu.Game/Screens/OnlinePlay/Components/MatchBeatmapDetailArea.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#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.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Screens.Select; @@ -15,7 +17,7 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay.Components { - public class MatchBeatmapDetailArea : BeatmapDetailArea + public partial class MatchBeatmapDetailArea : BeatmapDetailArea { public Action CreateNewItem; @@ -52,7 +54,7 @@ namespace osu.Game.Screens.OnlinePlay.Components }, new Drawable[] { - new TriangleButton + new RoundedButton { Text = "Add new playlist entry", RelativeSizeAxes = Axes.Both, @@ -84,7 +86,7 @@ namespace osu.Game.Screens.OnlinePlay.Components switch (tab) { - case BeatmapDetailAreaPlaylistTabItem _: + case BeatmapDetailAreaPlaylistTabItem: playlistArea.Show(); break; diff --git a/osu.Game/Screens/OnlinePlay/Components/ModeTypeInfo.cs b/osu.Game/Screens/OnlinePlay/Components/ModeTypeInfo.cs deleted file mode 100644 index 8402619ebc..0000000000 --- a/osu.Game/Screens/OnlinePlay/Components/ModeTypeInfo.cs +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Beatmaps.Drawables; -using osu.Game.Rulesets; -using osuTK; - -namespace osu.Game.Screens.OnlinePlay.Components -{ - public class ModeTypeInfo : OnlinePlayComposite - { - private const float height = 28; - private const float transition_duration = 100; - - [Resolved] - private RulesetStore rulesets { get; set; } - - private Container drawableRuleset; - - public ModeTypeInfo() - { - AutoSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load() - { - Container gameTypeContainer; - - InternalChild = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5f, 0f), - LayoutDuration = 100, - Children = new[] - { - drawableRuleset = new Container - { - AutoSizeAxes = Axes.Both, - }, - gameTypeContainer = new Container - { - AutoSizeAxes = Axes.Both, - }, - }, - }; - - Type.BindValueChanged(type => gameTypeContainer.Child = new DrawableGameType(type.NewValue) { Size = new Vector2(height) }, true); - - Playlist.CollectionChanged += (_, __) => updateBeatmap(); - - updateBeatmap(); - } - - private void updateBeatmap() - { - var item = Playlist.FirstOrDefault(); - var ruleset = item == null ? null : rulesets.GetRuleset(item.RulesetID)?.CreateInstance(); - - if (item?.Beatmap != null && ruleset != null) - { - var mods = item.RequiredMods.Select(m => m.ToMod(ruleset)).ToArray(); - - drawableRuleset.FadeIn(transition_duration); - drawableRuleset.Child = new DifficultyIcon(item.Beatmap, ruleset.RulesetInfo, mods) { Size = new Vector2(height) }; - } - else - drawableRuleset.FadeOut(transition_duration); - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundScreen.cs b/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundScreen.cs index 9e964de31e..014473dfee 100644 --- a/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundScreen.cs @@ -13,11 +13,9 @@ using osu.Game.Online.Rooms; using osuTK; using osuTK.Graphics; -#nullable enable - namespace osu.Game.Screens.OnlinePlay.Components { - public abstract class OnlinePlayBackgroundScreen : BackgroundScreen + public abstract partial class OnlinePlayBackgroundScreen : BackgroundScreen { private CancellationTokenSource? cancellationSource; private PlaylistItemBackground? background; diff --git a/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundSprite.cs b/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundSprite.cs index 2faa46e622..0d4cd30090 100644 --- a/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundSprite.cs +++ b/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundSprite.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Beatmaps.Drawables; @@ -8,7 +10,7 @@ using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Components { - public class OnlinePlayBackgroundSprite : OnlinePlayComposite + public partial class OnlinePlayBackgroundSprite : OnlinePlayComposite { protected readonly BeatmapSetCoverType BeatmapSetCoverType; private UpdateableBeatmapBackgroundSprite sprite; @@ -24,7 +26,7 @@ namespace osu.Game.Screens.OnlinePlay.Components InternalChild = sprite = CreateBackgroundSprite(); CurrentPlaylistItem.BindValueChanged(_ => updateBeatmap()); - Playlist.CollectionChanged += (_, __) => updateBeatmap(); + Playlist.CollectionChanged += (_, _) => updateBeatmap(); updateBeatmap(); } diff --git a/osu.Game/Screens/OnlinePlay/Components/OverlinedHeader.cs b/osu.Game/Screens/OnlinePlay/Components/OverlinedHeader.cs index f667a3c1d2..0e2ce6703f 100644 --- a/osu.Game/Screens/OnlinePlay/Components/OverlinedHeader.cs +++ b/osu.Game/Screens/OnlinePlay/Components/OverlinedHeader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -16,7 +18,7 @@ namespace osu.Game.Screens.OnlinePlay.Components /// /// A header used in the multiplayer interface which shows text / details beneath a line. /// - public class OverlinedHeader : OnlinePlayComposite + public partial class OverlinedHeader : OnlinePlayComposite { private bool showLine = true; diff --git a/osu.Game/Screens/OnlinePlay/Components/OverlinedPlaylistHeader.cs b/osu.Game/Screens/OnlinePlay/Components/OverlinedPlaylistHeader.cs index 45b822d20a..f8dcd7b75d 100644 --- a/osu.Game/Screens/OnlinePlay/Components/OverlinedPlaylistHeader.cs +++ b/osu.Game/Screens/OnlinePlay/Components/OverlinedPlaylistHeader.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Components { - public class OverlinedPlaylistHeader : OverlinedHeader + public partial class OverlinedPlaylistHeader : OverlinedHeader { public OverlinedPlaylistHeader() : base("Playlist") @@ -16,7 +18,7 @@ namespace osu.Game.Screens.OnlinePlay.Components { base.LoadComplete(); - Playlist.BindCollectionChanged((_, __) => Details.Value = Playlist.GetTotalDuration(), true); + Playlist.BindCollectionChanged((_, _) => Details.Value = Playlist.GetTotalDuration(), true); } } } diff --git a/osu.Game/Screens/OnlinePlay/Components/ParticipantCountDisplay.cs b/osu.Game/Screens/OnlinePlay/Components/ParticipantCountDisplay.cs index 53821da8fd..9f7e700ab3 100644 --- a/osu.Game/Screens/OnlinePlay/Components/ParticipantCountDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Components/ParticipantCountDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -9,7 +11,7 @@ using osu.Game.Graphics.Sprites; namespace osu.Game.Screens.OnlinePlay.Components { - public class ParticipantCountDisplay : OnlinePlayComposite + public partial class ParticipantCountDisplay : OnlinePlayComposite { private const float text_size = 30; private const float transition_duration = 100; diff --git a/osu.Game/Screens/OnlinePlay/Components/ParticipantsDisplay.cs b/osu.Game/Screens/OnlinePlay/Components/ParticipantsDisplay.cs index c36d1a2e76..4fdf41d0f7 100644 --- a/osu.Game/Screens/OnlinePlay/Components/ParticipantsDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Components/ParticipantsDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -8,7 +10,7 @@ using osu.Game.Graphics.Containers; namespace osu.Game.Screens.OnlinePlay.Components { - public class ParticipantsDisplay : OnlinePlayComposite + public partial class ParticipantsDisplay : OnlinePlayComposite { public Bindable Details = new Bindable(); diff --git a/osu.Game/Screens/OnlinePlay/Components/ParticipantsList.cs b/osu.Game/Screens/OnlinePlay/Components/ParticipantsList.cs index 1d5552fa7d..00f0889cc8 100644 --- a/osu.Game/Screens/OnlinePlay/Components/ParticipantsList.cs +++ b/osu.Game/Screens/OnlinePlay/Components/ParticipantsList.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -13,7 +15,7 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay.Components { - public class ParticipantsList : OnlinePlayComposite + public partial class ParticipantsList : OnlinePlayComposite { public const float TILE_SIZE = 35; @@ -58,7 +60,7 @@ namespace osu.Game.Screens.OnlinePlay.Components [BackgroundDependencyLoader] private void load() { - RecentParticipants.CollectionChanged += (_, __) => updateParticipants(); + RecentParticipants.CollectionChanged += (_, _) => updateParticipants(); updateParticipants(); } @@ -90,7 +92,7 @@ namespace osu.Game.Screens.OnlinePlay.Components }); } - private class UserTile : CompositeDrawable + private partial class UserTile : CompositeDrawable { public APIUser User { diff --git a/osu.Game/Screens/OnlinePlay/Components/PlaylistItemBackground.cs b/osu.Game/Screens/OnlinePlay/Components/PlaylistItemBackground.cs index 7e31591389..997ba6b639 100644 --- a/osu.Game/Screens/OnlinePlay/Components/PlaylistItemBackground.cs +++ b/osu.Game/Screens/OnlinePlay/Components/PlaylistItemBackground.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 enable - using osu.Framework.Allocation; using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; @@ -11,7 +9,7 @@ using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Components { - public class PlaylistItemBackground : Background + public partial class PlaylistItemBackground : Background { public readonly IBeatmapInfo? Beatmap; diff --git a/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs b/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs index cdaa39d2be..772c8c4278 100644 --- a/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs @@ -1,17 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Cursor; using osu.Framework.Localisation; -using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Online; using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Components { - public abstract class ReadyButton : TriangleButton, IHasTooltip + public abstract partial class ReadyButton : RoundedButton, IHasTooltip { public new readonly BindableBool Enabled = new BindableBool(); diff --git a/osu.Game/Screens/OnlinePlay/Components/RoomLocalUserInfo.cs b/osu.Game/Screens/OnlinePlay/Components/RoomLocalUserInfo.cs index 3bad6cb183..0c3b53266c 100644 --- a/osu.Game/Screens/OnlinePlay/Components/RoomLocalUserInfo.cs +++ b/osu.Game/Screens/OnlinePlay/Components/RoomLocalUserInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osu.Game.Graphics.Sprites; namespace osu.Game.Screens.OnlinePlay.Components { - public class RoomLocalUserInfo : OnlinePlayComposite + public partial class RoomLocalUserInfo : OnlinePlayComposite { private OsuSpriteText attemptDisplay; diff --git a/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs b/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs index 4242886e66..539d5b74b3 100644 --- a/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs +++ b/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; @@ -15,7 +17,7 @@ using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Components { - public class RoomManager : Component, IRoomManager + public partial class RoomManager : Component, IRoomManager { public event Action RoomsUpdated; diff --git a/osu.Game/Screens/OnlinePlay/Components/RoomPollingComponent.cs b/osu.Game/Screens/OnlinePlay/Components/RoomPollingComponent.cs index cd224a7347..395a77b9e6 100644 --- a/osu.Game/Screens/OnlinePlay/Components/RoomPollingComponent.cs +++ b/osu.Game/Screens/OnlinePlay/Components/RoomPollingComponent.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Game.Online; using osu.Game.Online.API; namespace osu.Game.Screens.OnlinePlay.Components { - public abstract class RoomPollingComponent : PollingComponent + public abstract partial class RoomPollingComponent : PollingComponent { [Resolved] protected IAPIProvider API { get; private set; } diff --git a/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs b/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs index e05bdf8c8e..780ee29e41 100644 --- a/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs +++ b/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Threading.Tasks; using osu.Game.Online.Rooms; @@ -9,7 +11,7 @@ namespace osu.Game.Screens.OnlinePlay.Components /// /// A that polls for the currently-selected room. /// - public class SelectionPollingComponent : RoomPollingComponent + public partial class SelectionPollingComponent : RoomPollingComponent { private readonly Room room; diff --git a/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs b/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs index 7425e46bd3..93c8faf0b0 100644 --- a/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using osu.Framework.Allocation; @@ -15,7 +17,7 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay.Components { - public class StarRatingRangeDisplay : OnlinePlayComposite + public partial class StarRatingRangeDisplay : OnlinePlayComposite { [Resolved] private OsuColour colours { get; set; } @@ -75,7 +77,7 @@ namespace osu.Game.Screens.OnlinePlay.Components base.LoadComplete(); DifficultyRange.BindValueChanged(_ => updateRange()); - Playlist.BindCollectionChanged((_, __) => updateRange(), true); + Playlist.BindCollectionChanged((_, _) => updateRange(), true); } private void updateRange() diff --git a/osu.Game/Screens/OnlinePlay/Components/StatusColouredContainer.cs b/osu.Game/Screens/OnlinePlay/Components/StatusColouredContainer.cs index a7ea32ee7c..ed39021a73 100644 --- a/osu.Game/Screens/OnlinePlay/Components/StatusColouredContainer.cs +++ b/osu.Game/Screens/OnlinePlay/Components/StatusColouredContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Components { - public class StatusColouredContainer : Container + public partial class StatusColouredContainer : Container { private readonly double transitionDuration; diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs index 2a72fc6eb1..8abdec9ade 100644 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs +++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using osu.Framework.Bindables; @@ -18,7 +20,7 @@ namespace osu.Game.Screens.OnlinePlay /// /// A scrollable list which displays the s in a . /// - public class DrawableRoomPlaylist : OsuRearrangeableListContainer, IKeyBindingHandler + public partial class DrawableRoomPlaylist : OsuRearrangeableListContainer, IKeyBindingHandler { /// /// The currently-selected item. Selection is visually represented with a border. @@ -154,6 +156,8 @@ namespace osu.Game.Screens.OnlinePlay protected override FillFlowContainer> CreateListFillFlowContainer() => new FillFlowContainer> { + LayoutDuration = 200, + LayoutEasing = Easing.OutQuint, Spacing = new Vector2(0, 2) }; @@ -179,7 +183,7 @@ namespace osu.Game.Screens.OnlinePlay // schedules added as the properties may change value while the drawable items haven't been created yet. SelectedItem.BindValueChanged(_ => Scheduler.AddOnce(scrollToSelection)); - Items.BindCollectionChanged((_, __) => Scheduler.AddOnce(scrollToSelection), true); + Items.BindCollectionChanged((_, _) => Scheduler.AddOnce(scrollToSelection), true); } private void scrollToSelection() @@ -202,6 +206,9 @@ namespace osu.Game.Screens.OnlinePlay public bool OnPressed(KeyBindingPressEvent e) { + if (!AllowSelection) + return false; + switch (e.Action) { case GlobalAction.SelectNext: @@ -222,9 +229,6 @@ namespace osu.Game.Screens.OnlinePlay private void selectNext(int direction) { - if (!AllowSelection) - return; - var visibleItems = ListContainer.AsEnumerable().Where(r => r.IsPresent); PlaylistItem item; diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs index 39853a5c45..3fab0fc180 100644 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs +++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs @@ -1,23 +1,28 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; +using osu.Game.Collections; using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -25,6 +30,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Online; using osu.Game.Online.Chat; using osu.Game.Online.Rooms; +using osu.Game.Overlays; using osu.Game.Overlays.BeatmapSet; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; @@ -36,7 +42,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay { - public class DrawableRoomPlaylistItem : OsuRearrangeableListItem + public partial class DrawableRoomPlaylistItem : OsuRearrangeableListItem, IHasContextMenu { public const float HEIGHT = 50; @@ -72,7 +78,7 @@ namespace osu.Game.Screens.OnlinePlay private IBeatmapInfo beatmap; private IRulesetInfo ruleset; - private Mod[] requiredMods; + private Mod[] requiredMods = Array.Empty(); private Container maskingContainer; private Container difficultyIconContainer; @@ -88,9 +94,15 @@ namespace osu.Game.Screens.OnlinePlay private PanelBackground panelBackground; private FillFlowContainer mainFillFlow; + [Resolved] + private RealmAccess realm { get; set; } + [Resolved] private RulesetStore rulesets { get; set; } + [Resolved] + private BeatmapManager beatmaps { get; set; } + [Resolved] private OsuColour colours { get; set; } @@ -100,6 +112,12 @@ namespace osu.Game.Screens.OnlinePlay [Resolved] private BeatmapLookupCache beatmapLookupCache { get; set; } + [Resolved(CanBeNull = true)] + private BeatmapSetOverlay beatmapOverlay { get; set; } + + [Resolved(CanBeNull = true)] + private ManageCollectionsDialog manageCollectionsDialog { get; set; } + protected override bool ShouldBeConsideredForInput(Drawable child) => AllowReordering || AllowDeletion || !AllowSelection || SelectedItem.Value == Model; public DrawableRoomPlaylistItem(PlaylistItem item) @@ -121,7 +139,8 @@ namespace osu.Game.Screens.OnlinePlay ruleset = rulesets.GetRuleset(Item.RulesetID); var rulesetInstance = ruleset?.CreateInstance(); - requiredMods = Item.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); + if (rulesetInstance != null) + requiredMods = Item.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); } protected override void LoadComplete() @@ -264,7 +283,7 @@ namespace osu.Game.Screens.OnlinePlay } if (beatmap != null) - difficultyIconContainer.Child = new DifficultyIcon(beatmap, ruleset, requiredMods, performBackgroundDifficultyLookup: false) { Size = new Vector2(icon_height) }; + difficultyIconContainer.Child = new DifficultyIcon(beatmap, ruleset) { Size = new Vector2(icon_height) }; else difficultyIconContainer.Clear(); @@ -431,7 +450,7 @@ namespace osu.Game.Screens.OnlinePlay } } }, - } + }, }; } @@ -468,7 +487,32 @@ namespace osu.Game.Screens.OnlinePlay return true; } - public class PlaylistEditButton : GrayButton + public MenuItem[] ContextMenuItems + { + get + { + List items = new List(); + + if (beatmapOverlay != null) + items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => beatmapOverlay.FetchAndShowBeatmap(Item.Beatmap.OnlineID))); + + if (beatmap != null) + { + if (beatmaps.QueryBeatmap(b => b.OnlineID == beatmap.OnlineID) is BeatmapInfo local && !local.BeatmapSet.AsNonNull().DeletePending) + { + var collectionItems = realm.Realm.All().AsEnumerable().Select(c => new CollectionToggleMenuItem(c.ToLive(realm), beatmap)).Cast().ToList(); + if (manageCollectionsDialog != null) + collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, manageCollectionsDialog.Show)); + + items.Add(new OsuMenuItem("Collections") { Items = collectionItems }); + } + } + + return items.ToArray(); + } + } + + public partial class PlaylistEditButton : GrayButton { public PlaylistEditButton() : base(FontAwesome.Solid.Edit) @@ -476,7 +520,7 @@ namespace osu.Game.Screens.OnlinePlay } } - public class PlaylistRemoveButton : GrayButton + public partial class PlaylistRemoveButton : GrayButton { public PlaylistRemoveButton() : base(FontAwesome.Solid.MinusSquare) @@ -484,7 +528,7 @@ namespace osu.Game.Screens.OnlinePlay } } - private sealed class PlaylistDownloadButton : BeatmapDownloadButton + private sealed partial class PlaylistDownloadButton : BeatmapDownloadButton { private readonly IBeatmapInfo beatmap; @@ -517,6 +561,10 @@ namespace osu.Game.Screens.OnlinePlay { switch (state.NewValue) { + case DownloadState.Unknown: + // Ignore initial state to ensure the button doesn't briefly appear. + break; + case DownloadState.LocallyAvailable: // Perform a local query of the beatmap by beatmap checksum, and reset the state if not matching. if (beatmapManager.QueryBeatmap(b => b.MD5Hash == beatmap.MD5Hash) == null) @@ -538,7 +586,7 @@ namespace osu.Game.Screens.OnlinePlay } // For now, this is the same implementation as in PanelBackground, but supports a beatmap info rather than a working beatmap - private class PanelBackground : Container // todo: should be a buffered container (https://github.com/ppy/osu-framework/issues/3222) + private partial class PanelBackground : Container // todo: should be a buffered container (https://github.com/ppy/osu-framework/issues/3222) { public readonly Bindable Beatmap = new Bindable(); @@ -593,7 +641,7 @@ namespace osu.Game.Screens.OnlinePlay } } - private class OwnerAvatar : UpdateableAvatar, IHasTooltip + private partial class OwnerAvatar : UpdateableAvatar, IHasTooltip { public OwnerAvatar() { @@ -606,7 +654,7 @@ namespace osu.Game.Screens.OnlinePlay public LocalisableString TooltipText => User == null ? string.Empty : $"queued by {User.Username}"; - private class TooltipArea : Component, IHasTooltip + private partial class TooltipArea : Component, IHasTooltip { private readonly OwnerAvatar avatar; diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs b/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs index 834e82fcfd..98f3df525d 100644 --- a/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs +++ b/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -15,7 +17,7 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay { - public class FooterButtonFreeMods : FooterButton, IHasCurrentValue> + public partial class FooterButtonFreeMods : FooterButton, IHasCurrentValue> { public Bindable> Current { diff --git a/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs b/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs index 7c9184cc0f..6313d907a5 100644 --- a/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Game.Overlays; using System.Collections.Generic; @@ -9,11 +11,10 @@ using osu.Framework.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Mods; using osu.Game.Rulesets.Mods; -using osuTK.Input; namespace osu.Game.Screens.OnlinePlay { - public class FreeModSelectOverlay : ModSelectOverlay + public partial class FreeModSelectOverlay : ModSelectOverlay { protected override bool ShowTotalMultiplier => false; @@ -31,7 +32,7 @@ namespace osu.Game.Screens.OnlinePlay IsValidMod = _ => true; } - protected override ModColumn CreateModColumn(ModType modType, Key[] toggleKeys = null) => new ModColumn(modType, true, toggleKeys); + protected override ModColumn CreateModColumn(ModType modType) => new ModColumn(modType, true); protected override IEnumerable CreateFooterButtons() => base.CreateFooterButtons().Prepend( new SelectAllModsButton(this) diff --git a/osu.Game/Screens/OnlinePlay/Header.cs b/osu.Game/Screens/OnlinePlay/Header.cs index 2d4b5cc527..4c4851c3ac 100644 --- a/osu.Game/Screens/OnlinePlay/Header.cs +++ b/osu.Game/Screens/OnlinePlay/Header.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Humanizer; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -14,7 +16,7 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay { - public class Header : Container + public partial class Header : Container { public const float HEIGHT = 80; @@ -36,13 +38,13 @@ namespace osu.Game.Screens.OnlinePlay }; // unnecessary to unbind these as this header has the same lifetime as the screen stack we are attaching to. - stack.ScreenPushed += (_, __) => updateSubScreenTitle(); - stack.ScreenExited += (_, __) => updateSubScreenTitle(); + stack.ScreenPushed += (_, _) => updateSubScreenTitle(); + stack.ScreenExited += (_, _) => updateSubScreenTitle(); } private void updateSubScreenTitle() => title.Screen = stack.CurrentScreen as IOnlinePlaySubScreen; - private class MultiHeaderTitle : CompositeDrawable + private partial class MultiHeaderTitle : CompositeDrawable { private const float spacing = 6; diff --git a/osu.Game/Screens/OnlinePlay/IOnlinePlaySubScreen.cs b/osu.Game/Screens/OnlinePlay/IOnlinePlaySubScreen.cs index a4762292a9..f32ead5a11 100644 --- a/osu.Game/Screens/OnlinePlay/IOnlinePlaySubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/IOnlinePlaySubScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Screens.OnlinePlay { public interface IOnlinePlaySubScreen : IOsuScreen diff --git a/osu.Game/Screens/OnlinePlay/IRoomManager.cs b/osu.Game/Screens/OnlinePlay/IRoomManager.cs index 6e1ffbda74..ed4fb7b15e 100644 --- a/osu.Game/Screens/OnlinePlay/IRoomManager.cs +++ b/osu.Game/Screens/OnlinePlay/IRoomManager.cs @@ -6,8 +6,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Online.Rooms; -#nullable enable - namespace osu.Game.Screens.OnlinePlay { [Cached(typeof(IRoomManager))] diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs index 772232f6b4..8c85a8235c 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Threading; using osu.Framework.Allocation; @@ -27,7 +29,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public class DrawableRoom : CompositeDrawable + public partial class DrawableRoom : CompositeDrawable { protected const float CORNER_RADIUS = 10; private const float height = 100; @@ -309,7 +311,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components return pills; } - private class RoomNameText : OsuSpriteText + private partial class RoomNameText : OsuSpriteText { [Resolved(typeof(Room), nameof(Online.Rooms.Room.Name))] private Bindable name { get; set; } @@ -326,7 +328,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components } } - private class RoomStatusText : OnlinePlayComposite + private partial class RoomStatusText : OnlinePlayComposite { [Resolved] private OsuColour colours { get; set; } @@ -432,7 +434,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components } } - public class PasswordProtectedIcon : CompositeDrawable + public partial class PasswordProtectedIcon : CompositeDrawable { [BackgroundDependencyLoader] private void load(OsuColour colours) diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs index 10bd534b89..c31633eefc 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs @@ -1,7 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Specialized; +using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -19,7 +22,7 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public class DrawableRoomParticipantsList : OnlinePlayComposite + public partial class DrawableRoomParticipantsList : OnlinePlayComposite { private const float avatar_size = 36; @@ -195,11 +198,15 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components switch (e.Action) { case NotifyCollectionChangedAction.Add: + Debug.Assert(e.NewItems != null); + foreach (var added in e.NewItems.OfType()) addUser(added); break; case NotifyCollectionChangedAction.Remove: + Debug.Assert(e.OldItems != null); + foreach (var removed in e.OldItems.OfType()) removeUser(removed); break; @@ -230,7 +237,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components private void removeUser(APIUser user) { - avatarFlow.RemoveAll(a => a.User == user); + avatarFlow.RemoveAll(a => a.User == user, true); } private void clearUsers() @@ -248,7 +255,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components hiddenUsers.Count = hiddenCount; if (displayedCircles > NumberOfCircles) - avatarFlow.Remove(avatarFlow.Last()); + avatarFlow.Remove(avatarFlow.Last(), true); else if (displayedCircles < NumberOfCircles) { var nextUser = RecentParticipants.FirstOrDefault(u => avatarFlow.All(a => a.User != u)); @@ -268,7 +275,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components } } - private class CircularAvatar : CompositeDrawable + private partial class CircularAvatar : CompositeDrawable { public APIUser User { @@ -300,7 +307,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components } } - public class HiddenUserCount : CompositeDrawable + public partial class HiddenUserCount : CompositeDrawable { public int Count { diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/EndDateInfo.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/EndDateInfo.cs index 3207d373db..c25dd6f158 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/EndDateInfo.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/EndDateInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -9,7 +11,7 @@ using osu.Game.Graphics; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public class EndDateInfo : OnlinePlayComposite + public partial class EndDateInfo : OnlinePlayComposite { public EndDateInfo() { @@ -28,7 +30,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components }; } - private class EndDatePart : DrawableDate + private partial class EndDatePart : DrawableDate { public readonly IBindable EndDate = new Bindable(); diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/FilterCriteria.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/FilterCriteria.cs index 488af5d4de..3a687ad351 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/FilterCriteria.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/FilterCriteria.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Rulesets; namespace osu.Game.Screens.OnlinePlay.Lounge.Components @@ -11,5 +13,6 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components public RoomStatusFilter Status; public string Category; public RulesetInfo Ruleset; + public RoomPermissionsFilter Permissions; } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/MatchTypePill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/MatchTypePill.cs index d104ede8f7..f96d547747 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/MatchTypePill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/MatchTypePill.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; @@ -11,7 +13,7 @@ using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public class MatchTypePill : OnlinePlayComposite + public partial class MatchTypePill : OnlinePlayComposite { private OsuTextFlowContainer textFlow; diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/PillContainer.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/PillContainer.cs index 109851a16b..263261143d 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/PillContainer.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/PillContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -11,7 +13,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components /// /// Displays contents in a "pill". /// - public class PillContainer : Container + public partial class PillContainer : Container { private const float padding = 8; diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/PlaylistCountPill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/PlaylistCountPill.cs index a6bbcd548d..81ba48d135 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/PlaylistCountPill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/PlaylistCountPill.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using Humanizer; using osu.Framework.Allocation; @@ -14,7 +16,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components /// /// A pill that displays the playlist item count. /// - public class PlaylistCountPill : OnlinePlayComposite + public partial class PlaylistCountPill : OnlinePlayComposite { private OsuTextFlowContainer count; @@ -42,7 +44,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components base.LoadComplete(); PlaylistItemStats.BindValueChanged(_ => updateCount()); - Playlist.BindCollectionChanged((_, __) => updateCount(), true); + Playlist.BindCollectionChanged((_, _) => updateCount(), true); } private void updateCount() diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/QueueModePill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/QueueModePill.cs index 7501f0237b..0175418a96 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/QueueModePill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/QueueModePill.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; @@ -11,7 +13,7 @@ using osu.Game.Online.Multiplayer; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public class QueueModePill : OnlinePlayComposite + public partial class QueueModePill : OnlinePlayComposite { private OsuTextFlowContainer textFlow; diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RankRangePill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RankRangePill.cs index 42fe0bfecd..adfc44fbd4 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RankRangePill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RankRangePill.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -13,7 +15,7 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public class RankRangePill : MultiplayerRoomComposite + public partial class RankRangePill : MultiplayerRoomComposite { private OsuTextFlowContainer rankFlow; diff --git a/osu.Game/Skinning/ISkinComponent.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPermissionsFilter.cs similarity index 54% rename from osu.Game/Skinning/ISkinComponent.cs rename to osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPermissionsFilter.cs index 4bd9f21b6b..faef2a9d57 100644 --- a/osu.Game/Skinning/ISkinComponent.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomPermissionsFilter.cs @@ -1,10 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -namespace osu.Game.Skinning +namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public interface ISkinComponent + public enum RoomPermissionsFilter { - string LookupName { get; } + All, + Public, + Private } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomSpecialCategoryPill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomSpecialCategoryPill.cs index 539af2ebaf..5d67a18d1f 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomSpecialCategoryPill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomSpecialCategoryPill.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public class RoomSpecialCategoryPill : OnlinePlayComposite + public partial class RoomSpecialCategoryPill : OnlinePlayComposite { private SpriteText text; private PillContainer pill; diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusFilter.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusFilter.cs index 0c8dc8832b..463b883f11 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusFilter.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusFilter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; namespace osu.Game.Screens.OnlinePlay.Lounge.Components diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs index 1d43f2dc65..201314851e 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -16,7 +18,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components /// /// A pill that displays the room's current status. /// - public class RoomStatusPill : OnlinePlayComposite + public partial class RoomStatusPill : OnlinePlayComposite { [Resolved] private OsuColour colours { get; set; } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs index 0fd9290880..ac6403bb34 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs @@ -1,9 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Collections.Specialized; +using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -19,7 +22,7 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public class RoomsContainer : CompositeDrawable, IKeyBindingHandler + public partial class RoomsContainer : CompositeDrawable, IKeyBindingHandler { public readonly Bindable SelectedRoom = new Bindable(); public readonly Bindable Filter = new Bindable(); @@ -85,9 +88,29 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components matchingFilter &= r.FilterTerms.Any(term => term.ToString().Contains(criteria.SearchString, StringComparison.InvariantCultureIgnoreCase)); } + matchingFilter &= matchPermissions(r, criteria.Permissions); + r.MatchingFilter = matchingFilter; } }); + + static bool matchPermissions(DrawableLoungeRoom room, RoomPermissionsFilter accessType) + { + switch (accessType) + { + case RoomPermissionsFilter.All: + return true; + + case RoomPermissionsFilter.Public: + return !room.Room.HasPassword.Value; + + case RoomPermissionsFilter.Private: + return room.Room.HasPassword.Value; + + default: + throw new ArgumentOutOfRangeException(nameof(accessType), accessType, $"Unsupported {nameof(RoomPermissionsFilter)} in filter"); + } + } } private void roomsChanged(object sender, NotifyCollectionChangedEventArgs args) @@ -95,10 +118,14 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components switch (args.Action) { case NotifyCollectionChangedAction.Add: + Debug.Assert(args.NewItems != null); + addRooms(args.NewItems.Cast()); break; case NotifyCollectionChangedAction.Remove: + Debug.Assert(args.OldItems != null); + removeRooms(args.OldItems.Cast()); break; } @@ -116,7 +143,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { foreach (var r in rooms) { - roomFlow.RemoveAll(d => d.Room == r); + roomFlow.RemoveAll(d => d.Room == r, true); // selection may have a lease due to being in a sub screen. if (!SelectedRoom.Disabled) diff --git a/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs b/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs index 0015cf8bf9..70e4b2a589 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -32,7 +34,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge /// /// A with lounge-specific interactions such as selection and hover sounds. /// - public class DrawableLoungeRoom : DrawableRoom, IFilterable, IHasContextMenu, IHasPopover, IKeyBindingHandler + public partial class DrawableLoungeRoom : DrawableRoom, IFilterable, IHasContextMenu, IHasPopover, IKeyBindingHandler { private const float transition_duration = 60; private const float selection_border_width = 4; @@ -178,7 +180,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge return true; } - public class PasswordEntryPopover : OsuPopover + public partial class PasswordEntryPopover : OsuPopover { private readonly Room room; @@ -195,7 +197,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge } private OsuPasswordTextBox passwordTextBox; - private TriangleButton joinButton; + private RoundedButton joinButton; private OsuSpriteText errorText; private Sample sampleJoinFail; @@ -224,7 +226,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge Width = 200, PlaceholderText = "password", }, - joinButton = new TriangleButton + joinButton = new RoundedButton { Width = 80, Text = "Join Room", @@ -248,7 +250,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge base.LoadComplete(); ScheduleAfterChildren(() => GetContainingInputManager().ChangeFocus(passwordTextBox)); - passwordTextBox.OnCommit += (_, __) => performJoin(); + passwordTextBox.OnCommit += (_, _) => performJoin(); } private void performJoin() diff --git a/osu.Game/Screens/OnlinePlay/Lounge/LoungeBackgroundScreen.cs b/osu.Game/Screens/OnlinePlay/Lounge/LoungeBackgroundScreen.cs index 52a902f5da..b31c351b82 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/LoungeBackgroundScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/LoungeBackgroundScreen.cs @@ -1,28 +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 enable - using osu.Framework.Bindables; using osu.Framework.Screens; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Components; -using PlaylistItem = osu.Game.Online.Rooms.PlaylistItem; namespace osu.Game.Screens.OnlinePlay.Lounge { - public class LoungeBackgroundScreen : OnlinePlayBackgroundScreen + public partial class LoungeBackgroundScreen : OnlinePlayBackgroundScreen { - public readonly Bindable SelectedRoom = new Bindable(); + public readonly Bindable SelectedRoom = new Bindable(); private readonly BindableList playlist = new BindableList(); public LoungeBackgroundScreen() { SelectedRoom.BindValueChanged(onSelectedRoomChanged); - playlist.BindCollectionChanged((_, __) => PlaylistItem = playlist.GetCurrentItem()); + playlist.BindCollectionChanged((_, _) => PlaylistItem = playlist.GetCurrentItem()); } - private void onSelectedRoomChanged(ValueChangedEvent room) + private void onSelectedRoomChanged(ValueChangedEvent room) { if (room.OldValue != null) playlist.UnbindFrom(room.OldValue.Playlist); diff --git a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs index f2429c1944..fc4a5357c6 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using System.Collections.Generic; @@ -33,10 +35,12 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay.Lounge { [Cached] - public abstract class LoungeSubScreen : OnlinePlaySubScreen + public abstract partial class LoungeSubScreen : OnlinePlaySubScreen { public override string Title => "Lounge"; + protected override bool PlayExitSound => false; + protected override BackgroundScreen CreateBackground() => new LoungeBackgroundScreen { SelectedRoom = { BindTarget = SelectedRoom } @@ -300,7 +304,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge joiningRoomOperation = ongoingOperationTracker?.BeginOperation(); - RoomManager?.JoinRoom(room, password, r => + RoomManager?.JoinRoom(room, password, _ => { Open(room); joiningRoomOperation?.Dispose(); diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/CreateRoomButton.cs b/osu.Game/Screens/OnlinePlay/Match/Components/CreateRoomButton.cs index 81e1cb2406..0251dba6ce 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/CreateRoomButton.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/CreateRoomButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Input; using osu.Framework.Input.Bindings; @@ -8,13 +10,12 @@ using osu.Framework.Input.Events; namespace osu.Game.Screens.OnlinePlay.Match.Components { - public abstract class CreateRoomButton : PurpleTriangleButton, IKeyBindingHandler + public abstract partial class CreateRoomButton : PurpleRoundedButton, IKeyBindingHandler { [BackgroundDependencyLoader] private void load() { SpriteText.Font = SpriteText.Font.With(size: 14); - Triangles.TriangleScale = 1.5f; } public bool OnPressed(KeyBindingPressEvent e) diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/MatchChatDisplay.cs b/osu.Game/Screens/OnlinePlay/Match/Components/MatchChatDisplay.cs index 0396562959..55d39407b0 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/MatchChatDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/MatchChatDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Online.Chat; @@ -8,7 +10,7 @@ using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Match.Components { - public class MatchChatDisplay : StandAloneChatDisplay + public partial class MatchChatDisplay : StandAloneChatDisplay { private readonly IBindable channelId = new Bindable(); diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/MatchLeaderboard.cs b/osu.Game/Screens/OnlinePlay/Match/Components/MatchLeaderboard.cs index b92c197f5a..4627cd4072 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/MatchLeaderboard.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/MatchLeaderboard.cs @@ -11,10 +11,10 @@ using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Match.Components { - public class MatchLeaderboard : Leaderboard + public partial class MatchLeaderboard : Leaderboard { [Resolved(typeof(Room), nameof(Room.RoomID))] - private Bindable roomId { get; set; } + private Bindable roomId { get; set; } = null!; [BackgroundDependencyLoader] private void load() @@ -31,7 +31,7 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components protected override bool IsOnlineScope => true; - protected override APIRequest FetchScores(CancellationToken cancellationToken) + protected override APIRequest? FetchScores(CancellationToken cancellationToken) { if (roomId.Value == null) return null; diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/MatchLeaderboardScore.cs b/osu.Game/Screens/OnlinePlay/Match/Components/MatchLeaderboardScore.cs index 799983342b..fabebc3859 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/MatchLeaderboardScore.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/MatchLeaderboardScore.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; @@ -11,7 +13,7 @@ using osu.Game.Scoring; namespace osu.Game.Screens.OnlinePlay.Match.Components { - public class MatchLeaderboardScore : LeaderboardScore + public partial class MatchLeaderboardScore : LeaderboardScore { private readonly APIUserScoreAggregate score; diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/MatchTypePicker.cs b/osu.Game/Screens/OnlinePlay/Match/Components/MatchTypePicker.cs index 216734e55e..995fce085e 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/MatchTypePicker.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/MatchTypePicker.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -15,7 +17,7 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay.Match.Components { - public class MatchTypePicker : DisableableTabControl + public partial class MatchTypePicker : DisableableTabControl { private const float height = 40; private const float selection_width = 3; @@ -33,7 +35,7 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components AddItem(MatchType.TeamVersus); } - private class GameTypePickerItem : DisableableTabItem + private partial class GameTypePickerItem : DisableableTabItem { private const float transition_duration = 200; diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/PurpleTriangleButton.cs b/osu.Game/Screens/OnlinePlay/Match/Components/PurpleRoundedButton.cs similarity index 65% rename from osu.Game/Screens/OnlinePlay/Match/Components/PurpleTriangleButton.cs rename to osu.Game/Screens/OnlinePlay/Match/Components/PurpleRoundedButton.cs index 28e8961a9a..80469e8171 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/PurpleTriangleButton.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/PurpleRoundedButton.cs @@ -3,18 +3,16 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; -using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Screens.OnlinePlay.Match.Components { - public class PurpleTriangleButton : TriangleButton + public partial class PurpleRoundedButton : RoundedButton { [BackgroundDependencyLoader] private void load() { BackgroundColour = Color4Extensions.FromHex(@"593790"); - Triangles.ColourLight = Color4Extensions.FromHex(@"7247b6"); - Triangles.ColourDark = Color4Extensions.FromHex(@"593790"); } } } diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/RoomAvailabilityPicker.cs b/osu.Game/Screens/OnlinePlay/Match/Components/RoomAvailabilityPicker.cs index 677a5be0d9..85fac9228b 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/RoomAvailabilityPicker.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/RoomAvailabilityPicker.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; @@ -17,7 +19,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Match.Components { - public class RoomAvailabilityPicker : DisableableTabControl + public partial class RoomAvailabilityPicker : DisableableTabControl { protected override TabItem CreateTabItem(RoomAvailability value) => new RoomAvailabilityPickerItem(value); protected override Dropdown CreateDropdown() => null; @@ -34,7 +36,7 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components AddItem(RoomAvailability.InviteOnly); } - private class RoomAvailabilityPickerItem : DisableableTabItem + private partial class RoomAvailabilityPickerItem : DisableableTabItem { private const float transition_duration = 200; diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs index c15b5b443a..4d4fe4ea56 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -16,7 +18,7 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay.Match.Components { - public abstract class RoomSettingsOverlay : FocusedOverlayContainer, IKeyBindingHandler + public abstract partial class RoomSettingsOverlay : FocusedOverlayContainer, IKeyBindingHandler { protected const float TRANSITION_DURATION = 350; protected const float FIELD_PADDING = 25; @@ -99,7 +101,7 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components /// use expanded overhanging content (like an 's dropdown), /// then the overhanging content will be correctly Z-ordered. /// - protected class SectionContainer : ReverseChildIDFillFlowContainer
+ protected partial class SectionContainer : ReverseChildIDFillFlowContainer
{ public SectionContainer() { @@ -111,7 +113,7 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components } } - protected class Section : Container + protected partial class Section : Container { private readonly Container content; @@ -133,7 +135,7 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components new OsuSpriteText { Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 12), - Text = title.ToUpper(), + Text = title.ToUpperInvariant(), }, content = new Container { diff --git a/osu.Game/Screens/OnlinePlay/Match/DrawableMatchRoom.cs b/osu.Game/Screens/OnlinePlay/Match/DrawableMatchRoom.cs index 1828a072f8..3bda93c909 100644 --- a/osu.Game/Screens/OnlinePlay/Match/DrawableMatchRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Match/DrawableMatchRoom.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -17,7 +19,7 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay.Match { - public class DrawableMatchRoom : DrawableRoom + public partial class DrawableMatchRoom : DrawableRoom { public readonly IBindable SelectedItem = new Bindable(); public Action OnEdit; @@ -46,7 +48,7 @@ namespace osu.Game.Screens.OnlinePlay.Match { if (allowEdit) { - ButtonsContainer.Add(editButton = new PurpleTriangleButton + ButtonsContainer.Add(editButton = new PurpleRoundedButton { RelativeSizeAxes = Axes.Y, Size = new Vector2(100, 1), @@ -68,7 +70,7 @@ namespace osu.Game.Screens.OnlinePlay.Match protected override Drawable CreateBackground() => background = new BackgroundSprite(); - private class BackgroundSprite : UpdateableBeatmapBackgroundSprite + private partial class BackgroundSprite : UpdateableBeatmapBackgroundSprite { protected override double LoadDelay => 0; } diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomBackgroundScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomBackgroundScreen.cs index 2e5f25370f..c9e51d376c 100644 --- a/osu.Game/Screens/OnlinePlay/Match/RoomBackgroundScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Match/RoomBackgroundScreen.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Components; namespace osu.Game.Screens.OnlinePlay.Match { - public class RoomBackgroundScreen : OnlinePlayBackgroundScreen + public partial class RoomBackgroundScreen : OnlinePlayBackgroundScreen { public readonly Bindable SelectedItem = new Bindable(); diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs index a612b6a0e6..6b68024393 100644 --- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; @@ -26,13 +28,14 @@ using osu.Game.Overlays; using osu.Game.Overlays.Mods; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Menu; using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.OnlinePlay.Multiplayer; namespace osu.Game.Screens.OnlinePlay.Match { [Cached(typeof(IPreviewTrackOwner))] - public abstract class RoomSubScreen : OnlinePlaySubScreen, IPreviewTrackOwner + public abstract partial class RoomSubScreen : OnlinePlaySubScreen, IPreviewTrackOwner { [Cached(typeof(IBindable))] public readonly Bindable SelectedItem = new Bindable(); @@ -84,7 +87,7 @@ namespace osu.Game.Screens.OnlinePlay.Match public readonly Room Room; private readonly bool allowEdit; - private ModSelectOverlay userModsSelectOverlay; + internal ModSelectOverlay UserModsSelectOverlay { get; private set; } [CanBeNull] private IDisposable userModsSelectOverlayRegistration; @@ -234,7 +237,7 @@ namespace osu.Game.Screens.OnlinePlay.Match } }; - LoadComponent(userModsSelectOverlay = new UserModSelectOverlay(OverlayColourScheme.Plum) + LoadComponent(UserModsSelectOverlay = new UserModSelectOverlay(OverlayColourScheme.Plum) { SelectedMods = { BindTarget = UserMods }, IsValidMod = _ => false @@ -267,7 +270,7 @@ namespace osu.Game.Screens.OnlinePlay.Match beatmapAvailabilityTracker.SelectedItem.BindTo(SelectedItem); beatmapAvailabilityTracker.Availability.BindValueChanged(_ => updateWorkingBeatmap()); - userModsSelectOverlayRegistration = overlayManager?.RegisterBlockingOverlay(userModsSelectOverlay); + userModsSelectOverlayRegistration = overlayManager?.RegisterBlockingOverlay(UserModsSelectOverlay); } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) @@ -278,18 +281,23 @@ namespace osu.Game.Screens.OnlinePlay.Match }; } + [Resolved(canBeNull: true)] + private IDialogOverlay dialogOverlay { get; set; } + public override bool OnBackButton() { if (Room.RoomID.Value == null) { - // room has not been created yet; exit immediately. + if (!ensureExitConfirmed()) + return true; + settingsOverlay.Hide(); return base.OnBackButton(); } - if (userModsSelectOverlay.State.Value == Visibility.Visible) + if (UserModsSelectOverlay.State.Value == Visibility.Visible) { - userModsSelectOverlay.Hide(); + UserModsSelectOverlay.Hide(); return true; } @@ -302,7 +310,7 @@ namespace osu.Game.Screens.OnlinePlay.Match return base.OnBackButton(); } - protected void ShowUserModSelect() => userModsSelectOverlay.Show(); + protected void ShowUserModSelect() => UserModsSelectOverlay.Show(); public override void OnEntering(ScreenTransitionEvent e) { @@ -312,6 +320,9 @@ namespace osu.Game.Screens.OnlinePlay.Match public override void OnSuspending(ScreenTransitionEvent e) { + // Should be a noop in most cases, but let's ensure beyond doubt that the beatmap is in a correct state. + updateWorkingBeatmap(); + onLeaving(); base.OnSuspending(e); } @@ -325,8 +336,13 @@ namespace osu.Game.Screens.OnlinePlay.Match Scheduler.AddOnce(updateRuleset); } + protected bool ExitConfirmed { get; private set; } + public override bool OnExiting(ScreenExitEvent e) { + if (!ensureExitConfirmed()) + return true; + RoomManager?.PartRoom(); Mods.Value = Array.Empty(); @@ -335,6 +351,28 @@ namespace osu.Game.Screens.OnlinePlay.Match return base.OnExiting(e); } + private bool ensureExitConfirmed() + { + if (ExitConfirmed) + return true; + + if (dialogOverlay == null || Room.RoomID.Value != null || Room.Playlist.Count == 0) + return true; + + // if the dialog is already displayed, block exiting until the user explicitly makes a decision. + if (dialogOverlay.CurrentDialog is ConfirmDiscardChangesDialog) + return false; + + dialogOverlay.Push(new ConfirmDiscardChangesDialog(() => + { + ExitConfirmed = true; + settingsOverlay.Hide(); + this.Exit(); + })); + + return false; + } + protected void StartPlay() { // User may be at song select or otherwise when the host starts gameplay. @@ -383,18 +421,21 @@ namespace osu.Game.Screens.OnlinePlay.Match if (!selected.AllowedMods.Any()) { UserModsSection?.Hide(); - userModsSelectOverlay.Hide(); - userModsSelectOverlay.IsValidMod = _ => false; + UserModsSelectOverlay.Hide(); + UserModsSelectOverlay.IsValidMod = _ => false; } else { UserModsSection?.Show(); - userModsSelectOverlay.IsValidMod = m => allowedMods.Any(a => a.GetType() == m.GetType()); + UserModsSelectOverlay.IsValidMod = m => allowedMods.Any(a => a.GetType() == m.GetType()); } } private void updateWorkingBeatmap() { + if (SelectedItem.Value == null || !this.IsCurrentScreen()) + return; + var beatmap = SelectedItem.Value?.Beatmap; // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info @@ -428,7 +469,7 @@ namespace osu.Game.Screens.OnlinePlay.Match private void onLeaving() { - userModsSelectOverlay.Hide(); + UserModsSelectOverlay.Hide(); endHandlingTrack(); } @@ -447,7 +488,7 @@ namespace osu.Game.Screens.OnlinePlay.Match if (track != null) { - Beatmap.Value.PrepareTrackForPreviewLooping(); + Beatmap.Value.PrepareTrackForPreview(true); music?.EnsurePlayingSomething(); } } @@ -476,7 +517,7 @@ namespace osu.Game.Screens.OnlinePlay.Match /// The room to change the settings of. protected abstract RoomSettingsOverlay CreateRoomSettingsOverlay(Room room); - public class UserModSelectButton : PurpleTriangleButton, IKeyBindingHandler + public partial class UserModSelectButton : PurpleRoundedButton, IKeyBindingHandler { public bool OnPressed(KeyBindingPressEvent e) { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs index e80923ed47..7975597beb 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Online.Multiplayer; @@ -8,7 +10,7 @@ using osu.Game.Screens.OnlinePlay.Match.Components; namespace osu.Game.Screens.OnlinePlay.Multiplayer { - public class CreateMultiplayerMatchButton : CreateRoomButton + public partial class CreateMultiplayerMatchButton : CreateRoomButton { private IBindable isConnected; private IBindable operationInProgress; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs index d08a63e21f..d003110039 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -14,7 +16,7 @@ using osu.Game.Screens.Play; namespace osu.Game.Screens.OnlinePlay.Multiplayer { - public class GameplayChatDisplay : MatchChatDisplay, IKeyBindingHandler + public partial class GameplayChatDisplay : MatchChatDisplay, IKeyBindingHandler { [Resolved(CanBeNull = true)] [CanBeNull] diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayMatchScoreDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayMatchScoreDisplay.cs index 20a88545c5..8c08390c73 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayMatchScoreDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayMatchScoreDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Screens.Play.HUD; @@ -8,7 +10,7 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer { - public class GameplayMatchScoreDisplay : MatchScoreDisplay + public partial class GameplayMatchScoreDisplay : MatchScoreDisplay { public Bindable Expanded = new Bindable(); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs index d048676872..44e18dd2bb 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using System.Linq; @@ -18,7 +20,7 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { - public class MatchStartControl : MultiplayerRoomComposite + public partial class MatchStartControl : MultiplayerRoomComposite { [Resolved] private OngoingOperationTracker ongoingOperationTracker { get; set; } @@ -107,7 +109,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match Debug.Assert(clickOperation == null); clickOperation = ongoingOperationTracker.BeginOperation(); - if (isReady() && Client.IsHost && Room.Countdown == null) + if (isReady() && Client.IsHost && !Room.ActiveCountdowns.Any(c => c is MatchStartCountdown)) startMatch(); else toggleReady(); @@ -138,10 +140,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private void cancelCountdown() { + if (Client.Room == null) + return; + Debug.Assert(clickOperation == null); clickOperation = ongoingOperationTracker.BeginOperation(); - Client.SendMatchRequest(new StopCountdownRequest()).ContinueWith(_ => endOperation()); + MultiplayerCountdown countdown = Client.Room.ActiveCountdowns.Single(c => c is MatchStartCountdown); + Client.SendMatchRequest(new StopCountdownRequest(countdown.ID)).ContinueWith(_ => endOperation()); } private void endOperation() @@ -190,7 +196,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match // When the local user is the host and spectating the match, the ready button should be enabled only if any users are ready. if (localUser?.State == MultiplayerUserState.Spectating) - readyButton.Enabled.Value &= Client.IsHost && newCountReady > 0 && Room.Countdown == null; + readyButton.Enabled.Value &= Client.IsHost && newCountReady > 0 && !Room.ActiveCountdowns.Any(c => c is MatchStartCountdown); if (newCountReady == countReady) return; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs index 1a51aebb76..6dc343f00a 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs @@ -1,7 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; +using System.Linq; using Humanizer; using osu.Framework.Allocation; using osu.Framework.Extensions; @@ -19,7 +22,7 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { - public class MultiplayerCountdownButton : IconButton, IHasPopover + public partial class MultiplayerCountdownButton : IconButton, IHasPopover { private static readonly TimeSpan[] available_delays = { @@ -77,7 +80,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private void onRoomUpdated() => Scheduler.AddOnce(() => { - bool countdownActive = multiplayerClient.Room?.Countdown is MatchStartCountdown; + bool countdownActive = multiplayerClient.Room?.ActiveCountdowns.Any(c => c is MatchStartCountdown) == true; if (countdownActive) { @@ -106,7 +109,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match foreach (var duration in available_delays) { - flow.Add(new OsuButton + flow.Add(new RoundedButton { RelativeSizeAxes = Axes.X, Text = $"Start match in {duration.Humanize()}", @@ -119,9 +122,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match }); } - if (multiplayerClient.Room?.Countdown != null && multiplayerClient.IsHost) + if (multiplayerClient.Room?.ActiveCountdowns.Any(c => c is MatchStartCountdown) == true && multiplayerClient.IsHost) { - flow.Add(new OsuButton + flow.Add(new RoundedButton { RelativeSizeAxes = Axes.X, Text = "Stop countdown", diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs index a07c95bca8..fcb6480b58 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { - public class MultiplayerMatchFooter : CompositeDrawable + public partial class MultiplayerMatchFooter : CompositeDrawable { private const float ready_button_width = 600; private const float spectate_button_width = 200; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs index a103d71120..66acd6d1b0 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs @@ -4,7 +4,6 @@ using System; using System.ComponentModel; using System.Diagnostics; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; @@ -17,6 +16,7 @@ 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.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Overlays; @@ -26,14 +26,14 @@ using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { - public class MultiplayerMatchSettingsOverlay : RoomSettingsOverlay + public partial class MultiplayerMatchSettingsOverlay : RoomSettingsOverlay { - private MatchSettings settings; + private MatchSettings settings = null!; protected override OsuButton SubmitButton => settings.ApplyButton; [Resolved] - private OngoingOperationTracker ongoingOperationTracker { get; set; } + private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!; protected override bool IsLoading => ongoingOperationTracker.InProgress.Value; @@ -51,51 +51,49 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match SettingsApplied = Hide }; - protected class MatchSettings : OnlinePlayComposite + protected partial class MatchSettings : OnlinePlayComposite { private const float disabled_alpha = 0.2f; - public Action SettingsApplied; + public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; - public OsuTextBox NameField, MaxParticipantsField; - public MatchTypePicker TypePicker; - public OsuEnumDropdown QueueModeDropdown; - public OsuTextBox PasswordTextBox; - public TriangleButton ApplyButton; + public Action? SettingsApplied; - public OsuSpriteText ErrorText; + public OsuTextBox NameField = null!; + public OsuTextBox MaxParticipantsField = null!; + public MatchTypePicker TypePicker = null!; + public OsuEnumDropdown QueueModeDropdown = null!; + public OsuTextBox PasswordTextBox = null!; + public OsuCheckbox AutoSkipCheckbox = null!; + public RoundedButton ApplyButton = null!; - private OsuEnumDropdown startModeDropdown; - private OsuSpriteText typeLabel; - private LoadingLayer loadingLayer; + public OsuSpriteText ErrorText = null!; - public void SelectBeatmap() - { - if (matchSubScreen.IsCurrentScreen()) - matchSubScreen.Push(new MultiplayerMatchSongSelect(matchSubScreen.Room)); - } + private OsuEnumDropdown startModeDropdown = null!; + private OsuSpriteText typeLabel = null!; + private LoadingLayer loadingLayer = null!; + + public void SelectBeatmap() => selectBeatmapButton.TriggerClick(); [Resolved] - private MultiplayerMatchSubScreen matchSubScreen { get; set; } + private MultiplayerMatchSubScreen matchSubScreen { get; set; } = null!; [Resolved] - private IRoomManager manager { get; set; } + private IRoomManager manager { get; set; } = null!; [Resolved] - private MultiplayerClient client { get; set; } + private MultiplayerClient client { get; set; } = null!; [Resolved] - private OngoingOperationTracker ongoingOperationTracker { get; set; } + private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!; private readonly IBindable operationInProgress = new BindableBool(); - - [CanBeNull] - private IDisposable applyingSettingsOperation; - private readonly Room room; - private Drawable playlistContainer; - private DrawableRoomPlaylist drawablePlaylist; + private IDisposable? applyingSettingsOperation; + private Drawable playlistContainer = null!; + private DrawableRoomPlaylist drawablePlaylist = null!; + private RoundedButton selectBeatmapButton = null!; public MatchSettings(Room room) { @@ -247,6 +245,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match LengthLimit = 255, }, }, + new Section("Other") + { + Child = AutoSkipCheckbox = new OsuCheckbox + { + LabelText = "Automatically skip the beatmap intro" + } + } } } }, @@ -267,12 +272,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match RelativeSizeAxes = Axes.X, Height = DrawableRoomPlaylistItem.HEIGHT }, - new PurpleTriangleButton + selectBeatmapButton = new RoundedButton { RelativeSizeAxes = Axes.X, Height = 40, Text = "Select beatmap", - Action = SelectBeatmap + Action = () => + { + if (matchSubScreen.IsCurrentScreen()) + matchSubScreen.Push(new MultiplayerMatchSongSelect(matchSubScreen.Room)); + } } } } @@ -341,6 +350,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match Password.BindValueChanged(password => PasswordTextBox.Text = password.NewValue ?? string.Empty, true); QueueMode.BindValueChanged(mode => QueueModeDropdown.Current.Value = mode.NewValue, true); AutoStartDuration.BindValueChanged(duration => startModeDropdown.Current.Value = (StartMode)(int)duration.NewValue.TotalSeconds, true); + AutoSkip.BindValueChanged(autoSkip => AutoSkipCheckbox.Current.Value = autoSkip.NewValue, true); operationInProgress.BindTo(ongoingOperationTracker.InProgress); operationInProgress.BindValueChanged(v => @@ -388,7 +398,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match password: PasswordTextBox.Text, matchType: TypePicker.Current.Value, queueMode: QueueModeDropdown.Current.Value, - autoStartDuration: autoStartDuration) + autoStartDuration: autoStartDuration, + autoSkip: AutoSkipCheckbox.Current.Value) .ContinueWith(t => Schedule(() => { if (t.IsCompletedSuccessfully) @@ -404,19 +415,20 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match room.Password.Value = PasswordTextBox.Current.Value; room.QueueMode.Value = QueueModeDropdown.Current.Value; room.AutoStartDuration.Value = autoStartDuration; + room.AutoSkip.Value = AutoSkipCheckbox.Current.Value; if (int.TryParse(MaxParticipantsField.Text, out int max)) room.MaxParticipants.Value = max; else room.MaxParticipants.Value = null; - manager?.CreateRoom(room, onSuccess, onError); + manager.CreateRoom(room, onSuccess, onError); } } private void hideError() => ErrorText.FadeOut(50); - private void onSuccess(Room room) + private void onSuccess(Room room) => Schedule(() => { Debug.Assert(applyingSettingsOperation != null); @@ -424,9 +436,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match applyingSettingsOperation.Dispose(); applyingSettingsOperation = null; - } + }); - private void onError(string text) + private void onError(string text) => Schedule(() => { Debug.Assert(applyingSettingsOperation != null); @@ -447,13 +459,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match applyingSettingsOperation.Dispose(); applyingSettingsOperation = null; - } + }); } - public class CreateOrUpdateButton : TriangleButton + public partial class CreateOrUpdateButton : RoundedButton { [Resolved(typeof(Room), nameof(Room.RoomID))] - private Bindable roomId { get; set; } + private Bindable roomId { get; set; } = null!; protected override void LoadComplete() { @@ -464,9 +476,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match [BackgroundDependencyLoader] private void load(OsuColour colours) { - BackgroundColour = colours.Yellow; - Triangles.ColourLight = colours.YellowLight; - Triangles.ColourDark = colours.YellowDark; + BackgroundColour = colours.YellowDark; } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs index 62310bcaac..1be573bdb8 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using JetBrains.Annotations; @@ -10,16 +12,13 @@ using osu.Framework.Audio.Sample; using osu.Framework.Localisation; using osu.Framework.Threading; using osu.Game.Graphics; -using osu.Game.Graphics.Backgrounds; using osu.Game.Online.Multiplayer; using osu.Game.Screens.OnlinePlay.Components; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { - public class MultiplayerReadyButton : ReadyButton + public partial class MultiplayerReadyButton : ReadyButton { - public new Triangles Triangles => base.Triangles; - [Resolved] private MultiplayerClient multiplayerClient { get; set; } @@ -55,23 +54,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private void onRoomUpdated() => Scheduler.AddOnce(() => { - MultiplayerCountdown newCountdown; - - switch (room?.Countdown) - { - case MatchStartCountdown _: - newCountdown = room.Countdown; - break; - - // Clear the countdown with any other (including non-null) countdown values. - default: - newCountdown = null; - break; - } + MultiplayerCountdown newCountdown = room?.ActiveCountdowns.SingleOrDefault(c => c is MatchStartCountdown); if (newCountdown != countdown) { - countdown = room?.Countdown; + countdown = newCountdown; countdownChangeTime = Time.Current; } @@ -211,7 +198,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match case MultiplayerUserState.Spectating: case MultiplayerUserState.Ready: - if (room?.Host?.Equals(localUser) == true && room.Countdown == null) + if (room?.Host?.Equals(localUser) == true && !room.ActiveCountdowns.Any(c => c is MatchStartCountdown)) setGreen(); else setYellow(); @@ -222,15 +209,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match void setYellow() { BackgroundColour = colours.YellowDark; - Triangles.ColourDark = colours.YellowDark; - Triangles.ColourLight = colours.Yellow; } void setGreen() { BackgroundColour = colours.Green; - Triangles.ColourDark = colours.Green; - Triangles.ColourLight = colours.GreenLight; } } @@ -246,8 +229,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { get { - if (room?.Countdown != null && multiplayerClient.IsHost && multiplayerClient.LocalUser?.State == MultiplayerUserState.Ready && !room.Settings.AutoStartEnabled) + if (room?.ActiveCountdowns.Any(c => c is MatchStartCountdown) == true + && multiplayerClient.IsHost + && multiplayerClient.LocalUser?.State == MultiplayerUserState.Ready + && !room.Settings.AutoStartEnabled) + { return "Cancel countdown"; + } return base.TooltipText; } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs index d939fbf400..1d308ed39c 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs @@ -1,18 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Graphics; -using osu.Game.Graphics.Backgrounds; -using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Online.Multiplayer; using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { - public class MultiplayerSpectateButton : MultiplayerRoomComposite + public partial class MultiplayerSpectateButton : MultiplayerRoomComposite { [Resolved] private OngoingOperationTracker ongoingOperationTracker { get; set; } @@ -22,11 +23,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private IBindable operationInProgress; - private readonly ButtonWithTrianglesExposed button; + private readonly RoundedButton button; public MultiplayerSpectateButton() { - InternalChild = button = new ButtonWithTrianglesExposed + InternalChild = button = new RoundedButton { RelativeSizeAxes = Axes.Both, Size = Vector2.One, @@ -39,7 +40,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { var clickOperation = ongoingOperationTracker.BeginOperation(); - Client.ToggleSpectate().ContinueWith(t => endOperation()); + Client.ToggleSpectate().ContinueWith(_ => endOperation()); void endOperation() => clickOperation?.Dispose(); } @@ -65,15 +66,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match default: button.Text = "Spectate"; button.BackgroundColour = colours.BlueDark; - button.Triangles.ColourDark = colours.BlueDarker; - button.Triangles.ColourLight = colours.Blue; break; case MultiplayerUserState.Spectating: button.Text = "Stop spectating"; button.BackgroundColour = colours.Gray4; - button.Triangles.ColourDark = colours.Gray5; - button.Triangles.ColourLight = colours.Gray6; break; } @@ -81,10 +78,5 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match && Client.Room.State != MultiplayerRoomState.Closed && !operationInProgress.Value; } - - private class ButtonWithTrianglesExposed : TriangleButton - { - public new Triangles Triangles => base.Triangles; - } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerHistoryList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerHistoryList.cs index 32d355d149..a19f61787b 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerHistoryList.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerHistoryList.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Graphics; @@ -13,7 +15,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist /// /// A historically-ordered list of s. /// - public class MultiplayerHistoryList : DrawableRoomPlaylist + public partial class MultiplayerHistoryList : DrawableRoomPlaylist { public MultiplayerHistoryList() { @@ -25,7 +27,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist Spacing = new Vector2(0, 2) }; - private class HistoryFillFlowContainer : FillFlowContainer> + private partial class HistoryFillFlowContainer : FillFlowContainer> { public override IEnumerable FlowingChildren => base.FlowingChildren.OfType>().OrderByDescending(item => item.Model.PlayedAt); } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs index 41f548a630..2d08d8ecf6 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using osu.Framework.Allocation; @@ -15,7 +17,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist /// /// The multiplayer playlist, containing lists to show the items from a in both gameplay-order and historical-order. /// - public class MultiplayerPlaylist : MultiplayerRoomComposite + public partial class MultiplayerPlaylist : MultiplayerRoomComposite { public readonly Bindable DisplayMode = new Bindable(); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistDisplayMode.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistDisplayMode.cs index cc3dca6a34..1672f98637 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistDisplayMode.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistDisplayMode.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist { /// diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs index 583a05839f..a5589c48b9 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; @@ -8,7 +10,7 @@ using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist { - public class MultiplayerPlaylistTabControl : OsuTabControl + public partial class MultiplayerPlaylistTabControl : OsuTabControl { public readonly IBindableList QueueItems = new BindableList(); @@ -20,7 +22,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist return base.CreateTabItem(value); } - private class QueueTabItem : OsuTabItem + private partial class QueueTabItem : OsuTabItem { public readonly IBindableList QueueItems = new BindableList(); @@ -32,7 +34,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist protected override void LoadComplete() { base.LoadComplete(); - QueueItems.BindCollectionChanged((_, __) => Text.Text = QueueItems.Count > 0 ? $"Queue ({QueueItems.Count})" : "Queue", true); + QueueItems.BindCollectionChanged((_, _) => Text.Text = QueueItems.Count > 0 ? $"Queue ({QueueItems.Count})" : "Queue", true); } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs index d72ce5e960..77d82c4347 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -17,7 +19,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist /// /// A gameplay-ordered list of s. /// - public class MultiplayerQueueList : DrawableRoomPlaylist + public partial class MultiplayerQueueList : DrawableRoomPlaylist { public MultiplayerQueueList() { @@ -31,7 +33,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist protected override DrawableRoomPlaylistItem CreateDrawablePlaylistItem(PlaylistItem item) => new QueuePlaylistItem(item); - private class QueueFillFlowContainer : FillFlowContainer> + private partial class QueueFillFlowContainer : FillFlowContainer> { [Resolved(typeof(Room), nameof(Room.Playlist))] private BindableList roomPlaylist { get; set; } @@ -39,13 +41,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist protected override void LoadComplete() { base.LoadComplete(); - roomPlaylist.BindCollectionChanged((_, __) => InvalidateLayout()); + roomPlaylist.BindCollectionChanged((_, _) => InvalidateLayout()); } public override IEnumerable FlowingChildren => base.FlowingChildren.OfType>().OrderBy(item => item.Model.PlaylistOrder); } - private class QueuePlaylistItem : DrawableRoomPlaylistItem + private partial class QueuePlaylistItem : DrawableRoomPlaylistItem { [Resolved] private IAPIProvider api { get; set; } @@ -76,9 +78,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist return; bool isItemOwner = Item.OwnerID == api.LocalUser.Value.OnlineID || multiplayerClient.IsHost; + bool isValidItem = isItemOwner && !Item.Expired; - AllowDeletion = isItemOwner && !Item.Expired && Item.ID != multiplayerClient.Room.Settings.PlaylistItemId; - AllowEditing = isItemOwner && !Item.Expired; + AllowDeletion = isValidItem + && (Item.ID != multiplayerClient.Room.Settings.PlaylistItemId // This is an optimisation for the following check. + || multiplayerClient.Room.Playlist.Count(i => !i.Expired) > 1); + + AllowEditing = isValidItem; } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs index 53d081a108..164d1c9a4b 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Logging; @@ -11,7 +13,7 @@ using osu.Game.Screens.OnlinePlay.Lounge; namespace osu.Game.Screens.OnlinePlay.Multiplayer { - public class Multiplayer : OnlinePlayScreen + public partial class Multiplayer : OnlinePlayScreen { [Resolved] private MultiplayerClient client { get; set; } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs index a05f248d3a..dd4f35cdd4 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs @@ -1,11 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Logging; using osu.Framework.Screens; +using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.Multiplayer; @@ -17,7 +23,7 @@ using osu.Game.Screens.OnlinePlay.Match; namespace osu.Game.Screens.OnlinePlay.Multiplayer { - public class MultiplayerLoungeSubScreen : LoungeSubScreen + public partial class MultiplayerLoungeSubScreen : LoungeSubScreen { [Resolved] private IAPIProvider api { get; set; } @@ -25,6 +31,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer [Resolved] private MultiplayerClient client { get; set; } + private Dropdown roomAccessTypeDropdown; + public override void OnResuming(ScreenTransitionEvent e) { base.OnResuming(e); @@ -38,10 +46,24 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer } } + protected override IEnumerable CreateFilterControls() + { + roomAccessTypeDropdown = new SlimEnumDropdown + { + RelativeSizeAxes = Axes.None, + Width = 160, + }; + + roomAccessTypeDropdown.Current.BindValueChanged(_ => UpdateFilter()); + + return base.CreateFilterControls().Append(roomAccessTypeDropdown); + } + protected override FilterCriteria CreateFilterCriteria() { var criteria = base.CreateFilterCriteria(); criteria.Category = @"realtime"; + criteria.Permissions = roomAccessTypeDropdown.Current.Value; return criteria; } @@ -68,7 +90,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer base.OpenNewRoom(room); } - private class MultiplayerListingPollingComponent : ListingPollingComponent + private partial class MultiplayerListingPollingComponent : ListingPollingComponent { [Resolved] private MultiplayerClient client { get; set; } @@ -79,7 +101,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private void load() { isConnected.BindTo(client.IsConnected); - isConnected.BindValueChanged(c => Scheduler.AddOnce(poll), true); + isConnected.BindValueChanged(_ => Scheduler.AddOnce(poll), true); } private void poll() diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs index 929c3ee321..873a1b0d50 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs @@ -1,49 +1,44 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Logging; using osu.Framework.Screens; -using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; -using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Select; namespace osu.Game.Screens.OnlinePlay.Multiplayer { - public class MultiplayerMatchSongSelect : OnlinePlaySongSelect + public partial class MultiplayerMatchSongSelect : OnlinePlaySongSelect { [Resolved] - private MultiplayerClient client { get; set; } + private MultiplayerClient client { get; set; } = null!; - private readonly long? itemToEdit; + [Resolved] + private OngoingOperationTracker operationTracker { get; set; } = null!; - private LoadingLayer loadingLayer; + private readonly IBindable operationInProgress = new Bindable(); + private readonly PlaylistItem? itemToEdit; + + private LoadingLayer loadingLayer = null!; + private IDisposable? selectionOperation; /// /// Construct a new instance of multiplayer song select. /// /// The room. /// The item to be edited. May be null, in which case a new item will be added to the playlist. - /// An optional initial beatmap selection to perform. - /// An optional initial ruleset selection to perform. - public MultiplayerMatchSongSelect(Room room, long? itemToEdit = null, WorkingBeatmap beatmap = null, RulesetInfo ruleset = null) - : base(room) + public MultiplayerMatchSongSelect(Room room, PlaylistItem? itemToEdit = null) + : base(room, itemToEdit) { this.itemToEdit = itemToEdit; - - if (beatmap != null || ruleset != null) - { - Schedule(() => - { - if (beatmap != null) Beatmap.Value = beatmap; - if (ruleset != null) Ruleset.Value = ruleset; - }); - } } [BackgroundDependencyLoader] @@ -52,17 +47,39 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer AddInternal(loadingLayer = new LoadingLayer(true)); } - protected override void SelectItem(PlaylistItem item) + protected override void LoadComplete() { + base.LoadComplete(); + + operationInProgress.BindTo(operationTracker.InProgress); + operationInProgress.BindValueChanged(_ => updateLoadingLayer(), true); + } + + private void updateLoadingLayer() + { + if (operationInProgress.Value) + loadingLayer.Show(); + else + loadingLayer.Hide(); + } + + protected override bool SelectItem(PlaylistItem item) + { + if (operationInProgress.Value) + { + Logger.Log($"{nameof(SelectItem)} aborted due to {nameof(operationInProgress)}"); + return false; + } + // If the client is already in a room, update via the client. // Otherwise, update the playlist directly in preparation for it to be submitted to the API on match creation. if (client.Room != null) { - loadingLayer.Show(); + selectionOperation = operationTracker.BeginOperation(); var multiplayerItem = new MultiplayerPlaylistItem { - ID = itemToEdit ?? 0, + ID = itemToEdit?.ID ?? 0, BeatmapID = item.Beatmap.OnlineID, BeatmapChecksum = item.Beatmap.MD5Hash, RulesetID = item.RulesetID, @@ -72,18 +89,25 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Task task = itemToEdit != null ? client.EditPlaylistItem(multiplayerItem) : client.AddPlaylistItem(multiplayerItem); - task.FireAndForget(onSuccess: () => Schedule(() => + task.FireAndForget(onSuccess: () => { - loadingLayer.Hide(); + selectionOperation.Dispose(); - // If an error or server side trigger occurred this screen may have already exited by external means. - if (this.IsCurrentScreen()) - this.Exit(); - }), onError: _ => Schedule(() => + Schedule(() => + { + // If an error or server side trigger occurred this screen may have already exited by external means. + if (this.IsCurrentScreen()) + this.Exit(); + }); + }, onError: _ => { - loadingLayer.Hide(); - Carousel.AllowSelection = true; - })); + selectionOperation.Dispose(); + + Schedule(() => + { + Carousel.AllowSelection = true; + }); + }); } else { @@ -91,6 +115,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Playlist.Add(item); this.Exit(); } + + return true; } protected override BeatmapDetailArea CreateBeatmapDetailArea() => new PlayBeatmapDetailArea(); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index 769873f74c..a36c7e801e 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -13,6 +15,7 @@ using osu.Framework.Screens; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Graphics.Cursor; using osu.Game.Online; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; @@ -35,17 +38,17 @@ using ParticipantsList = osu.Game.Screens.OnlinePlay.Multiplayer.Participants.Pa namespace osu.Game.Screens.OnlinePlay.Multiplayer { [Cached] - public class MultiplayerMatchSubScreen : RoomSubScreen, IHandlePresentBeatmap + public partial class MultiplayerMatchSubScreen : RoomSubScreen, IHandlePresentBeatmap { public override string Title { get; } public override string ShortTitle => "room"; + protected override bool PlayExitSound => !exitConfirmed; + [Resolved] private MultiplayerClient client { get; set; } - private readonly IBindable isConnected = new Bindable(); - private AddItemButton addItemButton; public MultiplayerMatchSubScreen(Room room) @@ -65,146 +68,146 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer client.LoadRequested += onLoadRequested; client.RoomUpdated += onRoomUpdated; - isConnected.BindTo(client.IsConnected); - isConnected.BindValueChanged(connected => - { - if (!connected.NewValue) - handleRoomLost(); - }, true); + if (!client.IsConnected.Value) + handleRoomLost(); } protected override Drawable CreateMainContent() => new Container { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Horizontal = 5, Vertical = 10 }, - Child = new GridContainer + Child = new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] + Child = new GridContainer { - new Dimension(), - new Dimension(GridSizeMode.Absolute, 10), - new Dimension(), - new Dimension(GridSizeMode.Absolute, 10), - new Dimension(), - }, - Content = new[] - { - new Drawable[] + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] { - // Participants column - new GridContainer + new Dimension(), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(), + }, + Content = new[] + { + new Drawable[] { - RelativeSizeAxes = Axes.Both, - RowDimensions = new[] + // Participants column + new GridContainer { - new Dimension(GridSizeMode.AutoSize) - }, - Content = new[] - { - new Drawable[] { new ParticipantsListHeader() }, - new Drawable[] + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { - new ParticipantsList - { - RelativeSizeAxes = Axes.Both - }, - } - } - }, - // Spacer - null, - // Beatmap column - new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] { new OverlinedHeader("Beatmap") }, - new Drawable[] - { - addItemButton = new AddItemButton - { - RelativeSizeAxes = Axes.X, - Height = 40, - Text = "Add item", - Action = () => OpenSongSelection() - }, + new Dimension(GridSizeMode.AutoSize) }, - null, - new Drawable[] + Content = new[] { - new MultiplayerPlaylist + new Drawable[] { new ParticipantsListHeader() }, + new Drawable[] { - RelativeSizeAxes = Axes.Both, - RequestEdit = item => OpenSongSelection(item.ID) - } - }, - new[] - { - UserModsSection = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Margin = new MarginPadding { Top = 10 }, - Alpha = 0, - Children = new Drawable[] + new ParticipantsList { - new OverlinedHeader("Extra mods"), - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10, 0), - Children = new Drawable[] - { - new UserModSelectButton - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Width = 90, - Text = "Select", - Action = ShowUserModSelect, - }, - new ModDisplay - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Current = UserMods, - Scale = new Vector2(0.8f), - }, - } - }, + RelativeSizeAxes = Axes.Both + }, + } + } + }, + // Spacer + null, + // Beatmap column + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] { new OverlinedHeader("Beatmap") }, + new Drawable[] + { + addItemButton = new AddItemButton + { + RelativeSizeAxes = Axes.X, + Height = 40, + Text = "Add item", + Action = () => OpenSongSelection() + }, + }, + null, + new Drawable[] + { + new MultiplayerPlaylist + { + RelativeSizeAxes = Axes.Both, + RequestEdit = OpenSongSelection } }, + new[] + { + UserModsSection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 10 }, + Alpha = 0, + Children = new Drawable[] + { + new OverlinedHeader("Extra mods"), + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + new UserModSelectButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Width = 90, + Text = "Select", + Action = ShowUserModSelect, + }, + new ModDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Current = UserMods, + Scale = new Vector2(0.8f), + }, + } + }, + } + }, + }, }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 5), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + } }, - RowDimensions = new[] + // Spacer + null, + // Main right column + new GridContainer { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.Absolute, 5), - new Dimension(), - new Dimension(GridSizeMode.AutoSize), - } - }, - // Spacer - null, - // Main right column - new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] { new OverlinedHeader("Chat") }, - new Drawable[] { new MatchChatDisplay(Room) { RelativeSizeAxes = Axes.Both } } + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] { new OverlinedHeader("Chat") }, + new Drawable[] { new MatchChatDisplay(Room) { RelativeSizeAxes = Axes.Both } } + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + } }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - } - }, + } } } } @@ -214,7 +217,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer /// Opens the song selection screen to add or edit an item. /// /// An optional playlist item to edit. If null, a new item will be added instead. - internal void OpenSongSelection(long? itemToEdit = null) + internal void OpenSongSelection(PlaylistItem itemToEdit = null) { if (!this.IsCurrentScreen()) return; @@ -411,7 +414,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer return; } - this.Push(new MultiplayerMatchSongSelect(Room, client.Room.Settings.PlaylistItemId, beatmap, ruleset)); + this.Push(new MultiplayerMatchSongSelect(Room, Room.Playlist.Single(item => item.ID == client.Room.Settings.PlaylistItemId))); } protected override void Dispose(bool isDisposing) @@ -427,7 +430,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer modSettingChangeTracker?.Dispose(); } - public class AddItemButton : PurpleTriangleButton + public partial class AddItemButton : PurpleRoundedButton { } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs index 43b128b971..7b448e4b5c 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs @@ -1,14 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Game.Graphics.UserInterface; @@ -19,11 +19,10 @@ using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Ranking; using osu.Game.Users; -using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer { - public class MultiplayerPlayer : RoomSubmittingPlayer + public partial class MultiplayerPlayer : RoomSubmittingPlayer { protected override bool PauseOnFocusLost => false; @@ -39,14 +38,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private readonly TaskCompletionSource resultsReady = new TaskCompletionSource(); - private MultiplayerGameplayLeaderboard leaderboard; - private readonly MultiplayerRoomUser[] users; - private readonly Bindable leaderboardExpanded = new BindableBool(); - private LoadingLayer loadingDisplay; - private FillFlowContainer leaderboardFlow; + + private MultiplayerGameplayLeaderboard multiplayerLeaderboard; /// /// Construct a multiplayer player. @@ -59,7 +55,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { AllowPause = false, AllowRestart = false, - AllowSkipping = false, + AllowSkipping = room.AutoSkip.Value, + AutomaticallySkipIntro = room.AutoSkip.Value, + AlwaysShowLeaderboard = true, }) { this.users = users; @@ -71,45 +69,33 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (!LoadedBeatmapSuccessfully) return; - HUDOverlay.Add(leaderboardFlow = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(5) - }); - - HUDOverlay.HoldingForHUD.BindValueChanged(_ => updateLeaderboardExpandedState()); - LocalUserPlaying.BindValueChanged(_ => updateLeaderboardExpandedState(), true); - - // todo: this should be implemented via a custom HUD implementation, and correctly masked to the main content area. - LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(users), l => - { - if (!LoadedBeatmapSuccessfully) - return; - - leaderboard.Expanded.BindTo(leaderboardExpanded); - - leaderboardFlow.Insert(0, l); - - if (leaderboard.TeamScores.Count >= 2) - { - LoadComponentAsync(new GameplayMatchScoreDisplay - { - Team1Score = { BindTarget = leaderboard.TeamScores.First().Value }, - Team2Score = { BindTarget = leaderboard.TeamScores.Last().Value }, - Expanded = { BindTarget = HUDOverlay.ShowHud }, - }, scoreDisplay => leaderboardFlow.Insert(1, scoreDisplay)); - } - }); - LoadComponentAsync(new GameplayChatDisplay(Room) { - Expanded = { BindTarget = leaderboardExpanded }, - }, chat => leaderboardFlow.Insert(2, chat)); + Expanded = { BindTarget = LeaderboardExpandedState }, + }, chat => HUDOverlay.LeaderboardFlow.Insert(2, chat)); HUDOverlay.Add(loadingDisplay = new LoadingLayer(true) { Depth = float.MaxValue }); } + protected override GameplayLeaderboard CreateGameplayLeaderboard() => multiplayerLeaderboard = new MultiplayerGameplayLeaderboard(users); + + protected override void AddLeaderboardToHUD(GameplayLeaderboard leaderboard) + { + Debug.Assert(leaderboard == multiplayerLeaderboard); + + HUDOverlay.LeaderboardFlow.Insert(0, leaderboard); + + if (multiplayerLeaderboard.TeamScores.Count >= 2) + { + LoadComponentAsync(new GameplayMatchScoreDisplay + { + Team1Score = { BindTarget = multiplayerLeaderboard.TeamScores.First().Value }, + Team2Score = { BindTarget = multiplayerLeaderboard.TeamScores.Last().Value }, + Expanded = { BindTarget = HUDOverlay.ShowHud }, + }, scoreDisplay => HUDOverlay.LeaderboardFlow.Insert(1, scoreDisplay)); + } + } + protected override void LoadAsyncComplete() { base.LoadAsyncComplete(); @@ -123,7 +109,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer client.GameplayStarted += onGameplayStarted; client.ResultsReady += onResultsReady; - ScoreProcessor.HasCompleted.BindValueChanged(completed => + ScoreProcessor.HasCompleted.BindValueChanged(_ => { // wait for server to tell us that results are ready (see SubmitScore implementation) loadingDisplay.Show(); @@ -164,9 +150,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer } } - private void updateLeaderboardExpandedState() => - leaderboardExpanded.Value = !LocalUserPlaying.Value || HUDOverlay.HoldingForHUD.Value; - private void failAndBail(string message = null) { if (!string.IsNullOrEmpty(message)) @@ -175,23 +158,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Schedule(() => PerformExit(false)); } - protected override void Update() - { - base.Update(); - - if (!LoadedBeatmapSuccessfully) - return; - - adjustLeaderboardPosition(); - } - - private void adjustLeaderboardPosition() - { - const float padding = 44; // enough margin to avoid the hit error display. - - leaderboardFlow.Position = new Vector2(padding, padding + HUDOverlay.TopScoringElementsHeight); - } - private void onGameplayStarted() => Scheduler.Add(() => { if (!this.IsCurrentScreen()) @@ -229,8 +195,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { Debug.Assert(Room.RoomID.Value != null); - return leaderboard.TeamScores.Count == 2 - ? new MultiplayerTeamResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem, leaderboard.TeamScores) + return multiplayerLeaderboard.TeamScores.Count == 2 + ? new MultiplayerTeamResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem, multiplayerLeaderboard.TeamScores) : new MultiplayerResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem); } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs index e9bf5339a9..f682508319 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Threading.Tasks; using osu.Framework.Allocation; @@ -11,7 +13,7 @@ using osu.Game.Screens.Play; namespace osu.Game.Screens.OnlinePlay.Multiplayer { - public class MultiplayerPlayerLoader : PlayerLoader + public partial class MultiplayerPlayerLoader : PlayerLoader { public bool GameplayPassed => player?.GameplayState.HasPassed == true; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerResultsScreen.cs index 140b3c45d8..de19d3a0e9 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerResultsScreen.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Online.Rooms; using osu.Game.Scoring; using osu.Game.Screens.OnlinePlay.Playlists; namespace osu.Game.Screens.OnlinePlay.Multiplayer { - public class MultiplayerResultsScreen : PlaylistsResultsScreen + public partial class MultiplayerResultsScreen : PlaylistsResultsScreen { public MultiplayerResultsScreen(ScoreInfo score, long roomId, PlaylistItem playlistItem) : base(score, roomId, playlistItem, false, false) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs index f6f815a3cb..ee5c84bf40 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Game.Online.Multiplayer; @@ -8,7 +10,7 @@ using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Multiplayer { - public abstract class MultiplayerRoomComposite : OnlinePlayComposite + public abstract partial class MultiplayerRoomComposite : OnlinePlayComposite { [CanBeNull] protected MultiplayerRoom Room => Client.Room; @@ -108,6 +110,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (Client != null) { Client.RoomUpdated -= invokeOnRoomUpdated; + Client.LoadRequested -= invokeOnRoomLoadRequested; Client.UserLeft -= invokeUserLeft; Client.UserKicked -= invokeUserKicked; Client.UserJoined -= invokeUserJoined; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs index 2d94b2328d..5f51ccc8d4 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using osu.Framework.Allocation; @@ -13,7 +15,7 @@ using osu.Game.Screens.OnlinePlay.Components; namespace osu.Game.Screens.OnlinePlay.Multiplayer { - public class MultiplayerRoomManager : RoomManager + public partial class MultiplayerRoomManager : RoomManager { [Resolved] private MultiplayerClient multiplayerClient { get; set; } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs index 49b5b7fed9..90595bc33b 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomSounds.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -10,7 +12,7 @@ using osu.Game.Online.Multiplayer; namespace osu.Game.Screens.OnlinePlay.Multiplayer { - public class MultiplayerRoomSounds : MultiplayerRoomComposite + public partial class MultiplayerRoomSounds : MultiplayerRoomComposite { private Sample hostChangedSample; private Sample userJoinedSample; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerTeamResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerTeamResultsScreen.cs index 3f0f3e043c..a8c513603c 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerTeamResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerTeamResultsScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -22,7 +24,7 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer { - public class MultiplayerTeamResultsScreen : MultiplayerResultsScreen + public partial class MultiplayerTeamResultsScreen : MultiplayerResultsScreen { private readonly SortedDictionary teamScores; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs index e091559046..c79c210e30 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -20,6 +21,7 @@ using osu.Game.Online.API; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; using osu.Game.Screens.Play.HUD; using osu.Game.Users; using osu.Game.Users.Drawables; @@ -28,23 +30,23 @@ using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { - public class ParticipantPanel : MultiplayerRoomComposite, IHasContextMenu + public partial class ParticipantPanel : MultiplayerRoomComposite, IHasContextMenu { public readonly MultiplayerRoomUser User; [Resolved] - private IAPIProvider api { get; set; } + private IAPIProvider api { get; set; } = null!; [Resolved] - private IRulesetStore rulesets { get; set; } + private IRulesetStore rulesets { get; set; } = null!; - private SpriteIcon crown; + private SpriteIcon crown = null!; - private OsuSpriteText userRankText; - private ModDisplay userModsDisplay; - private StateDisplay userStateDisplay; + private OsuSpriteText userRankText = null!; + private ModDisplay userModsDisplay = null!; + private StateDisplay userStateDisplay = null!; - private IconButton kickButton; + private IconButton kickButton = null!; public ParticipantPanel(MultiplayerRoomUser user) { @@ -125,15 +127,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Size = new Vector2(30, 20), - Country = user?.Country + Size = new Vector2(28, 20), + CountryCode = user?.CountryCode ?? default }, new OsuSpriteText { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 18), - Text = user?.Username + Text = user?.Username ?? string.Empty }, userRankText = new OsuSpriteText { @@ -186,7 +188,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants const double fade_time = 50; var currentItem = Playlist.GetCurrentItem(); - var ruleset = currentItem != null ? rulesets.GetRuleset(currentItem.RulesetID)?.CreateInstance() : null; + Ruleset? ruleset = currentItem != null ? rulesets.GetRuleset(currentItem.RulesetID)?.CreateInstance() : null; int? currentModeRank = ruleset != null ? User.User?.RulesetsStatistics?.GetValueOrDefault(ruleset.ShortName)?.GlobalRank : null; userRankText.Text = currentModeRank != null ? $"#{currentModeRank.Value:N0}" : string.Empty; @@ -203,10 +205,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants // If the mods are updated at the end of the frame, the flow container will skip a reflow cycle: https://github.com/ppy/osu-framework/issues/4187 // This looks particularly jarring here, so re-schedule the update to that start of our frame as a fix. - Schedule(() => userModsDisplay.Current.Value = User.Mods.Select(m => m.ToMod(ruleset)).ToList()); + Schedule(() => + { + userModsDisplay.Current.Value = ruleset != null ? User.Mods.Select(m => m.ToMod(ruleset)).ToList() : Array.Empty(); + }); } - public MenuItem[] ContextMenuItems + public MenuItem[]? ContextMenuItems { get { @@ -245,7 +250,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants } } - public class KickButton : IconButton + public partial class KickButton : IconButton { public KickButton() { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs index 14b930f115..6a7a3758c3 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs @@ -1,18 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Cursor; using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { - public class ParticipantsList : MultiplayerRoomComposite + public partial class ParticipantsList : MultiplayerRoomComposite { private FillFlowContainer panels; @@ -22,20 +23,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants [BackgroundDependencyLoader] private void load() { - InternalChild = new OsuContextMenuContainer + InternalChild = new OsuScrollContainer { RelativeSizeAxes = Axes.Both, - Child = new OsuScrollContainer + ScrollbarVisible = false, + Child = panels = new FillFlowContainer { - RelativeSizeAxes = Axes.Both, - ScrollbarVisible = false, - Child = panels = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 2) - } + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 2) } }; } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs index ef84c4b4fa..7f4e3360e4 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Game.Online.Multiplayer; using osu.Game.Resources.Localisation.Web; @@ -8,7 +10,7 @@ using osu.Game.Screens.OnlinePlay.Components; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { - public class ParticipantsListHeader : OverlinedHeader + public partial class ParticipantsListHeader : OverlinedHeader { [Resolved] private MultiplayerClient client { get; set; } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs index 658fc43e8d..bfdc0c02ac 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -19,13 +18,13 @@ using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { - public class StateDisplay : CompositeDrawable + public partial class StateDisplay : CompositeDrawable { private const double fade_time = 50; - private SpriteIcon icon; - private OsuSpriteText text; - private ProgressBar progressBar; + private SpriteIcon icon = null!; + private OsuSpriteText text = null!; + private ProgressBar progressBar = null!; public StateDisplay() { @@ -84,7 +83,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants }; } - private OsuColour colours; + private OsuColour colours = null!; public void UpdateStatus(MultiplayerUserState state, BeatmapAvailability availability) { @@ -162,10 +161,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants break; case DownloadState.Downloading: - Debug.Assert(availability.DownloadProgress != null); - progressBar.FadeIn(fade_time); - progressBar.CurrentTime = availability.DownloadProgress.Value; + progressBar.CurrentTime = availability.DownloadProgress ?? 0; text.Text = "downloading map"; icon.Icon = FontAwesome.Solid.ArrowAltCircleDown; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs index aca2c6073a..fe57ad26a5 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -18,7 +20,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { - internal class TeamDisplay : MultiplayerRoomComposite + internal partial class TeamDisplay : MultiplayerRoomComposite { private readonly MultiplayerRoomUser user; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSpectatorPlayerClock.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSpectatorPlayerClock.cs deleted file mode 100644 index 48d0b063ff..0000000000 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSpectatorPlayerClock.cs +++ /dev/null @@ -1,98 +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 enable - -using System; -using osu.Framework.Bindables; -using osu.Framework.Timing; - -namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate -{ - /// - /// A which catches up using rate adjustment. - /// - public class CatchUpSpectatorPlayerClock : ISpectatorPlayerClock - { - /// - /// The catch up rate. - /// - public const double CATCHUP_RATE = 2; - - /// - /// The source clock. - /// - public IFrameBasedClock? Source { get; set; } - - public double CurrentTime { get; private set; } - - public bool IsRunning { get; private set; } - - public void Reset() => CurrentTime = 0; - - public void Start() => IsRunning = true; - - public void Stop() => IsRunning = false; - - void IAdjustableClock.Start() - { - // Our running state should only be managed by an ISyncManager, ignore calls from external sources. - } - - void IAdjustableClock.Stop() - { - // Our running state should only be managed by an ISyncManager, ignore calls from external sources. - } - - public bool Seek(double position) - { - CurrentTime = position; - return true; - } - - public void ResetSpeedAdjustments() - { - } - - public double Rate => IsCatchingUp ? CATCHUP_RATE : 1; - - double IAdjustableClock.Rate - { - get => Rate; - set => throw new NotSupportedException(); - } - - double IClock.Rate => Rate; - - public void ProcessFrame() - { - ElapsedFrameTime = 0; - FramesPerSecond = 0; - - if (Source == null) - return; - - Source.ProcessFrame(); - - if (IsRunning) - { - double elapsedSource = Source.ElapsedFrameTime; - double elapsed = elapsedSource * Rate; - - CurrentTime += elapsed; - ElapsedFrameTime = elapsed; - FramesPerSecond = Source.FramesPerSecond; - } - } - - public double ElapsedFrameTime { get; private set; } - - public double FramesPerSecond { get; private set; } - - public FrameTimeInfo TimeInfo => new FrameTimeInfo { Elapsed = ElapsedFrameTime, Current = CurrentTime }; - - public Bindable WaitingOnFrames { get; } = new Bindable(true); - - public bool IsCatchingUp { get; set; } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISpectatorPlayerClock.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISpectatorPlayerClock.cs deleted file mode 100644 index b2ecb105c2..0000000000 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISpectatorPlayerClock.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Bindables; -using osu.Framework.Timing; - -namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate -{ - /// - /// A clock which is used by s and managed by an . - /// - public interface ISpectatorPlayerClock : IFrameBasedClock, IAdjustableClock - { - /// - /// Starts this . - /// - new void Start(); - - /// - /// Stops this . - /// - new void Stop(); - - /// - /// Whether this clock is waiting on frames to continue playback. - /// - Bindable WaitingOnFrames { get; } - - /// - /// Whether this clock is behind the master clock and running at a higher rate to catch up to it. - /// - /// - /// Of note, this will be false if this clock is *ahead* of the master clock. - /// - bool IsCatchingUp { get; set; } - - /// - /// The source clock - /// - IFrameBasedClock Source { set; } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISyncManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISyncManager.cs deleted file mode 100644 index 3c644ccb78..0000000000 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISyncManager.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Framework.Bindables; -using osu.Framework.Timing; - -namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate -{ - /// - /// Manages the synchronisation between one or more s in relation to a master clock. - /// - public interface ISyncManager - { - /// - /// An event which is invoked when gameplay is ready to start. - /// - event Action ReadyToStart; - - /// - /// The master clock which player clocks should synchronise to. - /// - IAdjustableClock MasterClock { get; } - - /// - /// An event which is invoked when the state of is changed. - /// - IBindable MasterState { get; } - - /// - /// Adds an to manage. - /// - /// The to add. - void AddPlayerClock(ISpectatorPlayerClock clock); - - /// - /// Removes an , stopping it from being managed by this . - /// - /// The to remove. - void RemovePlayerClock(ISpectatorPlayerClock clock); - } -} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MasterClockState.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MasterClockState.cs index 8982d1669d..92dbde9f08 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MasterClockState.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MasterClockState.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate { public enum MasterClockState diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs index 4e9ab07e4c..ed92b719fc 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs @@ -8,7 +8,7 @@ using osu.Game.Screens.Play.HUD; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate { - public class MultiSpectatorLeaderboard : MultiplayerGameplayLeaderboard + public partial class MultiSpectatorLeaderboard : MultiplayerGameplayLeaderboard { public MultiSpectatorLeaderboard(MultiplayerRoomUser[] users) : base(users) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs index 29afaf00d8..930bea4497 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs @@ -1,75 +1,77 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using JetBrains.Annotations; +using System.Threading; using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Timing; +using osu.Framework.Audio; using osu.Game.Beatmaps; using osu.Game.Scoring; using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate { /// /// A single spectated player within a . /// - public class MultiSpectatorPlayer : SpectatorPlayer + public partial class MultiSpectatorPlayer : SpectatorPlayer { - private readonly Bindable waitingOnFrames = new Bindable(true); - private readonly ISpectatorPlayerClock spectatorPlayerClock; + /// + /// All adjustments applied to the clock of this which come from mods. + /// + public IAggregateAudioAdjustment ClockAdjustmentsFromMods => clockAdjustmentsFromMods; + + private readonly AudioAdjustments clockAdjustmentsFromMods = new AudioAdjustments(); + private readonly SpectatorPlayerClock spectatorPlayerClock; /// /// Creates a new . /// /// The score containing the player's replay. /// The clock controlling the gameplay running state. - public MultiSpectatorPlayer([NotNull] Score score, [NotNull] ISpectatorPlayerClock spectatorPlayerClock) + public MultiSpectatorPlayer(Score score, SpectatorPlayerClock spectatorPlayerClock) : base(score, new PlayerConfiguration { AllowUserInteraction = false }) { this.spectatorPlayerClock = spectatorPlayerClock; } [BackgroundDependencyLoader] - private void load() + private void load(CancellationToken cancellationToken) { - spectatorPlayerClock.WaitingOnFrames.BindTo(waitingOnFrames); + // HUD overlay may not be loaded if load has been cancelled early. + if (cancellationToken.IsCancellationRequested) + return; HUDOverlay.PlayerSettingsOverlay.Expire(); HUDOverlay.HoldToQuit.Expire(); } + protected override void Update() + { + // The player clock's running state is controlled externally, but the local pausing state needs to be updated to start/stop gameplay. + if (GameplayClockContainer.SourceClock.IsRunning) + GameplayClockContainer.Start(); + else + GameplayClockContainer.Stop(); + + base.Update(); + } + protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); // This is required because the frame stable clock is set to WaitingOnFrames = false for one frame. - waitingOnFrames.Value = DrawableRuleset.FrameStableClock.WaitingOnFrames.Value || Score.Replay.Frames.Count == 0; + spectatorPlayerClock.WaitingOnFrames = DrawableRuleset.FrameStableClock.WaitingOnFrames.Value || Score.Replay.Frames.Count == 0; } protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) - => new SpectatorGameplayClockContainer(spectatorPlayerClock); - - private class SpectatorGameplayClockContainer : GameplayClockContainer { - public SpectatorGameplayClockContainer([NotNull] IClock sourceClock) - : base(sourceClock) - { - } - - protected override void Update() - { - // The SourceClock here is always a CatchUpSpectatorPlayerClock. - // The player clock's running state is controlled externally, but the local pausing state needs to be updated to stop gameplay. - if (SourceClock.IsRunning) - Start(); - else - Stop(); - - base.Update(); - } - - protected override GameplayClock CreateGameplayClock(IFrameBasedClock source) => new GameplayClock(source); + var gameplayClockContainer = new GameplayClockContainer(spectatorPlayerClock); + clockAdjustmentsFromMods.BindAdjustments(gameplayClockContainer.AdjustmentsFromMods); + return gameplayClockContainer; } + + protected override ResultsScreen CreateResults(ScoreInfo score) => new MultiSpectatorResultsScreen(score); } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayerLoader.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayerLoader.cs index 14bd8fa6dc..eb55b0d18a 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayerLoader.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayerLoader.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -13,7 +15,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// /// Used to load a single in a . /// - public class MultiSpectatorPlayerLoader : SpectatorPlayerLoader + public partial class MultiSpectatorPlayerLoader : SpectatorPlayerLoader { public MultiSpectatorPlayerLoader([NotNull] Score score, [NotNull] Func createPlayer) : base(score, createPlayer) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorResultsScreen.cs new file mode 100644 index 0000000000..fe3f02466d --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorResultsScreen.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using System; +using System.Collections.Generic; +using osu.Game.Online.API; +using osu.Game.Scoring; +using osu.Game.Screens.Play; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + public partial class MultiSpectatorResultsScreen : SpectatorResultsScreen + { + public MultiSpectatorResultsScreen(ScoreInfo score) + : base(score) + { + } + + protected override APIRequest FetchScores(Action> scoresCallback) => null; + + protected override APIRequest FetchNextPage(int direction, Action> scoresCallback) => null; + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs index d9c19cdfdd..2d2aa0f1d5 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs @@ -3,12 +3,11 @@ using System; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; -using osu.Framework.Bindables; +using osu.Framework.Audio; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; @@ -24,7 +23,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// /// A that spectates multiple users in a match. /// - public class MultiSpectatorScreen : SpectatorScreen + public partial class MultiSpectatorScreen : SpectatorScreen { // Isolates beatmap/ruleset to this screen. public override bool DisallowExternalBeatmapRulesetChanges => true; @@ -35,23 +34,24 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// /// Whether all spectating players have finished loading. /// - public bool AllPlayersLoaded => instances.All(p => p?.PlayerLoaded == true); + public bool AllPlayersLoaded => instances.All(p => p.PlayerLoaded); protected override UserActivity InitialActivity => new UserActivity.SpectatingMultiplayerGame(Beatmap.Value.BeatmapInfo, Ruleset.Value); [Resolved] - private OsuColour colours { get; set; } + private OsuColour colours { get; set; } = null!; [Resolved] - private MultiplayerClient multiplayerClient { get; set; } + private MultiplayerClient multiplayerClient { get; set; } = null!; + + private IAggregateAudioAdjustment? boundAdjustments; private readonly PlayerArea[] instances; - private MasterGameplayClockContainer masterClockContainer; - private ISyncManager syncManager; - private PlayerGrid grid; - private MultiSpectatorLeaderboard leaderboard; - private PlayerArea currentAudioSource; - private bool canStartMasterClock; + private MasterGameplayClockContainer masterClockContainer = null!; + private SpectatorSyncManager syncManager = null!; + private PlayerGrid grid = null!; + private MultiSpectatorLeaderboard leaderboard = null!; + private PlayerArea? currentAudioSource; private readonly Room room; private readonly MultiplayerRoomUser[] users; @@ -76,65 +76,66 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate FillFlowContainer leaderboardFlow; Container scoreDisplayContainer; - masterClockContainer = CreateMasterGameplayClockContainer(Beatmap.Value); - - InternalChildren = new[] + InternalChildren = new Drawable[] { - (Drawable)(syncManager = new CatchUpSyncManager(masterClockContainer)), - masterClockContainer.WithChild(new GridContainer + masterClockContainer = new MasterGameplayClockContainer(Beatmap.Value, 0) { - RelativeSizeAxes = Axes.Both, - RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, - Content = new[] + Child = new GridContainer { - new Drawable[] + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + Content = new[] { - scoreDisplayContainer = new Container + new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y - }, - }, - new Drawable[] - { - new GridContainer - { - RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, - Content = new[] + scoreDisplayContainer = new Container { - new Drawable[] + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + }, + }, + new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + Content = new[] { - leaderboardFlow = new FillFlowContainer + new Drawable[] { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(5) - }, - grid = new PlayerGrid { RelativeSizeAxes = Axes.Both } + leaderboardFlow = new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5) + }, + grid = new PlayerGrid { RelativeSizeAxes = Axes.Both } + } } } } } } - }) + }, + syncManager = new SpectatorSyncManager(masterClockContainer) + { + ReadyToStart = performInitialSeek, + } }; for (int i = 0; i < Users.Count; i++) - { - grid.Add(instances[i] = new PlayerArea(Users[i], masterClockContainer.GameplayClock)); - syncManager.AddPlayerClock(instances[i].GameplayClock); - } + grid.Add(instances[i] = new PlayerArea(Users[i], syncManager.CreateManagedClock())); LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(users) { Expanded = { Value = true }, - }, l => + }, _ => { foreach (var instance in instances) - leaderboard.AddClock(instance.UserId, instance.GameplayClock); + leaderboard.AddClock(instance.UserId, instance.SpectatorPlayerClock); leaderboardFlow.Insert(0, leaderboard); @@ -160,59 +161,50 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate masterClockContainer.Reset(); - syncManager.ReadyToStart += onReadyToStart; - syncManager.MasterState.BindValueChanged(onMasterStateChanged, true); + // Start with adjustments from the first player to keep a sane state. + bindAudioAdjustments(instances.First()); } protected override void Update() { base.Update(); - if (!isCandidateAudioSource(currentAudioSource?.GameplayClock)) + if (!isCandidateAudioSource(currentAudioSource?.SpectatorPlayerClock)) { - currentAudioSource = instances.Where(i => isCandidateAudioSource(i.GameplayClock)) - .OrderBy(i => Math.Abs(i.GameplayClock.CurrentTime - syncManager.MasterClock.CurrentTime)) - .FirstOrDefault(); + currentAudioSource = instances.Where(i => isCandidateAudioSource(i.SpectatorPlayerClock)).MinBy(i => Math.Abs(i.SpectatorPlayerClock.CurrentTime - syncManager.CurrentMasterTime)); + + // Only bind adjustments if there's actually a valid source, else just use the previous ones to ensure no sudden changes to audio. + if (currentAudioSource != null) + bindAudioAdjustments(currentAudioSource); foreach (var instance in instances) instance.Mute = instance != currentAudioSource; } } - private bool isCandidateAudioSource([CanBeNull] ISpectatorPlayerClock clock) - => clock?.IsRunning == true && !clock.IsCatchingUp && !clock.WaitingOnFrames.Value; + private void bindAudioAdjustments(PlayerArea first) + { + if (boundAdjustments != null) + masterClockContainer.AdjustmentsFromMods.UnbindAdjustments(boundAdjustments); - private void onReadyToStart() + boundAdjustments = first.ClockAdjustmentsFromMods; + masterClockContainer.AdjustmentsFromMods.BindAdjustments(boundAdjustments); + } + + private bool isCandidateAudioSource(SpectatorPlayerClock? clock) + => clock?.IsRunning == true && !clock.IsCatchingUp && !clock.WaitingOnFrames; + + private void performInitialSeek() { // Seek the master clock to the gameplay time. // This is chosen as the first available frame in the players' replays, which matches the seek by each individual SpectatorPlayer. double startTime = instances.Where(i => i.Score != null) - .SelectMany(i => i.Score.Replay.Frames) + .SelectMany(i => i.Score.AsNonNull().Replay.Frames) .Select(f => f.Time) .DefaultIfEmpty(0) .Min(); - masterClockContainer.StartTime = startTime; - masterClockContainer.Reset(true); - - // Although the clock has been started, this flag is set to allow for later synchronisation state changes to also be able to start it. - canStartMasterClock = true; - } - - private void onMasterStateChanged(ValueChangedEvent state) - { - switch (state.NewValue) - { - case MasterClockState.Synchronised: - if (canStartMasterClock) - masterClockContainer.Start(); - - break; - - case MasterClockState.TooFarAhead: - masterClockContainer.Stop(); - break; - } + masterClockContainer.Reset(startTime, true); } protected override void OnNewPlayingUserState(int userId, SpectatorState spectatorState) @@ -222,19 +214,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate protected override void StartGameplay(int userId, SpectatorGameplayState spectatorGameplayState) => instances.Single(i => i.UserId == userId).LoadScore(spectatorGameplayState.Score); - protected override void EndGameplay(int userId, SpectatorState state) + protected override void QuitGameplay(int userId) { - // Allowed passed/failed users to complete their remaining replay frames. - // The failed state isn't really possible in multiplayer (yet?) but is added here just for safety in case it starts being used. - if (state.State == SpectatedUserState.Passed || state.State == SpectatedUserState.Failed) - return; - RemoveUser(userId); var instance = instances.Single(i => i.UserId == userId); instance.FadeColour(colours.Gray4, 400, Easing.OutQuint); - syncManager.RemovePlayerClock(instance.GameplayClock); + syncManager.RemoveManagedClock(instance.SpectatorPlayerClock); } public override bool OnBackButton() @@ -248,7 +235,5 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate return base.OnBackButton(); } - - protected virtual MasterGameplayClockContainer CreateMasterGameplayClockContainer(WorkingBeatmap beatmap) => new MasterGameplayClockContainer(beatmap, 0); } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs index 4979bd906b..dc4a2df9d8 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs @@ -3,13 +3,11 @@ using System; using System.Collections.Generic; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; @@ -22,12 +20,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// /// Provides an area for and manages the hierarchy of a spectated player within a . /// - public class PlayerArea : CompositeDrawable + public partial class PlayerArea : CompositeDrawable { /// /// Raised after is called on . /// - public event Action OnGameplayStarted; + public event Action? OnGameplayStarted; /// /// Whether a is loaded in the area. @@ -40,28 +38,33 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate public readonly int UserId; /// - /// The used to control the gameplay running state of a loaded . + /// The used to control the gameplay running state of a loaded . /// - [NotNull] - public readonly ISpectatorPlayerClock GameplayClock = new CatchUpSpectatorPlayerClock(); + public readonly SpectatorPlayerClock SpectatorPlayerClock; + + /// + /// The clock adjustments applied by the loaded in this area. + /// + public IAggregateAudioAdjustment ClockAdjustmentsFromMods => clockAdjustmentsFromMods; /// /// The currently-loaded score. /// - [CanBeNull] - public Score Score { get; private set; } + public Score? Score { get; private set; } [Resolved] - private BeatmapManager beatmapManager { get; set; } + private IBindable beatmap { get; set; } = null!; + private readonly AudioAdjustments clockAdjustmentsFromMods = new AudioAdjustments(); private readonly BindableDouble volumeAdjustment = new BindableDouble(); private readonly Container gameplayContent; private readonly LoadingLayer loadingLayer; - private OsuScreenStack stack; + private OsuScreenStack? stack; - public PlayerArea(int userId, IFrameBasedClock masterClock) + public PlayerArea(int userId, SpectatorPlayerClock clock) { UserId = userId; + SpectatorPlayerClock = clock; RelativeSizeAxes = Axes.Both; Masking = true; @@ -78,18 +81,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate }; audioContainer.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment); - - GameplayClock.Source = masterClock; } - public void LoadScore([NotNull] Score score) + public void LoadScore(Score score) { if (Score != null) throw new InvalidOperationException($"Cannot load a new score on a {nameof(PlayerArea)} that has an existing score."); Score = score; - gameplayContent.Child = new PlayerIsolationContainer(beatmapManager.GetWorkingBeatmap(Score.ScoreInfo.BeatmapInfo), Score.ScoreInfo.Ruleset, Score.ScoreInfo.Mods) + gameplayContent.Child = new PlayerIsolationContainer(beatmap.Value, Score.ScoreInfo.Ruleset, Score.ScoreInfo.Mods) { RelativeSizeAxes = Axes.Both, Child = stack = new OsuScreenStack @@ -100,8 +101,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate stack.Push(new MultiSpectatorPlayerLoader(Score, () => { - var player = new MultiSpectatorPlayer(Score, GameplayClock); + var player = new MultiSpectatorPlayer(Score, SpectatorPlayerClock); player.OnGameplayStarted += () => OnGameplayStarted?.Invoke(); + + clockAdjustmentsFromMods.BindAdjustments(player.ClockAdjustmentsFromMods); + return player; })); @@ -127,7 +131,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// /// Isolates each player instance from the game-wide ruleset/beatmap/mods (to allow for different players having different settings). /// - private class PlayerIsolationContainer : Container + private partial class PlayerIsolationContainer : Container { [Cached] private readonly Bindable ruleset = new Bindable(); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.cs index 6638d47dca..82d4cf5caf 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid_Cell.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid_Cell.cs index 2df05cb5ed..4a8b8f49e1 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid_Cell.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid_Cell.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using JetBrains.Annotations; using osu.Framework.Graphics; @@ -15,7 +17,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// /// A cell of the grid. Contains the content and tracks to the linked facade. /// - private class Cell : CompositeDrawable + private partial class Cell : CompositeDrawable { /// /// The index of the original facade of this cell. diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid_Facade.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid_Facade.cs index 6b363c6040..2f4ed35392 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid_Facade.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid_Facade.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate @@ -10,7 +12,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// /// A facade of the grid which is used as a dummy object to store the required position/size of cells. /// - private class Facade : Drawable + private partial class Facade : Drawable { public Facade() { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorPlayerClock.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorPlayerClock.cs new file mode 100644 index 0000000000..45615d4e19 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorPlayerClock.cs @@ -0,0 +1,104 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Timing; +using osu.Game.Screens.Play; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + /// + /// A clock which catches up using rate adjustment. + /// + public class SpectatorPlayerClock : IFrameBasedClock, IAdjustableClock + { + /// + /// The catch up rate. + /// + private const double catchup_rate = 2; + + private readonly GameplayClockContainer masterClock; + + public double CurrentTime { get; private set; } + + /// + /// Whether this clock is waiting on frames to continue playback. + /// + public bool WaitingOnFrames { get; set; } = true; + + /// + /// Whether this clock is behind the master clock and running at a higher rate to catch up to it. + /// + /// + /// Of note, this will be false if this clock is *ahead* of the master clock. + /// + public bool IsCatchingUp { get; set; } + + /// + /// Whether this spectator clock should be running. + /// Use instead of / to control time. + /// + public bool IsRunning { get; set; } + + public SpectatorPlayerClock(GameplayClockContainer masterClock) + { + this.masterClock = masterClock; + } + + public void Reset() => CurrentTime = 0; + + public void Start() + { + // Our running state should only be managed by SpectatorSyncManager via IsRunning. + } + + public void Stop() + { + // Our running state should only be managed by an SpectatorSyncManager via IsRunning. + } + + public bool Seek(double position) + { + CurrentTime = position; + return true; + } + + public void ResetSpeedAdjustments() + { + } + + public double Rate + { + get => IsCatchingUp ? catchup_rate : 1; + set => throw new NotImplementedException(); + } + + public void ProcessFrame() + { + if (IsRunning) + { + // When in catch-up mode, the source is usually not running. + // In such a case, its elapsed time may be zero, which would cause catch-up to get stuck. + // To avoid this, use a constant 16ms elapsed time for now. Probably not too correct, but this whole logic isn't too correct anyway. + // Clamping is required to ensure that player clocks don't get too far ahead if ProcessFrame is run multiple times. + double elapsedSource = masterClock.ElapsedFrameTime != 0 ? masterClock.ElapsedFrameTime : Math.Clamp(masterClock.CurrentTime - CurrentTime, 0, 16); + double elapsed = elapsedSource * Rate; + + CurrentTime += elapsed; + ElapsedFrameTime = elapsed; + FramesPerSecond = masterClock.FramesPerSecond; + } + else + { + ElapsedFrameTime = 0; + FramesPerSecond = 0; + } + } + + public double ElapsedFrameTime { get; private set; } + + public double FramesPerSecond { get; private set; } + + public FrameTimeInfo TimeInfo => new FrameTimeInfo { Elapsed = ElapsedFrameTime, Current = CurrentTime }; + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorSyncManager.cs similarity index 66% rename from osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs rename to osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorSyncManager.cs index b0fdeddd56..615c0d7c2b 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorSyncManager.cs @@ -3,18 +3,17 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; -using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Timing; +using osu.Framework.Logging; +using osu.Game.Screens.Play; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate { /// - /// A which synchronises de-synced player clocks through catchup. + /// Manages the synchronisation between one or more s in relation to a master clock. /// - public class CatchUpSyncManager : Component, ISyncManager + public partial class SpectatorSyncManager : Component { /// /// The offset from the master clock to which player clocks should remain within to be considered in-sync. @@ -31,40 +30,53 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// public const double MAXIMUM_START_DELAY = 15000; - public event Action ReadyToStart; + /// + /// An event which is invoked when gameplay is ready to start. + /// + public Action? ReadyToStart; + + public double CurrentMasterTime => masterClock.CurrentTime; /// /// The master clock which is used to control the timing of all player clocks clocks. /// - public IAdjustableClock MasterClock { get; } - - public IBindable MasterState => masterState; + private readonly GameplayClockContainer masterClock; /// /// The player clocks. /// - private readonly List playerClocks = new List(); + private readonly List playerClocks = new List(); - private readonly Bindable masterState = new Bindable(); + private MasterClockState masterState = MasterClockState.Synchronised; private bool hasStarted; + private double? firstStartAttemptTime; - public CatchUpSyncManager(IAdjustableClock master) + public SpectatorSyncManager(GameplayClockContainer master) { - MasterClock = master; + masterClock = master; } - public void AddPlayerClock(ISpectatorPlayerClock clock) + /// + /// Create a new managed . + /// + /// The newly created . + public SpectatorPlayerClock CreateManagedClock() { - Debug.Assert(!playerClocks.Contains(clock)); + var clock = new SpectatorPlayerClock(masterClock); playerClocks.Add(clock); + return clock; } - public void RemovePlayerClock(ISpectatorPlayerClock clock) + /// + /// Removes an , stopping it from being managed by this . + /// + /// The to remove. + public void RemoveManagedClock(SpectatorPlayerClock clock) { playerClocks.Remove(clock); - clock.Stop(); + clock.IsRunning = false; } protected override void Update() @@ -75,7 +87,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate { // Ensure all player clocks are stopped until the start succeeds. foreach (var clock in playerClocks) - clock.Stop(); + clock.IsRunning = false; return; } @@ -95,7 +107,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate if (playerClocks.Count == 0) return false; - int readyCount = playerClocks.Count(s => !s.WaitingOnFrames.Value); + int readyCount = playerClocks.Count(s => !s.WaitingOnFrames); if (readyCount == playerClocks.Count) return performStart(); @@ -128,7 +140,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate // How far this player's clock is out of sync, compared to the master clock. // A negative value means the player is running fast (ahead); a positive value means the player is running behind (catching up). - double timeDelta = MasterClock.CurrentTime - clock.CurrentTime; + double timeDelta = masterClock.CurrentTime - clock.CurrentTime; // Check that the player clock isn't too far ahead. // This is a quiet case in which the catchup is done by the master clock, so IsCatchingUp is not set on the player clock. @@ -137,15 +149,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate // Importantly, set the clock to a non-catchup state. if this isn't done, updateMasterState may incorrectly pause the master clock // when it is required to be running (ie. if all players are ahead of the master). clock.IsCatchingUp = false; - clock.Stop(); + clock.IsRunning = false; continue; } // Make sure the player clock is running if it can. - if (!clock.WaitingOnFrames.Value) - clock.Start(); - else - clock.Stop(); + clock.IsRunning = !clock.WaitingOnFrames; if (clock.IsCatchingUp) { @@ -167,8 +176,26 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// private void updateMasterState() { - bool anyInSync = playerClocks.Any(s => !s.IsCatchingUp); - masterState.Value = anyInSync ? MasterClockState.Synchronised : MasterClockState.TooFarAhead; + MasterClockState newState = playerClocks.Any(s => !s.IsCatchingUp) ? MasterClockState.Synchronised : MasterClockState.TooFarAhead; + + if (masterState == newState) + return; + + masterState = newState; + Logger.Log($"{nameof(SpectatorSyncManager)}'s master clock become {masterState}"); + + switch (masterState) + { + case MasterClockState.Synchronised: + if (hasStarted) + masterClock.Start(); + + break; + + case MasterClockState.TooFarAhead: + masterClock.Stop(); + break; + } } } } diff --git a/osu.Game/Screens/OnlinePlay/OngoingOperationTracker.cs b/osu.Game/Screens/OnlinePlay/OngoingOperationTracker.cs index aabeafe460..7f73d6655f 100644 --- a/osu.Game/Screens/OnlinePlay/OngoingOperationTracker.cs +++ b/osu.Game/Screens/OnlinePlay/OngoingOperationTracker.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -11,7 +13,7 @@ namespace osu.Game.Screens.OnlinePlay /// Utility class to track ongoing online operations' progress. /// Can be used to disable interactivity while waiting for a response from online sources. /// - public class OngoingOperationTracker : Component + public partial class OngoingOperationTracker : Component { /// /// Whether there is an online operation in progress. diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs index 88354c8646..ff536a65c4 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -15,7 +17,7 @@ namespace osu.Game.Screens.OnlinePlay /// /// A that exposes bindables for properties. /// - public class OnlinePlayComposite : CompositeDrawable + public partial class OnlinePlayComposite : CompositeDrawable { [Resolved(typeof(Room))] protected Bindable RoomID { get; private set; } @@ -84,6 +86,9 @@ namespace osu.Game.Screens.OnlinePlay [Resolved(typeof(Room))] protected Bindable AutoStartDuration { get; private set; } + [Resolved(typeof(Room))] + protected Bindable AutoSkip { get; private set; } + [Resolved(CanBeNull = true)] private IBindable subScreenSelectedItem { get; set; } @@ -92,7 +97,7 @@ namespace osu.Game.Screens.OnlinePlay base.LoadComplete(); subScreenSelectedItem?.BindValueChanged(_ => UpdateSelectedItem()); - Playlist.BindCollectionChanged((_, __) => UpdateSelectedItem(), true); + Playlist.BindCollectionChanged((_, _) => UpdateSelectedItem(), true); } protected void UpdateSelectedItem() diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs index ff4225e155..3d80248306 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -19,7 +21,7 @@ using osu.Game.Users; namespace osu.Game.Screens.OnlinePlay { [Cached] - public abstract class OnlinePlayScreen : OsuScreen, IHasSubScreenStack + public abstract partial class OnlinePlayScreen : OsuScreen, IHasSubScreenStack { [Cached] protected readonly OverlayColourProvider ColourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); @@ -103,7 +105,8 @@ namespace osu.Game.Screens.OnlinePlay while (this.IsCurrentScreen()) this.Exit(); } - else + // Also handle the case where a child screen is current (ie. gameplay). + else if (this.GetChildScreen() != null) { this.MakeCurrent(); Schedule(forcefullyExit); @@ -145,9 +148,14 @@ namespace osu.Game.Screens.OnlinePlay public override bool OnExiting(ScreenExitEvent e) { - var subScreen = screenStack.CurrentScreen as Drawable; - if (subScreen?.IsLoaded == true && screenStack.CurrentScreen.OnExiting(e)) - return true; + while (screenStack.CurrentScreen != null && screenStack.CurrentScreen is not LoungeSubScreen) + { + var subScreen = (Screen)screenStack.CurrentScreen; + if (subScreen.IsLoaded && subScreen.OnExiting(e)) + return true; + + subScreen.Exit(); + } RoomManager.PartRoom(); @@ -214,7 +222,7 @@ namespace osu.Game.Screens.OnlinePlay protected abstract LoungeSubScreen CreateLounge(); - private class MultiplayerWaveContainer : WaveContainer + private partial class MultiplayerWaveContainer : WaveContainer { protected override bool StartHidden => true; diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index fb18a33d66..e0ae437d49 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -3,9 +3,9 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using Humanizer; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -24,7 +24,7 @@ using osu.Game.Utils; namespace osu.Game.Screens.OnlinePlay { - public abstract class OnlinePlaySongSelect : SongSelect, IOnlinePlaySubScreen + public abstract partial class OnlinePlaySongSelect : SongSelect, IOnlinePlaySubScreen { public string ShortTitle => "song selection"; @@ -33,32 +33,34 @@ namespace osu.Game.Screens.OnlinePlay public override bool AllowEditing => false; [Resolved(typeof(Room), nameof(Room.Playlist))] - protected BindableList Playlist { get; private set; } - - [CanBeNull] - [Resolved(CanBeNull = true)] - protected IBindable SelectedItem { get; private set; } + protected BindableList Playlist { get; private set; } = null!; [Resolved] - private RulesetStore rulesets { get; set; } + private RulesetStore rulesets { get; set; } = null!; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; protected override UserActivity InitialActivity => new UserActivity.InLobby(room); protected readonly Bindable> FreeMods = new Bindable>(Array.Empty()); private readonly Room room; - - private WorkingBeatmap initialBeatmap; - private RulesetInfo initialRuleset; - private IReadOnlyList initialMods; - private bool itemSelected; - + private readonly PlaylistItem? initialItem; private readonly FreeModSelectOverlay freeModSelectOverlay; - private IDisposable freeModSelectOverlayRegistration; - protected OnlinePlaySongSelect(Room room) + private IDisposable? freeModSelectOverlayRegistration; + + /// + /// Creates a new . + /// + /// The room. + /// An optional initial to use for the initial beatmap/ruleset/mods. + /// If null, the last in the room will be used. + protected OnlinePlaySongSelect(Room room, PlaylistItem? initialItem = null) { this.room = room; + this.initialItem = initialItem ?? room.Playlist.LastOrDefault(); Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING }; @@ -73,11 +75,6 @@ namespace osu.Game.Screens.OnlinePlay private void load() { LeftArea.Padding = new MarginPadding { Top = Header.HEIGHT }; - - initialBeatmap = Beatmap.Value; - initialRuleset = Ruleset.Value; - initialMods = Mods.Value.ToList(); - LoadComponent(freeModSelectOverlay); } @@ -85,14 +82,35 @@ namespace osu.Game.Screens.OnlinePlay { base.LoadComplete(); - var rulesetInstance = SelectedItem?.Value?.RulesetID == null ? null : rulesets.GetRuleset(SelectedItem.Value.RulesetID)?.CreateInstance(); - - if (rulesetInstance != null) + if (initialItem != null) { - // At this point, Mods contains both the required and allowed mods. For selection purposes, it should only contain the required mods. - // Similarly, freeMods is currently empty but should only contain the allowed mods. - Mods.Value = SelectedItem.Value.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); - FreeMods.Value = SelectedItem.Value.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); + // Prefer using a local databased beatmap lookup since OnlineId may be -1 for an invalid beatmap selection. + BeatmapInfo? beatmapInfo = initialItem.Beatmap as BeatmapInfo; + + // And in the case that this isn't a local databased beatmap, query by online ID. + if (beatmapInfo == null) + { + int onlineId = initialItem.Beatmap.OnlineID; + beatmapInfo = beatmapManager.QueryBeatmap(b => b.OnlineID == onlineId); + } + + if (beatmapInfo != null) + Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapInfo); + + RulesetInfo? ruleset = rulesets.GetRuleset(initialItem.RulesetID); + + if (ruleset != null) + { + Ruleset.Value = ruleset; + + var rulesetInstance = ruleset.CreateInstance(); + Debug.Assert(rulesetInstance != null); + + // At this point, Mods contains both the required and allowed mods. For selection purposes, it should only contain the required mods. + // Similarly, freeMods is currently empty but should only contain the allowed mods. + Mods.Value = initialItem.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); + FreeMods.Value = initialItem.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); + } } Mods.BindValueChanged(onModsChanged); @@ -116,8 +134,6 @@ namespace osu.Game.Screens.OnlinePlay protected sealed override bool OnStart() { - itemSelected = true; - var item = new PlaylistItem(Beatmap.Value.BeatmapInfo) { RulesetID = Ruleset.Value.OnlineID, @@ -125,15 +141,15 @@ namespace osu.Game.Screens.OnlinePlay AllowedMods = FreeMods.Value.Select(m => new APIMod(m)).ToArray() }; - SelectItem(item); - return true; + return SelectItem(item); } /// /// Invoked when the user has requested a selection of a beatmap. /// /// The resultant . This item has not yet been added to the 's. - protected abstract void SelectItem(PlaylistItem item); + /// true if a selection occurred. + protected abstract bool SelectItem(PlaylistItem item); public override bool OnBackButton() { @@ -148,15 +164,7 @@ namespace osu.Game.Screens.OnlinePlay public override bool OnExiting(ScreenExitEvent e) { - if (!itemSelected) - { - Beatmap.Value = initialBeatmap; - Ruleset.Value = initialRuleset; - Mods.Value = initialMods; - } - freeModSelectOverlay.Hide(); - return base.OnExiting(e); } @@ -165,7 +173,7 @@ namespace osu.Game.Screens.OnlinePlay IsValidMod = IsValidMod }; - protected override IEnumerable<(FooterButton, OverlayContainer)> CreateFooterButtons() + protected override IEnumerable<(FooterButton, OverlayContainer?)> CreateFooterButtons() { var buttons = base.CreateFooterButtons().ToList(); buttons.Insert(buttons.FindIndex(b => b.Item1 is FooterButtonMods) + 1, (new FooterButtonFreeMods { Current = FreeMods }, freeModSelectOverlay)); @@ -193,7 +201,6 @@ namespace osu.Game.Screens.OnlinePlay protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - freeModSelectOverlayRegistration?.Dispose(); } } diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreen.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreen.cs index 07e0f60011..c7b32131cf 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreen.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Screens; namespace osu.Game.Screens.OnlinePlay { - public abstract class OnlinePlaySubScreen : OsuScreen, IOnlinePlaySubScreen + public abstract partial class OnlinePlaySubScreen : OsuScreen, IOnlinePlaySubScreen { public override bool DisallowExternalBeatmapRulesetChanges => false; diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreenStack.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreenStack.cs index 7f2a0980c1..7ecb7d954e 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreenStack.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySubScreenStack.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Screens; namespace osu.Game.Screens.OnlinePlay { - public class OnlinePlaySubScreenStack : OsuScreenStack + public partial class OnlinePlaySubScreenStack : OsuScreenStack { protected override void ScreenChanged(IScreen prev, IScreen next) { diff --git a/osu.Game/Screens/OnlinePlay/Playlists/CreatePlaylistsRoomButton.cs b/osu.Game/Screens/OnlinePlay/Playlists/CreatePlaylistsRoomButton.cs index a9826a72eb..9507169e0f 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/CreatePlaylistsRoomButton.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/CreatePlaylistsRoomButton.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Game.Screens.OnlinePlay.Match.Components; namespace osu.Game.Screens.OnlinePlay.Playlists { - public class CreatePlaylistsRoomButton : CreateRoomButton + public partial class CreatePlaylistsRoomButton : CreateRoomButton { [BackgroundDependencyLoader] private void load() diff --git a/osu.Game/Screens/OnlinePlay/Playlists/Playlists.cs b/osu.Game/Screens/OnlinePlay/Playlists/Playlists.cs index 1edeef77df..f9324840dc 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/Playlists.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/Playlists.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Screens.OnlinePlay.Lounge; namespace osu.Game.Screens.OnlinePlay.Playlists { - public class Playlists : OnlinePlayScreen + public partial class Playlists : OnlinePlayScreen { protected override string ScreenTitle => "Playlists"; diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsLoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsLoungeSubScreen.cs index b36e162e3d..e1d747c3b0 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsLoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsLoungeSubScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.ComponentModel; using System.Linq; @@ -17,7 +19,7 @@ using osu.Game.Screens.OnlinePlay.Match; namespace osu.Game.Screens.OnlinePlay.Playlists { - public class PlaylistsLoungeSubScreen : LoungeSubScreen + public partial class PlaylistsLoungeSubScreen : LoungeSubScreen { [Resolved] private IAPIProvider api { get; set; } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs index 5cba8676c5..0c25a32259 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using System.Linq; @@ -19,7 +21,7 @@ using osu.Game.Users; namespace osu.Game.Screens.OnlinePlay.Playlists { - public class PlaylistsPlayer : RoomSubmittingPlayer + public partial class PlaylistsPlayer : RoomSubmittingPlayer { public Action Exited; @@ -65,7 +67,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { await base.PrepareScoreForResultsAsync(score).ConfigureAwait(false); - Score.ScoreInfo.TotalScore = (int)Math.Round(ScoreProcessor.ComputeFinalScore(ScoringMode.Standardised, Score.ScoreInfo)); + Score.ScoreInfo.TotalScore = ScoreProcessor.ComputeScore(ScoringMode.Standardised, Score.ScoreInfo); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsReadyButton.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsReadyButton.cs index 24f112ef0c..91a3edbea3 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsReadyButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using osu.Framework.Allocation; @@ -13,7 +15,7 @@ using osu.Game.Screens.OnlinePlay.Components; namespace osu.Game.Screens.OnlinePlay.Playlists { - public class PlaylistsReadyButton : ReadyButton + public partial class PlaylistsReadyButton : ReadyButton { [Resolved(typeof(Room), nameof(Room.EndDate))] private Bindable endDate { get; set; } @@ -36,8 +38,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists private void load(OsuColour colours) { BackgroundColour = colours.Green; - Triangles.ColourDark = colours.Green; - Triangles.ColourLight = colours.GreenLight; } private bool hasRemainingAttempts = true; diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs index 1e6722d51e..d40d43cd54 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; @@ -18,7 +20,7 @@ using osu.Game.Screens.Ranking; namespace osu.Game.Screens.OnlinePlay.Playlists { - public class PlaylistsResultsScreen : ResultsScreen + public partial class PlaylistsResultsScreen : ResultsScreen { private readonly long roomId; private readonly PlaylistItem playlistItem; @@ -178,31 +180,26 @@ namespace osu.Game.Screens.OnlinePlay.Playlists /// The callback to invoke with the final s. /// The s that were retrieved from s. /// An optional pivot around which the scores were retrieved. - private void performSuccessCallback([NotNull] Action> callback, [NotNull] List scores, [CanBeNull] MultiplayerScores pivot = null) + private void performSuccessCallback([NotNull] Action> callback, [NotNull] List scores, [CanBeNull] MultiplayerScores pivot = null) => Schedule(() => { - var scoreInfos = scores.Select(s => s.CreateScoreInfo(rulesets, playlistItem, Beatmap.Value.BeatmapInfo)).ToArray(); + var scoreInfos = scoreManager.OrderByTotalScore(scores.Select(s => s.CreateScoreInfo(scoreManager, rulesets, playlistItem, Beatmap.Value.BeatmapInfo))).ToArray(); - // Score panels calculate total score before displaying, which can take some time. In order to count that calculation as part of the loading spinner display duration, - // calculate the total scores locally before invoking the success callback. - scoreManager.OrderByTotalScoreAsync(scoreInfos).ContinueWith(_ => Schedule(() => + // Select a score if we don't already have one selected. + // Note: This is done before the callback so that the panel list centres on the selected score before panels are added (eliminating initial scroll). + if (SelectedScore.Value == null) { - // Select a score if we don't already have one selected. - // Note: This is done before the callback so that the panel list centres on the selected score before panels are added (eliminating initial scroll). - if (SelectedScore.Value == null) + Schedule(() => { - Schedule(() => - { - // Prefer selecting the local user's score, or otherwise default to the first visible score. - SelectedScore.Value = scoreInfos.FirstOrDefault(s => s.User.OnlineID == api.LocalUser.Value.Id) ?? scoreInfos.FirstOrDefault(); - }); - } + // Prefer selecting the local user's score, or otherwise default to the first visible score. + SelectedScore.Value = scoreInfos.FirstOrDefault(s => s.User.OnlineID == api.LocalUser.Value.Id) ?? scoreInfos.FirstOrDefault(); + }); + } - // Invoke callback to add the scores. Exclude the user's current score which was added previously. - callback.Invoke(scoreInfos.Where(s => s.OnlineID != Score?.OnlineID)); + // Invoke callback to add the scores. Exclude the user's current score which was added previously. + callback.Invoke(scoreInfos.Where(s => s.OnlineID != Score?.OnlineID)); - hideLoadingSpinners(pivot); - })); - } + hideLoadingSpinners(pivot); + }); private void hideLoadingSpinners([CanBeNull] MultiplayerScores pivot = null) { @@ -238,7 +235,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists } } - private class PanelListLoadingSpinner : LoadingSpinner + private partial class PanelListLoadingSpinner : LoadingSpinner { private readonly ScorePanelList list; diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFooter.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFooter.cs index 3eb1cde0a4..5161de5f64 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFooter.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomFooter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -8,7 +10,7 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay.Playlists { - public class PlaylistsRoomFooter : CompositeDrawable + public partial class PlaylistsRoomFooter : CompositeDrawable { public Action OnStart; diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs index 6674a37c3c..e93f56c2e2 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs @@ -16,6 +16,7 @@ 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; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; @@ -25,11 +26,11 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay.Playlists { - public class PlaylistsRoomSettingsOverlay : RoomSettingsOverlay + public partial class PlaylistsRoomSettingsOverlay : RoomSettingsOverlay { - public Action EditPlaylist; + public Action? EditPlaylist; - private MatchSettings settings; + private MatchSettings settings = null!; protected override OsuButton SubmitButton => settings.ApplyButton; @@ -49,32 +50,34 @@ namespace osu.Game.Screens.OnlinePlay.Playlists EditPlaylist = () => EditPlaylist?.Invoke() }; - protected class MatchSettings : OnlinePlayComposite + protected partial class MatchSettings : OnlinePlayComposite { private const float disabled_alpha = 0.2f; - public Action EditPlaylist; + public Action? EditPlaylist; - public OsuTextBox NameField, MaxParticipantsField, MaxAttemptsField; - public OsuDropdown DurationField; - public RoomAvailabilityPicker AvailabilityPicker; - public TriangleButton ApplyButton; + public OsuTextBox NameField = null!, MaxParticipantsField = null!, MaxAttemptsField = null!; + public OsuDropdown DurationField = null!; + public RoomAvailabilityPicker AvailabilityPicker = null!; + public RoundedButton ApplyButton = null!; public bool IsLoading => loadingLayer.State.Value == Visibility.Visible; - public OsuSpriteText ErrorText; + public OsuSpriteText ErrorText = null!; - private LoadingLayer loadingLayer; - private DrawableRoomPlaylist playlist; - private OsuSpriteText playlistLength; + private LoadingLayer loadingLayer = null!; + private DrawableRoomPlaylist playlist = null!; + private OsuSpriteText playlistLength = null!; - private PurpleTriangleButton editPlaylistButton; - - [Resolved(CanBeNull = true)] - private IRoomManager manager { get; set; } + private PurpleRoundedButton editPlaylistButton = null!; [Resolved] - private IAPIProvider api { get; set; } + private IRoomManager? manager { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private IBindable localUser = null!; private readonly Room room; @@ -138,9 +141,14 @@ namespace osu.Game.Screens.OnlinePlay.Playlists }, new Section("Duration") { - Child = DurationField = new DurationDropdown + Child = new Container { RelativeSizeAxes = Axes.X, + Height = 40, + Child = DurationField = new DurationDropdown + { + RelativeSizeAxes = Axes.X + } } }, new Section("Allowed attempts (across all playlist items)") @@ -215,7 +223,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists }, new Drawable[] { - editPlaylistButton = new PurpleTriangleButton + editPlaylistButton = new PurpleRoundedButton { RelativeSizeAxes = Axes.X, Height = 40, @@ -297,7 +305,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists MaxAttempts.BindValueChanged(count => MaxAttemptsField.Text = count.NewValue?.ToString(), true); Duration.BindValueChanged(duration => DurationField.Current.Value = duration.NewValue ?? TimeSpan.FromMinutes(30), true); - api.LocalUser.BindValueChanged(populateDurations, true); + localUser = api.LocalUser.GetBoundCopy(); + localUser.BindValueChanged(populateDurations, true); playlist.Items.BindTo(Playlist); Playlist.BindCollectionChanged(onPlaylistChanged, true); @@ -340,7 +349,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists public void SelectBeatmap() => editPlaylistButton.TriggerClick(); - private void onPlaylistChanged(object sender, NotifyCollectionChangedEventArgs e) => + private void onPlaylistChanged(object? sender, NotifyCollectionChangedEventArgs e) => playlistLength.Text = $"Length: {Playlist.GetTotalDuration()}"; private bool hasValidSettings => RoomID.Value == null && NameField.Text.Length > 0 && Playlist.Count > 0; @@ -406,7 +415,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists } } - public class CreateRoomButton : TriangleButton + public partial class CreateRoomButton : RoundedButton { public CreateRoomButton() { @@ -416,13 +425,11 @@ namespace osu.Game.Screens.OnlinePlay.Playlists [BackgroundDependencyLoader] private void load(OsuColour colours) { - BackgroundColour = colours.Yellow; - Triangles.ColourLight = colours.YellowLight; - Triangles.ColourDark = colours.YellowDark; + BackgroundColour = colours.YellowDark; } } - private class DurationDropdown : OsuDropdown + private partial class DurationDropdown : OsuDropdown { public DurationDropdown() { diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsPlaylist.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsPlaylist.cs index 2fe215eef2..736f09584b 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsPlaylist.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsPlaylist.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Extensions.IEnumerableExtensions; @@ -9,7 +11,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists /// /// A which is displayed during the setup stage of a playlists room. /// - public class PlaylistsRoomSettingsPlaylist : DrawableRoomPlaylist + public partial class PlaylistsRoomSettingsPlaylist : DrawableRoomPlaylist { public PlaylistsRoomSettingsPlaylist() { @@ -22,7 +24,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists Items.Remove(item); - SelectedItem.Value = nextItem ?? Items.LastOrDefault(); + if (AllowSelection && SelectedItem.Value == item) + SelectedItem.Value = nextItem ?? Items.LastOrDefault(); }; } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs index 338a9c856f..cf5a8e1985 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Diagnostics; using System.Linq; using JetBrains.Annotations; @@ -10,6 +12,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Framework.Screens; +using osu.Game.Graphics.Cursor; using osu.Game.Input; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Components; @@ -22,7 +25,7 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay.Playlists { - public class PlaylistsRoomSubScreen : RoomSubScreen + public partial class PlaylistsRoomSubScreen : RoomSubScreen { public override string Title { get; } @@ -66,158 +69,162 @@ namespace osu.Game.Screens.OnlinePlay.Playlists } }, true); - Room.MaxAttempts.BindValueChanged(attempts => progressSection.Alpha = Room.MaxAttempts.Value != null ? 1 : 0, true); + Room.MaxAttempts.BindValueChanged(_ => progressSection.Alpha = Room.MaxAttempts.Value != null ? 1 : 0, true); } protected override Drawable CreateMainContent() => new Container { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Horizontal = 5, Vertical = 10 }, - Child = new GridContainer + Child = new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] + Child = new GridContainer { - new Dimension(), - new Dimension(GridSizeMode.Absolute, 10), - new Dimension(), - new Dimension(GridSizeMode.Absolute, 10), - new Dimension(), - }, - Content = new[] - { - new Drawable[] + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] { - // Playlist items column - new Container + new Dimension(), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(), + }, + Content = new[] + { + new Drawable[] { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Right = 5 }, - Child = new GridContainer + // Playlist items column + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Right = 5 }, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] { new OverlinedPlaylistHeader(), }, + new Drawable[] + { + new DrawableRoomPlaylist + { + RelativeSizeAxes = Axes.Both, + Items = { BindTarget = Room.Playlist }, + SelectedItem = { BindTarget = SelectedItem }, + AllowSelection = true, + AllowShowingResults = true, + RequestResults = item => + { + Debug.Assert(RoomId.Value != null); + ParentScreen?.Push(new PlaylistsResultsScreen(null, RoomId.Value.Value, item, false)); + } + } + }, + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + } + } + }, + // Spacer + null, + // Middle column (mods and leaderboard) + new GridContainer { RelativeSizeAxes = Axes.Both, Content = new[] { - new Drawable[] { new OverlinedPlaylistHeader(), }, + new[] + { + UserModsSection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Alpha = 0, + Margin = new MarginPadding { Bottom = 10 }, + Children = new Drawable[] + { + new OverlinedHeader("Extra mods"), + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + new UserModSelectButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Width = 90, + Text = "Select", + Action = ShowUserModSelect, + }, + new ModDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Current = UserMods, + Scale = new Vector2(0.8f), + }, + } + } + } + }, + }, new Drawable[] { - new DrawableRoomPlaylist + progressSection = new FillFlowContainer { - RelativeSizeAxes = Axes.Both, - Items = { BindTarget = Room.Playlist }, - SelectedItem = { BindTarget = SelectedItem }, - AllowSelection = true, - AllowShowingResults = true, - RequestResults = item => + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Alpha = 0, + Margin = new MarginPadding { Bottom = 10 }, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - Debug.Assert(RoomId.Value != null); - ParentScreen?.Push(new PlaylistsResultsScreen(null, RoomId.Value.Value, item, false)); + new OverlinedHeader("Progress"), + new RoomLocalUserInfo(), } - } + }, }, + new Drawable[] + { + new OverlinedHeader("Leaderboard") + }, + new Drawable[] { leaderboard = new MatchLeaderboard { RelativeSizeAxes = Axes.Both }, }, + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + } + }, + // Spacer + null, + // Main right column + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] { new OverlinedHeader("Chat") }, + new Drawable[] { new MatchChatDisplay(Room) { RelativeSizeAxes = Axes.Both } } }, RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize), new Dimension(), } - } - }, - // Spacer - null, - // Middle column (mods and leaderboard) - new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new[] - { - UserModsSection = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Alpha = 0, - Margin = new MarginPadding { Bottom = 10 }, - Children = new Drawable[] - { - new OverlinedHeader("Extra mods"), - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10, 0), - Children = new Drawable[] - { - new UserModSelectButton - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Width = 90, - Text = "Select", - Action = ShowUserModSelect, - }, - new ModDisplay - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Current = UserMods, - Scale = new Vector2(0.8f), - }, - } - } - } - }, - }, - new Drawable[] - { - progressSection = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Alpha = 0, - Margin = new MarginPadding { Bottom = 10 }, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - new OverlinedHeader("Progress"), - new RoomLocalUserInfo(), - } - }, - }, - new Drawable[] - { - new OverlinedHeader("Leaderboard") - }, - new Drawable[] { leaderboard = new MatchLeaderboard { RelativeSizeAxes = Axes.Both }, }, }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - } - }, - // Spacer - null, - // Main right column - new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] { new OverlinedHeader("Chat") }, - new Drawable[] { new MatchChatDisplay(Room) { RelativeSizeAxes = Axes.Both } } - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - } }, }, - }, + } } }; diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs index 86591c1d6d..cedea4af70 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs @@ -10,7 +10,7 @@ using osu.Game.Screens.Select; namespace osu.Game.Screens.OnlinePlay.Playlists { - public class PlaylistsSongSelect : OnlinePlaySongSelect + public partial class PlaylistsSongSelect : OnlinePlaySongSelect { public PlaylistsSongSelect(Room room) : base(room) @@ -22,7 +22,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists CreateNewItem = createNewItem }; - protected override void SelectItem(PlaylistItem item) + protected override bool SelectItem(PlaylistItem item) { switch (Playlist.Count) { @@ -37,6 +37,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists } this.Exit(); + return true; } private void createNewItem() diff --git a/osu.Game/Screens/OsuScreen.cs b/osu.Game/Screens/OsuScreen.cs index 77db1285bd..bc4cc2b00f 100644 --- a/osu.Game/Screens/OsuScreen.cs +++ b/osu.Game/Screens/OsuScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using JetBrains.Annotations; @@ -19,7 +21,7 @@ using osu.Game.Users; namespace osu.Game.Screens { - public abstract class OsuScreen : Screen, IOsuScreen, IHasDescription + public abstract partial class OsuScreen : Screen, IOsuScreen, IHasDescription { /// /// The amount of negative padding that should be applied to game background content which touches both the left and right sides of the screen. @@ -38,11 +40,10 @@ namespace osu.Game.Screens public virtual bool AllowExternalScreenChange => false; - /// - /// Whether all overlays should be hidden when this screen is entered or resumed. - /// public virtual bool HideOverlaysOnEnter => false; + public virtual bool HideMenuCursorOnNonMouseInput => false; + /// /// The initial overlay activation mode to use when this screen is entered for the first time. /// @@ -77,7 +78,7 @@ namespace osu.Game.Screens private Sample sampleExit; - protected virtual bool PlayResumeSound => true; + protected virtual bool PlayExitSound => true; public virtual float BackgroundParallaxAmount => 1; @@ -173,9 +174,6 @@ namespace osu.Game.Screens public override void OnResuming(ScreenTransitionEvent e) { - if (PlayResumeSound) - sampleExit?.Play(); - applyArrivingDefaults(true); // it's feasible to resume to a screen if the target screen never loaded successfully. @@ -215,6 +213,9 @@ namespace osu.Game.Screens public override bool OnExiting(ScreenExitEvent e) { + if (ValidForResume && PlayExitSound) + sampleExit?.Play(); + if (ValidForResume && logo != null) onExitingLogo(); diff --git a/osu.Game/Screens/OsuScreenDependencies.cs b/osu.Game/Screens/OsuScreenDependencies.cs index 8d54829d49..66176685fd 100644 --- a/osu.Game/Screens/OsuScreenDependencies.cs +++ b/osu.Game/Screens/OsuScreenDependencies.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; diff --git a/osu.Game/Screens/OsuScreenStack.cs b/osu.Game/Screens/OsuScreenStack.cs index 18b16ba865..dffbbdbc55 100644 --- a/osu.Game/Screens/OsuScreenStack.cs +++ b/osu.Game/Screens/OsuScreenStack.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Screens; @@ -8,7 +10,7 @@ using osu.Game.Graphics.Containers; namespace osu.Game.Screens { - public class OsuScreenStack : ScreenStack + public partial class OsuScreenStack : ScreenStack { [Cached] private BackgroundScreenStack backgroundScreenStack; diff --git a/osu.Game/Screens/Play/ArgonKeyCounter.cs b/osu.Game/Screens/Play/ArgonKeyCounter.cs new file mode 100644 index 0000000000..6818b30823 --- /dev/null +++ b/osu.Game/Screens/Play/ArgonKeyCounter.cs @@ -0,0 +1,76 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.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 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; + + public ArgonKeyCounter(InputTrigger trigger) + : base(trigger) + { + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Children = new Drawable[] + { + inputIndicator = new Circle + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + Height = line_height * scale_factor, + Alpha = 0.5f + }, + 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(); + + IsActive.BindValueChanged(e => inputIndicator.Alpha = e.NewValue ? 1 : 0.5f, true); + CountPresses.BindValueChanged(e => countText.Text = e.NewValue.ToString(@"#,0"), true); + } + } +} 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/BeatmapMetadataDisplay.cs b/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs index e8021d4065..a152f4be19 100644 --- a/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs +++ b/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Diagnostics; using osu.Framework.Allocation; @@ -24,7 +26,7 @@ namespace osu.Game.Screens.Play /// /// Displays beatmap metadata inside /// - public class BeatmapMetadataDisplay : Container + public partial class BeatmapMetadataDisplay : Container { private readonly IWorkingBeatmap beatmap; private readonly Bindable> mods; @@ -112,7 +114,7 @@ namespace osu.Game.Screens.Play Anchor = Anchor.Centre, FillMode = FillMode.Fill, }, - loading = new LoadingLayer(true) + loading = new LoadingLayer(dimBackground: true, blockInput: false) } }, versionFlow = new FillFlowContainer @@ -195,24 +197,22 @@ namespace osu.Game.Screens.Play starRatingDisplay.Show(); } else - { starRatingDisplay.Hide(); - starDifficulty.ValueChanged += d => - { - Debug.Assert(d.NewValue != null); + starDifficulty.ValueChanged += d => + { + Debug.Assert(d.NewValue != null); - starRatingDisplay.Current.Value = d.NewValue.Value; + starRatingDisplay.Current.Value = d.NewValue.Value; - versionFlow.AutoSizeDuration = 300; - versionFlow.AutoSizeEasing = Easing.OutQuint; + versionFlow.AutoSizeDuration = 300; + versionFlow.AutoSizeEasing = Easing.OutQuint; - starRatingDisplay.FadeIn(300, Easing.InQuint); - }; - } + starRatingDisplay.FadeIn(300, Easing.InQuint); + }; } - private class MetadataLineLabel : OsuSpriteText + private partial class MetadataLineLabel : OsuSpriteText { public MetadataLineLabel(LocalisableString text) { @@ -224,7 +224,7 @@ namespace osu.Game.Screens.Play } } - private class MetadataLineInfo : OsuSpriteText + private partial class MetadataLineInfo : OsuSpriteText { public MetadataLineInfo(string text) { diff --git a/osu.Game/Screens/Play/Break/BlurredIcon.cs b/osu.Game/Screens/Play/Break/BlurredIcon.cs index 1e974ec916..cd38390324 100644 --- a/osu.Game/Screens/Play/Break/BlurredIcon.cs +++ b/osu.Game/Screens/Play/Break/BlurredIcon.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,7 +12,7 @@ using osuTK; namespace osu.Game.Screens.Play.Break { - public class BlurredIcon : BufferedContainer + public partial class BlurredIcon : BufferedContainer { private readonly SpriteIcon icon; diff --git a/osu.Game/Screens/Play/Break/BreakArrows.cs b/osu.Game/Screens/Play/Break/BreakArrows.cs index 4b96fa666a..f0f1e8cc3d 100644 --- a/osu.Game/Screens/Play/Break/BreakArrows.cs +++ b/osu.Game/Screens/Play/Break/BreakArrows.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -9,7 +11,7 @@ using osuTK; namespace osu.Game.Screens.Play.Break { - public class BreakArrows : CompositeDrawable + public partial class BreakArrows : CompositeDrawable { private const int glow_icon_size = 60; private const int glow_icon_blur_sigma = 10; diff --git a/osu.Game/Screens/Play/Break/BreakInfo.cs b/osu.Game/Screens/Play/Break/BreakInfo.cs index ead41a826a..f99c1d1817 100644 --- a/osu.Game/Screens/Play/Break/BreakInfo.cs +++ b/osu.Game/Screens/Play/Break/BreakInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; @@ -11,7 +13,7 @@ using osuTK; namespace osu.Game.Screens.Play.Break { - public class BreakInfo : Container + public partial class BreakInfo : Container { public PercentageBreakInfoLine AccuracyDisplay; diff --git a/osu.Game/Screens/Play/Break/BreakInfoLine.cs b/osu.Game/Screens/Play/Break/BreakInfoLine.cs index 4cae90e50f..7261155c94 100644 --- a/osu.Game/Screens/Play/Break/BreakInfoLine.cs +++ b/osu.Game/Screens/Play/Break/BreakInfoLine.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -14,7 +16,7 @@ using osu.Game.Utils; namespace osu.Game.Screens.Play.Break { - public class BreakInfoLine : Container + public partial class BreakInfoLine : Container where T : struct { private const int margin = 2; @@ -80,7 +82,7 @@ namespace osu.Game.Screens.Play.Break } } - public class PercentageBreakInfoLine : BreakInfoLine + public partial class PercentageBreakInfoLine : BreakInfoLine { public PercentageBreakInfoLine(LocalisableString name, string prefix = "") : base(name, prefix) diff --git a/osu.Game/Screens/Play/Break/GlowIcon.cs b/osu.Game/Screens/Play/Break/GlowIcon.cs index 2810389619..595c4dd494 100644 --- a/osu.Game/Screens/Play/Break/GlowIcon.cs +++ b/osu.Game/Screens/Play/Break/GlowIcon.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,7 +12,7 @@ using osuTK; namespace osu.Game.Screens.Play.Break { - public class GlowIcon : Container + public partial class GlowIcon : Container { private readonly SpriteIcon spriteIcon; private readonly BlurredIcon blurredIcon; diff --git a/osu.Game/Screens/Play/Break/LetterboxOverlay.cs b/osu.Game/Screens/Play/Break/LetterboxOverlay.cs index 2d7163b5fe..c4e2dbf403 100644 --- a/osu.Game/Screens/Play/Break/LetterboxOverlay.cs +++ b/osu.Game/Screens/Play/Break/LetterboxOverlay.cs @@ -9,7 +9,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Play.Break { - public class LetterboxOverlay : CompositeDrawable + public partial class LetterboxOverlay : CompositeDrawable { private const int height = 350; @@ -20,29 +20,21 @@ namespace osu.Game.Screens.Play.Break RelativeSizeAxes = Axes.Both; InternalChildren = new Drawable[] { - new Container + new Box { Anchor = Anchor.TopLeft, Origin = Anchor.TopLeft, RelativeSizeAxes = Axes.X, Height = height, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(Color4.Black, transparent_black), - } + Colour = ColourInfo.GradientVertical(Color4.Black, transparent_black), }, - new Container + new Box { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, RelativeSizeAxes = Axes.X, Height = height, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(transparent_black, Color4.Black), - } + Colour = ColourInfo.GradientVertical(transparent_black, Color4.Black), } }; } diff --git a/osu.Game/Screens/Play/Break/RemainingTimeCounter.cs b/osu.Game/Screens/Play/Break/RemainingTimeCounter.cs index 2f5e43aebf..da83f8c29f 100644 --- a/osu.Game/Screens/Play/Break/RemainingTimeCounter.cs +++ b/osu.Game/Screens/Play/Break/RemainingTimeCounter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; @@ -9,7 +11,7 @@ using osu.Game.Graphics.Sprites; namespace osu.Game.Screens.Play.Break { - public class RemainingTimeCounter : Counter + public partial class RemainingTimeCounter : Counter { private readonly OsuSpriteText counter; diff --git a/osu.Game/Screens/Play/BreakOverlay.cs b/osu.Game/Screens/Play/BreakOverlay.cs index 1665ee83ae..4927800059 100644 --- a/osu.Game/Screens/Play/BreakOverlay.cs +++ b/osu.Game/Screens/Play/BreakOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -12,7 +14,7 @@ using osu.Game.Screens.Play.Break; namespace osu.Game.Screens.Play { - public class BreakOverlay : Container + public partial class BreakOverlay : Container { /// /// The duration of the break overlay fading. diff --git a/osu.Game/Screens/Play/BreakTracker.cs b/osu.Game/Screens/Play/BreakTracker.cs index 8441b7657e..20ef1dc4bf 100644 --- a/osu.Game/Screens/Play/BreakTracker.cs +++ b/osu.Game/Screens/Play/BreakTracker.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Bindables; @@ -11,7 +13,7 @@ using osu.Game.Utils; namespace osu.Game.Screens.Play { - public class BreakTracker : Component + public partial class BreakTracker : Component { private readonly ScoreProcessor scoreProcessor; private readonly double gameplayStartTime; diff --git a/osu.Game/Screens/Play/ComboEffects.cs b/osu.Game/Screens/Play/ComboEffects.cs index 5041d07e5d..09c94a8f1d 100644 --- a/osu.Game/Screens/Play/ComboEffects.cs +++ b/osu.Game/Screens/Play/ComboEffects.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; @@ -11,7 +13,7 @@ using osu.Game.Skinning; namespace osu.Game.Screens.Play { - public class ComboEffects : CompositeDrawable + public partial class ComboEffects : CompositeDrawable { private readonly ScoreProcessor processor; @@ -43,7 +45,7 @@ namespace osu.Game.Screens.Play private ISamplePlaybackDisabler samplePlaybackDisabler { get; set; } [Resolved] - private GameplayClock gameplayClock { get; set; } + private IGameplayClock gameplayClock { get; set; } private void onComboChange(ValueChangedEvent combo) { diff --git a/osu.Game/Screens/Play/DimmableStoryboard.cs b/osu.Game/Screens/Play/DimmableStoryboard.cs index 5a3ef1e9d3..40cc0f66ad 100644 --- a/osu.Game/Screens/Play/DimmableStoryboard.cs +++ b/osu.Game/Screens/Play/DimmableStoryboard.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -15,7 +17,7 @@ namespace osu.Game.Screens.Play /// /// A container that handles loading, as well as applies user-specified visual settings to it. /// - public class DimmableStoryboard : UserDimContainer + public partial class DimmableStoryboard : UserDimContainer { public Container OverlayLayerContainer { get; private set; } diff --git a/osu.Game/Screens/Play/EpilepsyWarning.cs b/osu.Game/Screens/Play/EpilepsyWarning.cs index ccb2870d78..6316bbdb4e 100644 --- a/osu.Game/Screens/Play/EpilepsyWarning.cs +++ b/osu.Game/Screens/Play/EpilepsyWarning.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -12,7 +14,7 @@ using osuTK; namespace osu.Game.Screens.Play { - public class EpilepsyWarning : VisibilityContainer + public partial class EpilepsyWarning : VisibilityContainer { public const double FADE_DURATION = 250; diff --git a/osu.Game/Screens/Play/FailAnimation.cs b/osu.Game/Screens/Play/FailAnimation.cs index 17a3e5eb71..57bdad079e 100644 --- a/osu.Game/Screens/Play/FailAnimation.cs +++ b/osu.Game/Screens/Play/FailAnimation.cs @@ -1,26 +1,26 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Audio; -using osu.Framework.Bindables; -using osu.Game.Rulesets.UI; using System; using System.Collections.Generic; -using JetBrains.Annotations; 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; using osu.Framework.Graphics.Shapes; using osu.Framework.Utils; +using osu.Game.Audio; using osu.Game.Audio.Effects; 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; @@ -30,29 +30,29 @@ namespace osu.Game.Screens.Play /// Manage the animation to be applied when a player fails. /// Single use and automatically disposed after use. /// - public class FailAnimation : Container + public partial class FailAnimation : Container { - public Action OnComplete; + public Action? OnComplete; private readonly DrawableRuleset drawableRuleset; private readonly BindableDouble trackFreq = new BindableDouble(1); private readonly BindableDouble volumeAdjustment = new BindableDouble(0.5); - private Container filters; + private Container filters = null!; - private Box redFlashLayer; + private Box redFlashLayer = null!; - private Track track; + private Track track = null!; - private AudioFilter failLowPassFilter; - private AudioFilter failHighPassFilter; + private AudioFilter failLowPassFilter = null!; + private AudioFilter failHighPassFilter = null!; private const float duration = 2500; - private Sample failSample; + private SkinnableSound failSample = null!; [Resolved] - private OsuConfigManager config { get; set; } + private OsuConfigManager config { get; set; } = null!; protected override Container Content { get; } = new Container { @@ -64,8 +64,7 @@ namespace osu.Game.Screens.Play /// /// The player screen background, used to adjust appearance on failing. /// - [CanBeNull] - public BackgroundScreen Background { private get; set; } + public BackgroundScreen? Background { private get; set; } public FailAnimation(DrawableRuleset drawableRuleset) { @@ -78,7 +77,7 @@ namespace osu.Game.Screens.Play private void load(AudioManager audio, IBindable beatmap) { track = beatmap.Value.Track; - failSample = audio.Samples.Get(@"Gameplay/failsound"); + AddInternal(failSample = new SkinnableSound(new SampleInfo("Gameplay/failsound"))); AddRangeInternal(new Drawable[] { @@ -103,6 +102,7 @@ namespace osu.Game.Screens.Play } private bool started; + private bool filtersRemoved; /// /// Start the fail animation playing. @@ -111,13 +111,14 @@ namespace osu.Game.Screens.Play public void Start() { if (started) throw new InvalidOperationException("Animation cannot be started more than once."); + if (filtersRemoved) throw new InvalidOperationException("Animation cannot be started after filters have been removed."); started = true; this.TransformBindableTo(trackFreq, 0, duration).OnComplete(_ => { // Don't reset frequency as the pause screen may appear post transform, causing a second frequency sweep. - RemoveFilters(false); + removeFilters(false); OnComplete?.Invoke(); }); @@ -151,18 +152,31 @@ namespace osu.Game.Screens.Play Background?.FadeColour(OsuColour.Gray(0.3f), 60); } - public void RemoveFilters(bool resetTrackFrequency = true) + /// + /// Stops any and all persistent effects added by the ongoing fail animation. + /// + public void Stop() { - if (resetTrackFrequency) - track?.RemoveAdjustment(AdjustableProperty.Frequency, trackFreq); + failSample.Stop(); + removeFilters(); + } - track?.RemoveAdjustment(AdjustableProperty.Volume, volumeAdjustment); + private void removeFilters(bool resetTrackFrequency = true) + { + filtersRemoved = true; + + if (!started) + return; + + if (resetTrackFrequency) + track.RemoveAdjustment(AdjustableProperty.Frequency, trackFreq); + + track.RemoveAdjustment(AdjustableProperty.Volume, volumeAdjustment); if (filters.Parent == null) return; - RemoveInternal(filters); - filters.Dispose(); + RemoveInternal(filters, true); } protected override void Update() @@ -196,7 +210,7 @@ namespace osu.Game.Screens.Play dropOffScreen(obj, failTime, rotation, originalScale, originalPosition); // need to reapply the fail drop after judgement state changes - obj.ApplyCustomUpdateState += (o, _) => dropOffScreen(obj, failTime, rotation, originalScale, originalPosition); + obj.ApplyCustomUpdateState += (_, _) => dropOffScreen(obj, failTime, rotation, originalScale, originalPosition); appliedObjects.Add(obj); } diff --git a/osu.Game/Screens/Play/FailOverlay.cs b/osu.Game/Screens/Play/FailOverlay.cs index b67f45c4f2..4fbc937b59 100644 --- a/osu.Game/Screens/Play/FailOverlay.cs +++ b/osu.Game/Screens/Play/FailOverlay.cs @@ -1,14 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System; +using System.Threading.Tasks; +using osu.Game.Scoring; using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osuTK; using osuTK.Graphics; using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; namespace osu.Game.Screens.Play { - public class FailOverlay : GameplayMenuOverlay + public partial class FailOverlay : GameplayMenuOverlay { + public Func> SaveReplay; + public override string Header => "failed"; public override string Description => "you're dead, try again?"; @@ -17,6 +30,39 @@ namespace osu.Game.Screens.Play { AddButton("Retry", colours.YellowDark, () => OnRetry?.Invoke()); AddButton("Quit", new Color4(170, 27, 39, 255), () => OnQuit?.Invoke()); + // from #10339 maybe this is a better visual effect + Add(new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Height = TwoLayerButton.SIZE_EXTENDED.Y, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#333") + }, + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Spacing = new Vector2(5), + Padding = new MarginPadding(10), + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new SaveFailedScoreButton(SaveReplay) + { + Width = 300 + }, + } + } + } + }); } } } diff --git a/osu.Game/Screens/Play/GameplayClock.cs b/osu.Game/Screens/Play/GameplayClock.cs deleted file mode 100644 index 54aa395f5f..0000000000 --- a/osu.Game/Screens/Play/GameplayClock.cs +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Bindables; -using osu.Framework.Timing; -using osu.Framework.Utils; - -namespace osu.Game.Screens.Play -{ - /// - /// A clock which is used for gameplay elements that need to follow audio time 1:1. - /// Exposed via DI by . - /// - /// The main purpose of this clock is to stop components using it from accidentally processing the main - /// , as this should only be done once to ensure accuracy. - /// - /// - public class GameplayClock : IFrameBasedClock - { - internal readonly IFrameBasedClock UnderlyingClock; - - public readonly BindableBool IsPaused = new BindableBool(); - - /// - /// All adjustments applied to this clock which don't come from gameplay or mods. - /// - public virtual IEnumerable> NonGameplayAdjustments => Enumerable.Empty>(); - - public GameplayClock(IFrameBasedClock underlyingClock) - { - UnderlyingClock = underlyingClock; - } - - public double CurrentTime => UnderlyingClock.CurrentTime; - - public double Rate => UnderlyingClock.Rate; - - /// - /// The rate of gameplay when playback is at 100%. - /// This excludes any seeking / user adjustments. - /// - public double TrueGameplayRate - { - get - { - double baseRate = Rate; - - foreach (var adjustment in NonGameplayAdjustments) - { - if (Precision.AlmostEquals(adjustment.Value, 0)) - return 0; - - baseRate /= adjustment.Value; - } - - return baseRate; - } - } - - public bool IsRunning => UnderlyingClock.IsRunning; - - public void ProcessFrame() - { - // intentionally not updating the underlying clock (handled externally). - } - - public double ElapsedFrameTime => UnderlyingClock.ElapsedFrameTime; - - public double FramesPerSecond => UnderlyingClock.FramesPerSecond; - - public FrameTimeInfo TimeInfo => UnderlyingClock.TimeInfo; - - public IClock Source => UnderlyingClock; - } -} diff --git a/osu.Game/Screens/Play/GameplayClockContainer.cs b/osu.Game/Screens/Play/GameplayClockContainer.cs index 721abc66f8..c42f607908 100644 --- a/osu.Game/Screens/Play/GameplayClockContainer.cs +++ b/osu.Game/Screens/Play/GameplayClockContainer.cs @@ -3,94 +3,116 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Logging; using osu.Framework.Timing; +using osu.Game.Beatmaps; namespace osu.Game.Screens.Play { /// - /// Encapsulates gameplay timing logic and provides a via DI for gameplay components to use. + /// Encapsulates gameplay timing logic and provides a via DI for gameplay components to use. /// - [Cached] - public abstract class GameplayClockContainer : Container, IAdjustableClock + [Cached(typeof(IGameplayClock))] + public partial class GameplayClockContainer : Container, IAdjustableClock, IGameplayClock { - /// - /// The final clock which is exposed to gameplay components. - /// - public GameplayClock GameplayClock { get; private set; } - /// /// Whether gameplay is paused. /// - public readonly BindableBool IsPaused = new BindableBool(true); + public IBindable IsPaused => isPaused; /// - /// The adjustable source clock used for gameplay. Should be used for seeks and clock control. + /// The source clock. Should generally not be used for any timekeeping purposes. /// - protected readonly DecoupleableInterpolatingFramedClock AdjustableSource; - - /// - /// The source clock. - /// - protected IClock SourceClock { get; private set; } + public IClock SourceClock { get; private set; } /// /// Invoked when a seek has been performed via /// - public event Action OnSeek; + public event Action? OnSeek; /// /// The time from which the clock should start. Will be seeked to on calling . + /// Can be adjusted by calling with a time value. /// /// - /// If not set, a value of zero will be used. - /// Importantly, the value will be inferred from the current ruleset in unless specified. + /// By default, a value of zero will be used. + /// Importantly, the value will be inferred from the current beatmap in by default. /// - public double? StartTime { get; set; } + public double StartTime { get; protected set; } + + public IAdjustableAudioComponent AdjustmentsFromMods { get; } = new AudioAdjustments(); + + private readonly BindableBool isPaused = new BindableBool(true); + + /// + /// The adjustable source clock used for gameplay. Should be used for seeks and clock control. + /// This is the final source exposed to gameplay components via delegation in this class. + /// + protected readonly FramedBeatmapClock GameplayClock; + + protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; /// /// Creates a new . /// /// The source used for timing. - protected GameplayClockContainer(IClock sourceClock) + /// Whether to apply platform, user and beatmap offsets to the mix. + public GameplayClockContainer(IClock sourceClock, bool applyOffsets = false) { SourceClock = sourceClock; RelativeSizeAxes = Axes.Both; - AdjustableSource = new DecoupleableInterpolatingFramedClock { IsCoupled = false }; - IsPaused.BindValueChanged(OnIsPausedChanged); - } - - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) - { - var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - - dependencies.CacheAs(GameplayClock = CreateGameplayClock(AdjustableSource)); - GameplayClock.IsPaused.BindTo(IsPaused); - - return dependencies; + InternalChildren = new Drawable[] + { + GameplayClock = new FramedBeatmapClock(applyOffsets) { IsCoupled = false }, + Content + }; } /// - /// Starts gameplay. + /// Starts gameplay and marks un-paused state. /// - public virtual void Start() + public void Start() { + if (!isPaused.Value) + return; + + isPaused.Value = false; + ensureSourceClockSet(); - if (!AdjustableSource.IsRunning) + PrepareStart(); + + // The case which caused this to be added is FrameStabilityContainer, which manages its own current and elapsed time. + // Because we generally update our own current time quicker than children can query it (via Start/Seek/Update), + // this means that the first frame ever exposed to children may have a non-zero current time. + // + // If the child component is not aware of the parent ElapsedFrameTime (which is the case for FrameStabilityContainer) + // they will take on the new CurrentTime with a zero elapsed time. This can in turn cause components to behave incorrectly + // if they are intending to trigger events at the precise StartTime (ie. DrawableStoryboardSample). + // + // By scheduling the start call, children are guaranteed to receive one frame at the original start time, allowing + // then to progress with a correct locally calculated elapsed time. + SchedulerAfterChildren.Add(() => { - // Seeking the decoupled clock to its current time ensures that its source clock will be seeked to the same time - // This accounts for the clock source potentially taking time to enter a completely stopped state - Seek(GameplayClock.CurrentTime); + if (isPaused.Value) + return; - AdjustableSource.Start(); - } + StartGameplayClock(); + }); + } - IsPaused.Value = false; + /// + /// When is called, this will be run to give an opportunity to prepare the clock at the correct + /// start location. + /// + protected virtual void PrepareStart() + { } /// @@ -99,43 +121,58 @@ namespace osu.Game.Screens.Play /// The destination time to seek to. public virtual void Seek(double time) { - AdjustableSource.Seek(time); + Logger.Log($"{nameof(GameplayClockContainer)} seeking to {time}"); - // Manually process to make sure the gameplay clock is correctly updated after a seek. - GameplayClock.UnderlyingClock.ProcessFrame(); + GameplayClock.Seek(time); OnSeek?.Invoke(); } /// - /// Stops gameplay. + /// Stops gameplay and marks paused state. /// - public void Stop() => IsPaused.Value = true; + public void Stop() + { + if (isPaused.Value) + return; + + isPaused.Value = true; + StopGameplayClock(); + } + + protected virtual void StartGameplayClock() => GameplayClock.Start(); + protected virtual void StopGameplayClock() => GameplayClock.Stop(); /// /// Resets this and the source to an initial state ready for gameplay. /// + /// The time to seek to on resetting. If null, the existing will be used. /// Whether to start the clock immediately, if not already started. - public void Reset(bool startClock = false) + public void Reset(double? time = null, bool startClock = false) { - // Manually stop the source in order to not affect the IsPaused state. - AdjustableSource.Stop(); + bool wasPaused = isPaused.Value; - if (!IsPaused.Value || startClock) - Start(); + Stop(); ensureSourceClockSet(); - Seek(StartTime ?? 0); + + if (time != null) + StartTime = time.Value; + + Seek(StartTime); + + if (!wasPaused || startClock) + Start(); } /// /// Changes the source clock. /// /// The new source. - protected void ChangeSource(IClock sourceClock) => AdjustableSource.ChangeSource(SourceClock = sourceClock); + protected void ChangeSource(IClock sourceClock) => GameplayClock.ChangeSource(SourceClock = sourceClock); /// - /// Ensures that the is set to , if it hasn't been given a source yet. + /// Ensures that the is set to , if it hasn't been given a source yet. /// This is usually done before a seek to avoid accidentally seeking only the adjustable source in decoupled mode, /// but not the actual source clock. /// That will pretty much only happen on the very first call of this method, as the source clock is passed in the constructor, @@ -143,40 +180,10 @@ namespace osu.Game.Screens.Play /// private void ensureSourceClockSet() { - if (AdjustableSource.Source == null) + if (GameplayClock.Source == null) ChangeSource(SourceClock); } - protected override void Update() - { - if (!IsPaused.Value) - GameplayClock.UnderlyingClock.ProcessFrame(); - - base.Update(); - } - - /// - /// Invoked when the value of is changed to start or stop the clock. - /// - /// Whether the clock should now be paused. - protected virtual void OnIsPausedChanged(ValueChangedEvent isPaused) - { - if (isPaused.NewValue) - AdjustableSource.Stop(); - else - AdjustableSource.Start(); - } - - /// - /// Creates the final which is exposed via DI to be used by gameplay components. - /// - /// - /// Any intermediate clocks such as platform offsets should be applied here. - /// - /// The providing the source time. - /// The final . - protected abstract GameplayClock CreateGameplayClock(IFrameBasedClock source); - #region IAdjustableClock bool IAdjustableClock.Seek(double position) @@ -187,7 +194,7 @@ namespace osu.Game.Screens.Play void IAdjustableClock.Reset() => Reset(); - public void ResetSpeedAdjustments() + public virtual void ResetSpeedAdjustments() { } @@ -197,12 +204,23 @@ namespace osu.Game.Screens.Play set => throw new NotSupportedException(); } - double IClock.Rate => GameplayClock.Rate; + public double Rate => GameplayClock.Rate; public double CurrentTime => GameplayClock.CurrentTime; public bool IsRunning => GameplayClock.IsRunning; #endregion + + public void ProcessFrame() + { + // Handled via update. Don't process here to safeguard from external usages potentially processing frames additional times. + } + + public double ElapsedFrameTime => GameplayClock.ElapsedFrameTime; + + public double FramesPerSecond => GameplayClock.FramesPerSecond; + + public FrameTimeInfo TimeInfo => GameplayClock.TimeInfo; } } diff --git a/osu.Game/Screens/Play/GameplayClockExtensions.cs b/osu.Game/Screens/Play/GameplayClockExtensions.cs new file mode 100644 index 0000000000..5e88b41080 --- /dev/null +++ b/osu.Game/Screens/Play/GameplayClockExtensions.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +namespace osu.Game.Screens.Play +{ + public static class GameplayClockExtensions + { + /// + /// The rate of gameplay when playback is at 100%. + /// This excludes any seeking / user adjustments. + /// + public static double GetTrueGameplayRate(this IGameplayClock clock) + { + // To handle rewind, we still want to maintain the same direction as the underlying clock. + double rate = clock.Rate == 0 ? 1 : Math.Sign(clock.Rate); + + return rate + * clock.AdjustmentsFromMods.AggregateFrequency.Value + * clock.AdjustmentsFromMods.AggregateTempo.Value; + } + } +} diff --git a/osu.Game/Screens/Play/GameplayMenuOverlay.cs b/osu.Game/Screens/Play/GameplayMenuOverlay.cs index 9e3400b09d..81146a4ea6 100644 --- a/osu.Game/Screens/Play/GameplayMenuOverlay.cs +++ b/osu.Game/Screens/Play/GameplayMenuOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -23,7 +25,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Play { - public abstract class GameplayMenuOverlay : OverlayContainer, IKeyBindingHandler + public abstract partial class GameplayMenuOverlay : OverlayContainer, IKeyBindingHandler { protected const int TRANSITION_DURATION = 200; @@ -42,7 +44,7 @@ namespace osu.Game.Screens.Play /// /// Action that is invoked when is triggered. /// - protected virtual Action BackAction => () => InternalButtons.Children.LastOrDefault()?.TriggerClick(); + protected virtual Action BackAction => () => InternalButtons.LastOrDefault()?.TriggerClick(); /// /// Action that is invoked when is triggered. @@ -140,7 +142,7 @@ namespace osu.Game.Screens.Play }, }; - State.ValueChanged += s => InternalButtons.Deselect(); + State.ValueChanged += _ => InternalButtons.Deselect(); updateRetryCount(); } @@ -187,7 +189,7 @@ namespace osu.Game.Screens.Play InternalButtons.Add(button); } - public bool OnPressed(KeyBindingPressEvent e) + public virtual bool OnPressed(KeyBindingPressEvent e) { switch (e.Action) { @@ -246,7 +248,7 @@ namespace osu.Game.Screens.Play }; } - private class Button : DialogButton + private partial class Button : DialogButton { // required to ensure keyboard navigation always starts from an extremity (unless the cursor is moved) protected override bool OnHover(HoverEvent e) => true; @@ -265,7 +267,7 @@ namespace osu.Game.Screens.Play { switch (e) { - case ScrollEvent _: + case ScrollEvent: if (ReceivePositionalInputAt(e.ScreenSpaceMousePosition)) return globalAction.TriggerEvent(e); diff --git a/osu.Game/Screens/Play/GameplayState.cs b/osu.Game/Screens/Play/GameplayState.cs index 586cdcda51..c2162d4df2 100644 --- a/osu.Game/Screens/Play/GameplayState.cs +++ b/osu.Game/Screens/Play/GameplayState.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 enable - using System; using System.Collections.Generic; using osu.Framework.Bindables; @@ -72,6 +70,7 @@ namespace osu.Game.Screens.Play { ScoreInfo = { + BeatmapInfo = beatmap.BeatmapInfo, Ruleset = ruleset.RulesetInfo } }; diff --git a/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs b/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs new file mode 100644 index 0000000000..9dce8996c3 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs @@ -0,0 +1,118 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Localisation.HUD; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Screens.Play.HUD +{ + public partial class ArgonSongProgress : SongProgress + { + private readonly SongProgressInfo info; + private readonly ArgonSongProgressGraph graph; + private readonly ArgonSongProgressBar bar; + private readonly Container graphContainer; + + private const float bar_height = 10; + + [SettingSource(typeof(SongProgressStrings), nameof(SongProgressStrings.ShowGraph), nameof(SongProgressStrings.ShowGraphDescription))] + public Bindable ShowGraph { get; } = new BindableBool(true); + + [Resolved] + private Player? player { get; set; } + + public ArgonSongProgress() + { + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + Masking = true; + CornerRadius = 5; + Children = new Drawable[] + { + info = new SongProgressInfo + { + Origin = Anchor.TopLeft, + Name = "Info", + Anchor = Anchor.TopLeft, + RelativeSizeAxes = Axes.X, + ShowProgress = false + }, + bar = new ArgonSongProgressBar(bar_height) + { + Name = "Seek bar", + Origin = Anchor.BottomLeft, + Anchor = Anchor.BottomLeft, + OnSeek = time => player?.Seek(time), + }, + graphContainer = new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Masking = true, + CornerRadius = 5, + Child = graph = new ArgonSongProgressGraph + { + Name = "Difficulty graph", + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive + }, + RelativeSizeAxes = Axes.X, + }, + }; + RelativeSizeAxes = Axes.X; + } + + [BackgroundDependencyLoader] + private void load() + { + info.TextColour = Colour4.White; + info.Font = OsuFont.Torus.With(size: 18, weight: FontWeight.Bold); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Interactive.BindValueChanged(_ => bar.Interactive = Interactive.Value, true); + ShowGraph.BindValueChanged(_ => updateGraphVisibility(), true); + } + + protected override void UpdateObjects(IEnumerable objects) + { + graph.Objects = objects; + + info.StartTime = bar.StartTime = FirstHitTime; + info.EndTime = bar.EndTime = LastHitTime; + } + + private void updateGraphVisibility() + { + graph.FadeTo(ShowGraph.Value ? 1 : 0, 200, Easing.In); + bar.ShowBackground = !ShowGraph.Value; + } + + protected override void Update() + { + base.Update(); + Height = bar.Height + bar_height + info.Height; + graphContainer.Height = bar.Height; + } + + protected override void UpdateProgress(double progress, bool isIntro) + { + bar.TrackTime = GameplayClock.CurrentTime; + + if (isIntro) + bar.CurrentTime = 0; + else + bar.CurrentTime = FrameStableClock.CurrentTime; + } + } +} diff --git a/osu.Game/Screens/Play/HUD/ArgonSongProgressBar.cs b/osu.Game/Screens/Play/HUD/ArgonSongProgressBar.cs new file mode 100644 index 0000000000..dd6e10ba5d --- /dev/null +++ b/osu.Game/Screens/Play/HUD/ArgonSongProgressBar.cs @@ -0,0 +1,265 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Threading; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Play.HUD +{ + public partial class ArgonSongProgressBar : SliderBar + { + public Action? OnSeek { get; set; } + + // Parent will handle restricting the area of valid input. + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + + private readonly float barHeight; + + private readonly RoundedBar playfieldBar; + private readonly RoundedBar catchupBar; + + private readonly Box background; + + private readonly BindableBool showBackground = new BindableBool(); + + private readonly ColourInfo mainColour; + private readonly ColourInfo mainColourDarkened; + private ColourInfo catchUpColour; + private ColourInfo catchUpColourDarkened; + + public bool ShowBackground + { + get => showBackground.Value; + set => showBackground.Value = value; + } + + public double StartTime + { + private get => CurrentNumber.MinValue; + set => CurrentNumber.MinValue = value; + } + + public double EndTime + { + private get => CurrentNumber.MaxValue; + set => CurrentNumber.MaxValue = value; + } + + public double CurrentTime + { + private get => CurrentNumber.Value; + set => CurrentNumber.Value = value; + } + + public double TrackTime + { + private get => currentTrackTime.Value; + set => currentTrackTime.Value = value; + } + + private double length => EndTime - StartTime; + + private readonly BindableNumber currentTrackTime; + + public bool Interactive { get; set; } + + public ArgonSongProgressBar(float barHeight) + { + currentTrackTime = new BindableDouble(); + setupAlternateValue(); + + StartTime = 0; + EndTime = 1; + + RelativeSizeAxes = Axes.X; + Height = this.barHeight = barHeight; + + CornerRadius = 5; + Masking = true; + + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Colour = Colour4.White.Darken(1 + 1 / 4f) + }, + catchupBar = new RoundedBar + { + Name = "Audio bar", + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + CornerRadius = 5, + AlwaysPresent = true, + RelativeSizeAxes = Axes.Both + }, + playfieldBar = new RoundedBar + { + Name = "Playfield bar", + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + CornerRadius = 5, + AccentColour = mainColour = Color4.White, + RelativeSizeAxes = Axes.Both + }, + }; + + mainColourDarkened = Colour4.White.Darken(1 / 3f); + } + + private void setupAlternateValue() + { + CurrentNumber.MaxValueChanged += v => currentTrackTime.MaxValue = v; + CurrentNumber.MinValueChanged += v => currentTrackTime.MinValue = v; + CurrentNumber.PrecisionChanged += v => currentTrackTime.Precision = v; + } + + private float normalizedReference + { + get + { + if (EndTime - StartTime == 0) + return 1; + + return (float)((TrackTime - StartTime) / length); + } + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + catchUpColour = colours.BlueLight; + catchUpColourDarkened = colours.BlueDark; + + showBackground.BindValueChanged(_ => updateBackground(), true); + } + + private void updateBackground() + { + background.FadeTo(showBackground.Value ? 1 / 4f : 0, 200, Easing.In); + playfieldBar.TransformTo(nameof(playfieldBar.AccentColour), ShowBackground ? mainColour : mainColourDarkened, 200, Easing.In); + } + + protected override bool OnHover(HoverEvent e) + { + if (Interactive) + this.ResizeHeightTo(barHeight * 3.5f, 200, Easing.Out); + + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + this.ResizeHeightTo(barHeight, 800, Easing.OutQuint); + base.OnHoverLost(e); + } + + protected override void UpdateValue(float value) + { + // Handled in Update + } + + protected override void Update() + { + base.Update(); + + playfieldBar.Length = (float)Interpolation.Lerp(playfieldBar.Length, NormalizedValue, Math.Clamp(Time.Elapsed / 40, 0, 1)); + catchupBar.Length = (float)Interpolation.Lerp(catchupBar.Length, normalizedReference, Math.Clamp(Time.Elapsed / 40, 0, 1)); + + if (TrackTime < CurrentTime) + ChangeChildDepth(catchupBar, -1); + else + ChangeChildDepth(catchupBar, 0); + + float timeDelta = (float)(Math.Abs(CurrentTime - TrackTime)); + + const float colour_transition_threshold = 20000; + + catchupBar.AccentColour = Interpolation.ValueAt( + Math.Min(timeDelta, colour_transition_threshold), + ShowBackground ? mainColour : mainColourDarkened, + ShowBackground ? catchUpColour : catchUpColourDarkened, + 0, colour_transition_threshold, + Easing.OutQuint); + + catchupBar.Alpha = Math.Max(1, catchupBar.Length); + } + + private ScheduledDelegate? scheduledSeek; + + protected override void OnUserChange(double value) + { + scheduledSeek?.Cancel(); + scheduledSeek = Schedule(() => + { + if (Interactive) + OnSeek?.Invoke(value); + }); + } + + private partial class RoundedBar : Container + { + private readonly Box fill; + private readonly Container mask; + private float length; + + public RoundedBar() + { + Masking = true; + Children = new[] + { + mask = new Container + { + Masking = true, + RelativeSizeAxes = Axes.Y, + Size = new Vector2(1), + Child = fill = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.White + } + } + }; + } + + public float Length + { + get => length; + set + { + length = value; + mask.Width = value * DrawWidth; + } + } + + public new float CornerRadius + { + get => base.CornerRadius; + set + { + base.CornerRadius = value; + mask.CornerRadius = value; + } + } + + public ColourInfo AccentColour + { + get => fill.Colour; + set => fill.Colour = value; + } + } + } +} diff --git a/osu.Game/Screens/Play/HUD/ArgonSongProgressGraph.cs b/osu.Game/Screens/Play/HUD/ArgonSongProgressGraph.cs new file mode 100644 index 0000000000..0899476ed4 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/ArgonSongProgressGraph.cs @@ -0,0 +1,64 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Objects; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Screens.Play.HUD +{ + public partial class ArgonSongProgressGraph : SegmentedGraph + { + private IEnumerable? objects; + + public IEnumerable Objects + { + set + { + objects = value; + + const int granularity = 200; + int[] values = new int[granularity]; + + if (!objects.Any()) + return; + + double firstHit = objects.First().StartTime; + double lastHit = objects.Max(o => o.GetEndTime()); + + if (lastHit == 0) + lastHit = objects.Last().StartTime; + + double interval = (lastHit - firstHit + 1) / granularity; + + foreach (var h in objects) + { + double endTime = h.GetEndTime(); + + Debug.Assert(endTime >= h.StartTime); + + int startRange = (int)((h.StartTime - firstHit) / interval); + int endRange = (int)((endTime - firstHit) / interval); + for (int i = startRange; i <= endRange; i++) + values[i]++; + } + + Values = values; + } + } + + public ArgonSongProgressGraph() + : base(5) + { + var colours = new List(); + + for (int i = 0; i < 5; i++) + colours.Add(Colour4.White.Darken(1 + 1 / 5f).Opacity(1 / 5f)); + + TierColours = colours; + } + } +} diff --git a/osu.Game/Screens/Play/HUD/BPMCounter.cs b/osu.Game/Screens/Play/HUD/BPMCounter.cs new file mode 100644 index 0000000000..cd24237493 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/BPMCounter.cs @@ -0,0 +1,95 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + public partial class BPMCounter : RollingCounter, ISerialisableDrawable + { + protected override double RollingDuration => 750; + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + [Resolved] + private IGameplayClock gameplayClock { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load(OsuColour colour) + { + Colour = colour.BlueLighter; + Current.Value = DisplayedCount = 0; + } + + protected override void Update() + { + base.Update(); + + // We want to check Rate every update to cover windup/down + Current.Value = beatmap.Value.Beatmap.ControlPointInfo.TimingPointAt(gameplayClock.CurrentTime).BPM * gameplayClock.GetTrueGameplayRate(); + } + + protected override OsuSpriteText CreateSpriteText() + => base.CreateSpriteText().With(s => s.Font = s.Font.With(size: 20f, fixedWidth: true)); + + protected override LocalisableString FormatCount(double count) + { + return $@"{count:0}"; + } + + protected override IHasText CreateText() => new TextComponent(); + + private partial class TextComponent : CompositeDrawable, IHasText + { + public LocalisableString Text + { + get => text.Text; + set => text.Text = value; + } + + private readonly OsuSpriteText text; + + public TextComponent() + { + AutoSizeAxes = Axes.Both; + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(2), + Children = new Drawable[] + { + text = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.Numeric.With(size: 16, fixedWidth: true) + }, + new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.Numeric.With(size: 8), + Text = @"BPM", + Padding = new MarginPadding { Bottom = 2f }, // align baseline better + } + } + }; + } + } + + public bool UsesFixedAnchor { get; set; } + } +} diff --git a/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCalculator.cs b/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCalculator.cs new file mode 100644 index 0000000000..ba0c47dc8b --- /dev/null +++ b/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCalculator.cs @@ -0,0 +1,57 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Screens.Play.HUD.ClicksPerSecond +{ + public partial class ClicksPerSecondCalculator : Component + { + private readonly List timestamps = new List(); + + [Resolved] + private IGameplayClock gameplayClock { get; set; } = null!; + + [Resolved] + private IFrameStableClock? frameStableClock { get; set; } + + public int Value { get; private set; } + + private IGameplayClock clock => frameStableClock ?? gameplayClock; + + public ClicksPerSecondCalculator() + { + RelativeSizeAxes = Axes.Both; + } + + public void AddInputTimestamp() => timestamps.Add(clock.CurrentTime); + + protected override void Update() + { + base.Update(); + + double latestValidTime = clock.CurrentTime; + double earliestTimeValid = latestValidTime - 1000 * gameplayClock.GetTrueGameplayRate(); + + int count = 0; + + for (int i = timestamps.Count - 1; i >= 0; i--) + { + // handle rewinding by removing future timestamps as we go + if (timestamps[i] > latestValidTime) + { + timestamps.RemoveAt(i); + continue; + } + + if (timestamps[i] >= earliestTimeValid) + count++; + } + + Value = count; + } + } +} diff --git a/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCounter.cs b/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCounter.cs new file mode 100644 index 0000000000..1aa7c5e091 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCounter.cs @@ -0,0 +1,102 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Screens.Play.HUD.ClicksPerSecond +{ + public partial class ClicksPerSecondCounter : RollingCounter, ISerialisableDrawable + { + [Resolved] + private ClicksPerSecondCalculator calculator { get; set; } = null!; + + protected override double RollingDuration => 350; + + public bool UsesFixedAnchor { get; set; } + + public ClicksPerSecondCounter() + { + Current.Value = 0; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Colour = colours.BlueLighter; + } + + protected override void Update() + { + base.Update(); + + Current.Value = calculator.Value; + } + + protected override IHasText CreateText() => new TextComponent(); + + private partial class TextComponent : CompositeDrawable, IHasText + { + public LocalisableString Text + { + get => text.Text; + set => text.Text = value; + } + + private readonly OsuSpriteText text; + + public TextComponent() + { + AutoSizeAxes = Axes.Both; + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(2), + Children = new Drawable[] + { + text = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.Numeric.With(size: 16, fixedWidth: true) + }, + new FillFlowContainer + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Font = OsuFont.Numeric.With(size: 6, fixedWidth: false), + Text = @"clicks", + }, + new OsuSpriteText + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Font = OsuFont.Numeric.With(size: 6, fixedWidth: false), + Text = @"/sec", + Padding = new MarginPadding { Bottom = 3f }, // align baseline better + } + } + } + } + }; + } + } + } +} diff --git a/osu.Game/Screens/Play/HUD/ComboCounter.cs b/osu.Game/Screens/Play/HUD/ComboCounter.cs new file mode 100644 index 0000000000..17531281aa --- /dev/null +++ b/osu.Game/Screens/Play/HUD/ComboCounter.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Graphics.UserInterface; +using osu.Game.Skinning; + +namespace osu.Game.Screens.Play.HUD +{ + public abstract partial class ComboCounter : RollingCounter, ISerialisableDrawable + { + public bool UsesFixedAnchor { get; set; } + + protected ComboCounter() + { + Current.Value = DisplayedCount = 0; + } + + protected override double GetProportionalDuration(int currentValue, int newValue) + { + return Math.Abs(currentValue - newValue) * RollingDuration * 100.0f; + } + } +} diff --git a/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs index 06b53e8426..eb3c71afbb 100644 --- a/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Game.Graphics; using osu.Game.Skinning; namespace osu.Game.Screens.Play.HUD { - public class DefaultAccuracyCounter : GameplayAccuracyCounter, ISkinnableDrawable + public partial class DefaultAccuracyCounter : GameplayAccuracyCounter, ISerialisableDrawable { public bool UsesFixedAnchor { get; set; } diff --git a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs index 52f86d2bc3..1377055c30 100644 --- a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs @@ -1,27 +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.Graphics; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Scoring; -using osu.Game.Skinning; namespace osu.Game.Screens.Play.HUD { - public class DefaultComboCounter : RollingCounter, ISkinnableDrawable + public partial class DefaultComboCounter : ComboCounter { - public bool UsesFixedAnchor { get; set; } - - public DefaultComboCounter() - { - Current.Value = DisplayedCount = 0; - } - [BackgroundDependencyLoader] private void load(OsuColour colours, ScoreProcessor scoreProcessor) { @@ -29,17 +19,12 @@ namespace osu.Game.Screens.Play.HUD Current.BindTo(scoreProcessor.Combo); } + protected override OsuSpriteText CreateSpriteText() + => base.CreateSpriteText().With(s => s.Font = s.Font.With(size: 20f)); + protected override LocalisableString FormatCount(int count) { return $@"{count}x"; } - - protected override double GetProportionalDuration(int currentValue, int newValue) - { - return Math.Abs(currentValue - newValue) * RollingDuration * 100.0f; - } - - protected override OsuSpriteText CreateSpriteText() - => base.CreateSpriteText().With(s => s.Font = s.Font.With(size: 20f)); } } diff --git a/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs b/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs index 4f93868a66..2c43905a46 100644 --- a/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -17,7 +19,7 @@ using osu.Game.Skinning; namespace osu.Game.Screens.Play.HUD { - public class DefaultHealthDisplay : HealthDisplay, IHasAccentColour, ISkinnableDrawable + public partial class DefaultHealthDisplay : HealthDisplay, IHasAccentColour, ISerialisableDrawable { /// /// The base opacity of the glow. @@ -76,30 +78,39 @@ namespace osu.Game.Screens.Play.HUD public DefaultHealthDisplay() { - Size = new Vector2(1, 5); - RelativeSizeAxes = Axes.X; - Margin = new MarginPadding { Top = 20 }; + const float padding = 20; + const float bar_height = 5; - InternalChildren = new Drawable[] + Size = new Vector2(1, bar_height + padding * 2); + RelativeSizeAxes = Axes.X; + + InternalChild = new Container { - new Box + Padding = new MarginPadding { Vertical = padding }, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - }, - fill = new Container - { - RelativeSizeAxes = Axes.Both, - Size = new Vector2(0, 1), - Masking = true, - Children = new[] + new Box { - new Box + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + }, + fill = new Container + { + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0, 1), + Masking = true, + Children = new[] { - RelativeSizeAxes = Axes.Both, + new Box + { + RelativeSizeAxes = Axes.Both, + } } - } - }, + }, + } }; } 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 98df73a5e6..f7ac72035f 100644 --- a/osu.Game/Screens/Play/KeyCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultKeyCounter.cs @@ -1,4 +1,4 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; @@ -11,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 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)] @@ -104,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, @@ -114,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, @@ -128,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/DefaultScoreCounter.cs b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs index 6af89404e0..7cc2dc1751 100644 --- a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Graphics; @@ -8,7 +10,7 @@ using osu.Game.Skinning; namespace osu.Game.Screens.Play.HUD { - public class DefaultScoreCounter : GameplayScoreCounter, ISkinnableDrawable + public partial class DefaultScoreCounter : GameplayScoreCounter, ISerialisableDrawable { public DefaultScoreCounter() { diff --git a/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs b/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs new file mode 100644 index 0000000000..6eed563703 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs @@ -0,0 +1,130 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Localisation.HUD; +using osu.Game.Rulesets.Objects; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + public partial class DefaultSongProgress : SongProgress + { + private const float bottom_bar_height = 5; + private const float graph_height = SquareGraph.Column.WIDTH * 6; + private const float handle_height = 18; + + private static readonly Vector2 handle_size = new Vector2(10, handle_height); + + private const float transition_duration = 200; + + private readonly DefaultSongProgressBar bar; + private readonly DefaultSongProgressGraph graph; + private readonly SongProgressInfo info; + + [SettingSource(typeof(SongProgressStrings), nameof(SongProgressStrings.ShowGraph), nameof(SongProgressStrings.ShowGraphDescription))] + public Bindable ShowGraph { get; } = new BindableBool(true); + + [Resolved] + private Player? player { get; set; } + + public DefaultSongProgress() + { + RelativeSizeAxes = Axes.X; + Anchor = Anchor.BottomRight; + Origin = Anchor.BottomRight; + + Children = new Drawable[] + { + info = new SongProgressInfo + { + Origin = Anchor.BottomLeft, + Anchor = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + }, + graph = new DefaultSongProgressGraph + { + RelativeSizeAxes = Axes.X, + Origin = Anchor.BottomLeft, + Anchor = Anchor.BottomLeft, + Height = graph_height, + Margin = new MarginPadding { Bottom = bottom_bar_height }, + }, + bar = new DefaultSongProgressBar(bottom_bar_height, graph_height, handle_size) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + OnSeek = time => player?.Seek(time), + }, + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + graph.FillColour = bar.FillColour = colours.BlueLighter; + } + + protected override void LoadComplete() + { + Interactive.BindValueChanged(_ => updateBarVisibility(), true); + ShowGraph.BindValueChanged(_ => updateGraphVisibility(), true); + + base.LoadComplete(); + } + + protected override void UpdateObjects(IEnumerable objects) + { + graph.Objects = objects; + + info.StartTime = FirstHitTime; + info.EndTime = LastHitTime; + bar.StartTime = FirstHitTime; + bar.EndTime = LastHitTime; + } + + protected override void UpdateProgress(double progress, bool isIntro) + { + bar.CurrentTime = GameplayClock.CurrentTime; + + if (isIntro) + graph.Progress = 0; + else + graph.Progress = (int)(graph.ColumnCount * progress); + } + + protected override void Update() + { + base.Update(); + Height = bottom_bar_height + graph_height + handle_size.Y + info.Height - graph.Y; + } + + private void updateBarVisibility() + { + bar.Interactive = Interactive.Value; + + updateInfoMargin(); + } + + private void updateGraphVisibility() + { + float barHeight = bottom_bar_height + handle_size.Y; + + bar.ResizeHeightTo(ShowGraph.Value ? barHeight + graph_height : barHeight, transition_duration, Easing.In); + graph.FadeTo(ShowGraph.Value ? 1 : 0, transition_duration, Easing.In); + + updateInfoMargin(); + } + + private void updateInfoMargin() + { + float finalMargin = bottom_bar_height + (Interactive.Value ? handle_size.Y : 0) + (ShowGraph.Value ? graph_height : 0); + info.TransformTo(nameof(info.Margin), new MarginPadding { Bottom = finalMargin }, transition_duration, Easing.In); + } + } +} diff --git a/osu.Game/Screens/Play/SongProgressBar.cs b/osu.Game/Screens/Play/HUD/DefaultSongProgressBar.cs similarity index 87% rename from osu.Game/Screens/Play/SongProgressBar.cs rename to osu.Game/Screens/Play/HUD/DefaultSongProgressBar.cs index 5052b32335..0e16067dcc 100644 --- a/osu.Game/Screens/Play/SongProgressBar.cs +++ b/osu.Game/Screens/Play/HUD/DefaultSongProgressBar.cs @@ -11,19 +11,19 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Utils; using osu.Framework.Threading; -namespace osu.Game.Screens.Play +namespace osu.Game.Screens.Play.HUD { - public class SongProgressBar : SliderBar + public partial class DefaultSongProgressBar : SliderBar { - public Action OnSeek; + /// + /// Action which is invoked when a seek is requested, with the proposed millisecond value for the seek operation. + /// + public Action? OnSeek { get; set; } - private readonly Box fill; - private readonly Container handleBase; - private readonly Container handleContainer; - - private bool showHandle; - - public bool ShowHandle + /// + /// Whether the progress bar should allow interaction, ie. to perform seek operations. + /// + public bool Interactive { get => showHandle; set @@ -57,7 +57,13 @@ namespace osu.Game.Screens.Play set => CurrentNumber.Value = value; } - public SongProgressBar(float barHeight, float handleBarHeight, Vector2 handleSize) + private readonly Box fill; + private readonly Container handleBase; + private readonly Container handleContainer; + + private bool showHandle; + + public DefaultSongProgressBar(float barHeight, float handleBarHeight, Vector2 handleSize) { CurrentNumber.MinValue = 0; CurrentNumber.MaxValue = 1; @@ -140,7 +146,7 @@ namespace osu.Game.Screens.Play handleBase.X = newX; } - private ScheduledDelegate scheduledSeek; + private ScheduledDelegate? scheduledSeek; protected override void OnUserChange(double value) { diff --git a/osu.Game/Screens/Play/SongProgressGraph.cs b/osu.Game/Screens/Play/HUD/DefaultSongProgressGraph.cs similarity index 92% rename from osu.Game/Screens/Play/SongProgressGraph.cs rename to osu.Game/Screens/Play/HUD/DefaultSongProgressGraph.cs index f96de149ba..bee5978817 100644 --- a/osu.Game/Screens/Play/SongProgressGraph.cs +++ b/osu.Game/Screens/Play/HUD/DefaultSongProgressGraph.cs @@ -1,14 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using System.Collections.Generic; using System.Diagnostics; using osu.Game.Rulesets.Objects; -namespace osu.Game.Screens.Play +namespace osu.Game.Screens.Play.HUD { - public class SongProgressGraph : SquareGraph + public partial class DefaultSongProgressGraph : SquareGraph { private IEnumerable objects; diff --git a/osu.Game/Screens/Play/HUD/FailingLayer.cs b/osu.Game/Screens/Play/HUD/FailingLayer.cs index ceb81f6b8d..67e7ae8f3f 100644 --- a/osu.Game/Screens/Play/HUD/FailingLayer.cs +++ b/osu.Game/Screens/Play/HUD/FailingLayer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -20,7 +22,7 @@ namespace osu.Game.Screens.Play.HUD /// /// An overlay layer on top of the playfield which fades to red when the current player health falls below a certain threshold defined by . /// - public class FailingLayer : HealthDisplay + public partial class FailingLayer : HealthDisplay { /// /// Whether the current player health should be shown on screen. diff --git a/osu.Game/Screens/Play/HUD/GameplayAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/GameplayAccuracyCounter.cs index 7a63084812..9da032e489 100644 --- a/osu.Game/Screens/Play/HUD/GameplayAccuracyCounter.cs +++ b/osu.Game/Screens/Play/HUD/GameplayAccuracyCounter.cs @@ -2,17 +2,64 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation.HUD; using osu.Game.Rulesets.Scoring; namespace osu.Game.Screens.Play.HUD { - public abstract class GameplayAccuracyCounter : PercentageCounter + public abstract partial class GameplayAccuracyCounter : PercentageCounter { - [BackgroundDependencyLoader] - private void load(ScoreProcessor scoreProcessor) + [SettingSource(typeof(GameplayAccuracyCounterStrings), nameof(GameplayAccuracyCounterStrings.AccuracyDisplay), nameof(GameplayAccuracyCounterStrings.AccuracyDisplayDescription))] + public Bindable AccuracyDisplay { get; } = new Bindable(); + + [Resolved] + private ScoreProcessor scoreProcessor { get; set; } = null!; + + protected override void LoadComplete() { - Current.BindTo(scoreProcessor.Accuracy); + base.LoadComplete(); + + AccuracyDisplay.BindValueChanged(mod => + { + Current.UnbindBindings(); + + switch (mod.NewValue) + { + case AccuracyDisplayMode.Standard: + Current.BindTo(scoreProcessor.Accuracy); + break; + + case AccuracyDisplayMode.MinimumAchievable: + Current.BindTo(scoreProcessor.MinimumAccuracy); + break; + + case AccuracyDisplayMode.MaximumAchievable: + Current.BindTo(scoreProcessor.MaximumAccuracy); + break; + } + }, true); + + // if the accuracy counter is using the "minimum achievable" mode, + // then its initial value is 0%, rather than the 100% that the base PercentageCounter assumes. + // to counteract this, manually finish transforms on DisplayedCount once after the initial callback above + // to stop it from rolling down from 100% to 0%. + FinishTransforms(targetMember: nameof(DisplayedCount)); + } + + public enum AccuracyDisplayMode + { + [LocalisableDescription(typeof(GameplayAccuracyCounterStrings), nameof(GameplayAccuracyCounterStrings.AccuracyDisplayModeStandard))] + Standard, + + [LocalisableDescription(typeof(GameplayAccuracyCounterStrings), nameof(GameplayAccuracyCounterStrings.AccuracyDisplayModeMax))] + MaximumAchievable, + + [LocalisableDescription(typeof(GameplayAccuracyCounterStrings), nameof(GameplayAccuracyCounterStrings.AccuracyDisplayModeMin))] + MinimumAchievable } } } diff --git a/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs index 638b2c395f..d990af32e7 100644 --- a/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/GameplayLeaderboard.cs @@ -3,7 +3,6 @@ using System; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Bindables; using osu.Framework.Caching; using osu.Framework.Extensions.Color4Extensions; @@ -11,15 +10,14 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Containers; -using osu.Game.Online.API.Requests.Responses; +using osu.Game.Users; using osuTK; using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD { - public class GameplayLeaderboard : CompositeDrawable + public abstract partial class GameplayLeaderboard : CompositeDrawable { - private readonly int maxPanels; private readonly Cached sorting = new Cached(); public Bindable Expanded = new Bindable(); @@ -29,22 +27,22 @@ namespace osu.Game.Screens.Play.HUD private bool requiresScroll; private readonly OsuScrollContainer scroll; - private GameplayLeaderboardScore trackedScore; + public GameplayLeaderboardScore? TrackedScore { get; private set; } + + private const int max_panels = 8; /// /// Create a new leaderboard. /// - /// The maximum panels to show at once. Defines the maximum height of this component. - public GameplayLeaderboard(int maxPanels = 8) + protected GameplayLeaderboard() { - this.maxPanels = maxPanels; - Width = GameplayLeaderboardScore.EXTENDED_WIDTH + GameplayLeaderboardScore.SHEAR_WIDTH; InternalChildren = new Drawable[] { scroll = new InputDisabledScrollContainer { + ClampExtension = 0, RelativeSizeAxes = Axes.Both, Child = Flow = new FillFlowContainer { @@ -75,24 +73,25 @@ namespace osu.Game.Screens.Play.HUD /// Whether the player should be tracked on the leaderboard. /// Set to true for the local player or a player whose replay is currently being played. /// - public ILeaderboardScore Add([CanBeNull] APIUser user, bool isTracked) + public ILeaderboardScore Add(IUser? user, bool isTracked) { var drawable = CreateLeaderboardScoreDrawable(user, isTracked); if (isTracked) { - if (trackedScore != null) + if (TrackedScore != null) throw new InvalidOperationException("Cannot track more than one score."); - trackedScore = drawable; + TrackedScore = drawable; } drawable.Expanded.BindTo(Expanded); Flow.Add(drawable); drawable.TotalScore.BindValueChanged(_ => sorting.Invalidate(), true); + drawable.DisplayOrder.BindValueChanged(_ => sorting.Invalidate(), true); - int displayCount = Math.Min(Flow.Count, maxPanels); + int displayCount = Math.Min(Flow.Count, max_panels); Height = displayCount * (GameplayLeaderboardScore.PANEL_HEIGHT + Flow.Spacing.Y); requiresScroll = displayCount != Flow.Count; @@ -102,21 +101,22 @@ namespace osu.Game.Screens.Play.HUD public void Clear() { Flow.Clear(); - trackedScore = null; + TrackedScore = null; scroll.ScrollToStart(false); } - protected virtual GameplayLeaderboardScore CreateLeaderboardScoreDrawable(APIUser user, bool isTracked) => + protected virtual GameplayLeaderboardScore CreateLeaderboardScoreDrawable(IUser? user, bool isTracked) => new GameplayLeaderboardScore(user, isTracked); protected override void Update() { base.Update(); - if (requiresScroll && trackedScore != null) + if (requiresScroll && TrackedScore != null) { - float scrollTarget = scroll.GetChildPosInContent(trackedScore) + trackedScore.DrawHeight / 2 - scroll.DrawHeight / 2; - scroll.ScrollTo(scrollTarget, false); + float scrollTarget = scroll.GetChildPosInContent(TrackedScore) + TrackedScore.DrawHeight / 2 - scroll.DrawHeight / 2; + + scroll.ScrollTo(scrollTarget); } const float panel_height = GameplayLeaderboardScore.PANEL_HEIGHT; @@ -124,7 +124,7 @@ namespace osu.Game.Screens.Play.HUD float fadeBottom = scroll.Current + scroll.DrawHeight; float fadeTop = scroll.Current + panel_height; - if (scroll.Current <= 0) fadeTop -= panel_height; + if (scroll.IsScrolledToStart()) fadeTop -= panel_height; if (!scroll.IsScrolledToEnd()) fadeBottom -= panel_height; // logic is mostly shared with Leaderboard, copied here for simplicity. @@ -163,18 +163,23 @@ namespace osu.Game.Screens.Play.HUD if (sorting.IsValid) return; - var orderedByScore = Flow.OrderByDescending(i => i.TotalScore.Value).ToList(); + var orderedByScore = Flow + .OrderByDescending(i => i.TotalScore.Value) + .ThenBy(i => i.DisplayOrder.Value) + .ToList(); for (int i = 0; i < Flow.Count; i++) { Flow.SetLayoutPosition(orderedByScore[i], i); - orderedByScore[i].ScorePosition = i + 1; + orderedByScore[i].ScorePosition = CheckValidScorePosition(orderedByScore[i], i + 1) ? i + 1 : null; } sorting.Validate(); } - private class InputDisabledScrollContainer : OsuScrollContainer + protected virtual bool CheckValidScorePosition(GameplayLeaderboardScore score, int position) => true; + + private partial class InputDisabledScrollContainer : OsuScrollContainer { public InputDisabledScrollContainer() { diff --git a/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs b/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs index 773ee4a036..07b80feb3e 100644 --- a/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs +++ b/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs @@ -1,6 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -10,7 +13,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Online.API.Requests.Responses; +using osu.Game.Users; using osu.Game.Users.Drawables; using osu.Game.Utils; using osuTK; @@ -18,7 +21,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD { - public class GameplayLeaderboardScore : CompositeDrawable, ILeaderboardScore + public partial class GameplayLeaderboardScore : CompositeDrawable, ILeaderboardScore { public const float EXTENDED_WIDTH = regular_width + top_player_left_width_extension; @@ -37,8 +40,6 @@ namespace osu.Game.Screens.Play.HUD private const float rank_text_width = 35f; - private const float score_components_width = 85f; - private const float avatar_size = 25f; private const double panel_transition_duration = 500; @@ -49,10 +50,11 @@ namespace osu.Game.Screens.Play.HUD private OsuSpriteText positionText, scoreText, accuracyText, comboText, usernameText; - public BindableDouble TotalScore { get; } = new BindableDouble(); + public BindableLong TotalScore { get; } = new BindableLong(); public BindableDouble Accuracy { get; } = new BindableDouble(1); public BindableInt Combo { get; } = new BindableInt(); public BindableBool HasQuit { get; } = new BindableBool(); + public Bindable DisplayOrder { get; } = new Bindable(); public Color4? BackgroundColour { get; set; } @@ -60,26 +62,28 @@ namespace osu.Game.Screens.Play.HUD private int? scorePosition; + private bool scorePositionIsSet; + public int? ScorePosition { get => scorePosition; set { - if (value == scorePosition) + // We always want to run once, as the incoming value may be null and require a visual update to "-". + if (value == scorePosition && scorePositionIsSet) return; scorePosition = value; - if (scorePosition.HasValue) - positionText.Text = $"#{scorePosition.Value.FormatRank()}"; + positionText.Text = scorePosition.HasValue ? $"#{scorePosition.Value.FormatRank()}" : "-"; + scorePositionIsSet = true; - positionText.FadeTo(scorePosition.HasValue ? 1 : 0); updateState(); } } [CanBeNull] - public APIUser User { get; } + public IUser User { get; } /// /// Whether this score is the local user or a replay player (and should be focused / always visible). @@ -101,7 +105,7 @@ namespace osu.Game.Screens.Play.HUD /// /// The score's player. /// Whether the player is the local user or a replay player. - public GameplayLeaderboardScore([CanBeNull] APIUser user, bool tracked) + public GameplayLeaderboardScore([CanBeNull] IUser user, bool tracked) { User = user; Tracked = tracked; @@ -158,7 +162,7 @@ namespace osu.Game.Screens.Play.HUD { new Dimension(GridSizeMode.Absolute, rank_text_width), new Dimension(), - new Dimension(GridSizeMode.AutoSize, maxSize: score_components_width), + new Dimension(GridSizeMode.AutoSize), }, Content = new[] { @@ -283,8 +287,19 @@ namespace osu.Game.Screens.Play.HUD LoadComponentAsync(new DrawableAvatar(User), avatarContainer.Add); TotalScore.BindValueChanged(v => scoreText.Text = v.NewValue.ToString("N0"), true); - Accuracy.BindValueChanged(v => accuracyText.Text = v.NewValue.FormatAccuracy(), true); - Combo.BindValueChanged(v => comboText.Text = $"{v.NewValue}x", true); + + Accuracy.BindValueChanged(v => + { + accuracyText.Text = v.NewValue.FormatAccuracy(); + updateDetailsWidth(); + }, true); + + Combo.BindValueChanged(v => + { + comboText.Text = $"{v.NewValue}x"; + updateDetailsWidth(); + }, true); + HasQuit.BindValueChanged(_ => updateState()); } @@ -300,13 +315,10 @@ namespace osu.Game.Screens.Play.HUD private void changeExpandedState(ValueChangedEvent expanded) { - scoreComponents.ClearTransforms(); - if (expanded.NewValue) { gridContainer.ResizeWidthTo(regular_width, panel_transition_duration, Easing.OutQuint); - scoreComponents.ResizeWidthTo(score_components_width, panel_transition_duration, Easing.OutQuint); scoreComponents.FadeIn(panel_transition_duration, Easing.OutQuint); usernameText.FadeIn(panel_transition_duration, Easing.OutQuint); @@ -315,11 +327,29 @@ namespace osu.Game.Screens.Play.HUD { gridContainer.ResizeWidthTo(compact_width, panel_transition_duration, Easing.OutQuint); - scoreComponents.ResizeWidthTo(0, panel_transition_duration, Easing.OutQuint); scoreComponents.FadeOut(text_transition_duration, Easing.OutQuint); usernameText.FadeOut(text_transition_duration, Easing.OutQuint); } + + updateDetailsWidth(); + } + + private float? scoreComponentsTargetWidth; + + private void updateDetailsWidth() + { + const float score_components_min_width = 88f; + + float newWidth = Expanded.Value + ? Math.Max(score_components_min_width, comboText.DrawWidth + accuracyText.DrawWidth + 25) + : 0; + + if (scoreComponentsTargetWidth == newWidth) + return; + + scoreComponentsTargetWidth = newWidth; + scoreComponents.ResizeWidthTo(newWidth, panel_transition_duration, Easing.OutQuint); } private void updateState() diff --git a/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs b/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs index e05eff5f3e..a11cccd97c 100644 --- a/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -10,7 +12,7 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Screens.Play.HUD { - public abstract class GameplayScoreCounter : ScoreCounter + public abstract partial class GameplayScoreCounter : ScoreCounter { private Bindable scoreDisplayMode; diff --git a/osu.Game/Screens/Play/HUD/HealthDisplay.cs b/osu.Game/Screens/Play/HUD/HealthDisplay.cs index 94f1b99b37..7a73eb1657 100644 --- a/osu.Game/Screens/Play/HUD/HealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/HealthDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -15,7 +17,7 @@ namespace osu.Game.Screens.Play.HUD /// A container for components displaying the current player health. /// Gets bound automatically to the when inserted to hierarchy. /// - public abstract class HealthDisplay : CompositeDrawable + public abstract partial class HealthDisplay : CompositeDrawable { private readonly Bindable showHealthBar = new Bindable(true); diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs index dca50c07ad..eb5221aa45 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs @@ -9,22 +9,25 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Localisation.HUD; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; using osuTK; namespace osu.Game.Screens.Play.HUD.HitErrorMeters { - public class BarHitErrorMeter : HitErrorMeter + [Cached] + public partial class BarHitErrorMeter : HitErrorMeter { - private const int judgement_line_width = 14; - - [SettingSource("Judgement line thickness", "How thick the individual lines should be.")] + [SettingSource(typeof(BarHitErrorMeterStrings), nameof(BarHitErrorMeterStrings.JudgementLineThickness), nameof(BarHitErrorMeterStrings.JudgementLineThicknessDescription))] public BindableNumber JudgementLineThickness { get; } = new BindableNumber(4) { MinValue = 1, @@ -32,37 +35,45 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters Precision = 0.1f, }; - [SettingSource("Show moving average arrow", "Whether an arrow should move beneath the bar showing the average error.")] + [SettingSource(typeof(BarHitErrorMeterStrings), nameof(BarHitErrorMeterStrings.ColourBarVisibility))] + public Bindable ColourBarVisibility { get; } = new Bindable(true); + + [SettingSource(typeof(BarHitErrorMeterStrings), nameof(BarHitErrorMeterStrings.ShowMovingAverage), nameof(BarHitErrorMeterStrings.ShowMovingAverageDescription))] public Bindable ShowMovingAverage { get; } = new BindableBool(true); - [SettingSource("Centre marker style", "How to signify the centre of the display")] + [SettingSource(typeof(BarHitErrorMeterStrings), nameof(BarHitErrorMeterStrings.CentreMarkerStyle), nameof(BarHitErrorMeterStrings.CentreMarkerStyleDescription))] public Bindable CentreMarkerStyle { get; } = new Bindable(CentreMarkerStyles.Circle); - [SettingSource("Label style", "How to show early/late extremities")] + [SettingSource(typeof(BarHitErrorMeterStrings), nameof(BarHitErrorMeterStrings.LabelStyle), nameof(BarHitErrorMeterStrings.LabelStyleDescription))] public Bindable LabelStyle { get; } = new Bindable(LabelStyles.Icons); - private SpriteIcon arrow; - private Drawable labelEarly; - private Drawable labelLate; + private const int judgement_line_width = 14; - private Container colourBarsEarly; - private Container colourBarsLate; + private const int max_concurrent_judgements = 50; - private Container judgementsContainer; + private const int centre_marker_size = 8; private double maxHitWindow; private double floatingAverage; - private Container colourBars; - private Container arrowContainer; - private (HitResult result, double length)[] hitWindows; + private readonly DrawablePool judgementLinePool = new DrawablePool(50); - private const int max_concurrent_judgements = 50; + private SpriteIcon arrow = null!; + private UprightAspectMaintainingContainer labelEarly = null!; + private UprightAspectMaintainingContainer labelLate = null!; - private Drawable[] centreMarkerDrawables; + private Container colourBarsEarly = null!; + private Container colourBarsLate = null!; - private const int centre_marker_size = 8; + private Container judgementsContainer = null!; + + private Container colourBars = null!; + private Container arrowContainer = null!; + + private (HitResult result, double length)[] hitWindows = null!; + + private Drawable[]? centreMarkerDrawables; public BarHitErrorMeter() { @@ -85,6 +96,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters Margin = new MarginPadding(2), Children = new Drawable[] { + judgementLinePool, colourBars = new Container { Name = "colour axis", @@ -101,6 +113,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters Origin = Anchor.TopCentre, Width = bar_width, RelativeSizeAxes = Axes.Y, + Alpha = 0, Height = 0.5f, Scale = new Vector2(1, -1), }, @@ -108,6 +121,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters { Anchor = Anchor.Centre, Origin = Anchor.TopCentre, + Alpha = 0, Width = bar_width, RelativeSizeAxes = Axes.Y, Height = 0.5f, @@ -120,6 +134,20 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters RelativeSizeAxes = Axes.Y, Width = judgement_line_width, }, + labelEarly = new UprightAspectMaintainingContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + Y = -10, + }, + labelLate = new UprightAspectMaintainingContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + Y = 10, + }, } }, arrowContainer = new Container @@ -157,6 +185,11 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters CentreMarkerStyle.BindValueChanged(style => recreateCentreMarker(style.NewValue), true); LabelStyle.BindValueChanged(style => recreateLabels(style.NewValue), true); + ColourBarVisibility.BindValueChanged(visible => + { + colourBarsEarly.FadeTo(visible.NewValue ? 1 : 0, 500, Easing.OutQuint); + colourBarsLate.FadeTo(visible.NewValue ? 1 : 0, 500, Easing.OutQuint); + }, true); // delay the appearance animations for only the initial appearance. using (arrowContainer.BeginDelayedSequence(450)) @@ -259,57 +292,41 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters { const float icon_size = 14; - labelEarly?.Expire(); - labelEarly = null; - - labelLate?.Expire(); - labelLate = null; - switch (style) { case LabelStyles.None: + labelEarly.Clear(); + labelLate.Clear(); break; case LabelStyles.Icons: - labelEarly = new SpriteIcon + labelEarly.Child = new SpriteIcon { - Y = -10, Size = new Vector2(icon_size), Icon = FontAwesome.Solid.ShippingFast, - Anchor = Anchor.TopCentre, - Origin = Anchor.Centre, }; - labelLate = new SpriteIcon + labelLate.Child = new SpriteIcon { - Y = 10, Size = new Vector2(icon_size), Icon = FontAwesome.Solid.Bicycle, - Anchor = Anchor.BottomCentre, - Origin = Anchor.Centre, }; break; case LabelStyles.Text: - labelEarly = new OsuSpriteText + labelEarly.Child = new OsuSpriteText { - Y = -10, Text = "Early", Font = OsuFont.Default.With(size: 10), Height = 12, - Anchor = Anchor.TopCentre, - Origin = Anchor.Centre, }; - labelLate = new OsuSpriteText + labelLate.Child = new OsuSpriteText { - Y = 10, Text = "Late", Font = OsuFont.Default.With(size: 10), Height = 12, - Anchor = Anchor.BottomCentre, - Origin = Anchor.Centre, }; break; @@ -318,26 +335,8 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters throw new ArgumentOutOfRangeException(nameof(style), style, null); } - if (labelEarly != null) - { - colourBars.Add(labelEarly); - labelEarly.FadeInFromZero(500); - } - - if (labelLate != null) - { - colourBars.Add(labelLate); - labelLate.FadeInFromZero(500); - } - } - - protected override void Update() - { - base.Update(); - - // undo any layout rotation to display icons in the correct orientation - if (labelEarly != null) labelEarly.Rotation = -Rotation; - if (labelLate != null) labelLate.Rotation = -Rotation; + labelEarly.FadeInFromZero(500); + labelLate.FadeInFromZero(500); } private void createColourBars((HitResult result, double length)[] windows) @@ -420,11 +419,12 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters } } - judgementsContainer.Add(new JudgementLine + judgementLinePool.Get(drawableJudgement => { - JudgementLineThickness = { BindTarget = JudgementLineThickness }, - Y = getRelativeJudgementPosition(judgement.TimeOffset), - Colour = GetColourForHitResult(judgement.Type), + drawableJudgement.Y = getRelativeJudgementPosition(judgement.TimeOffset); + drawableJudgement.Colour = GetColourForHitResult(judgement.Type); + + judgementsContainer.Add(drawableJudgement); }); arrow.MoveToY( @@ -434,10 +434,13 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters private float getRelativeJudgementPosition(double value) => Math.Clamp((float)((value / maxHitWindow) + 1) / 2, 0, 1); - internal class JudgementLine : CompositeDrawable + internal partial class JudgementLine : PoolableDrawable { public readonly BindableNumber JudgementLineThickness = new BindableFloat(); + [Resolved] + private BarHitErrorMeter barHitErrorMeter { get; set; } = null!; + public JudgementLine() { RelativeSizeAxes = Axes.X; @@ -456,16 +459,22 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters protected override void LoadComplete() { + base.LoadComplete(); + + JudgementLineThickness.BindTo(barHitErrorMeter.JudgementLineThickness); + JudgementLineThickness.BindValueChanged(thickness => Height = thickness.NewValue, true); + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + const int judgement_fade_in_duration = 100; const int judgement_fade_out_duration = 5000; - base.LoadComplete(); - Alpha = 0; Width = 0; - JudgementLineThickness.BindValueChanged(thickness => Height = thickness.NewValue, true); - this .FadeTo(0.6f, judgement_fade_in_duration, Easing.OutQuint) .ResizeWidthTo(1, judgement_fade_in_duration, Easing.OutQuint) @@ -480,15 +489,25 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters public enum CentreMarkerStyles { + [LocalisableDescription(typeof(BarHitErrorMeterStrings), nameof(BarHitErrorMeterStrings.CentreMarkerStylesNone))] None, + + [LocalisableDescription(typeof(BarHitErrorMeterStrings), nameof(BarHitErrorMeterStrings.CentreMarkerStylesCircle))] Circle, + + [LocalisableDescription(typeof(BarHitErrorMeterStrings), nameof(BarHitErrorMeterStrings.CentreMarkerStylesLine))] Line } public enum LabelStyles { + [LocalisableDescription(typeof(BarHitErrorMeterStrings), nameof(BarHitErrorMeterStrings.LabelStylesNone))] None, + + [LocalisableDescription(typeof(BarHitErrorMeterStrings), nameof(BarHitErrorMeterStrings.LabelStylesIcons))] Icons, + + [LocalisableDescription(typeof(BarHitErrorMeterStrings), nameof(BarHitErrorMeterStrings.LabelStylesText))] Text } } diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs index 5012be7249..5793713fca 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs @@ -1,11 +1,17 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Localisation.HUD; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; using osuTK; @@ -13,16 +19,40 @@ using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD.HitErrorMeters { - public class ColourHitErrorMeter : HitErrorMeter + [Cached] + public partial class ColourHitErrorMeter : HitErrorMeter { private const int animation_duration = 200; + private const int drawable_judgement_size = 8; + + [SettingSource(typeof(ColourHitErrorMeterStrings), nameof(ColourHitErrorMeterStrings.JudgementCount), nameof(ColourHitErrorMeterStrings.JudgementCountDescription))] + public BindableNumber JudgementCount { get; } = new BindableNumber(20) + { + MinValue = 1, + MaxValue = 50, + }; + + [SettingSource(typeof(ColourHitErrorMeterStrings), nameof(ColourHitErrorMeterStrings.JudgementSpacing), nameof(ColourHitErrorMeterStrings.JudgementSpacingDescription))] + public BindableNumber JudgementSpacing { get; } = new BindableNumber(2) + { + MinValue = 0, + MaxValue = 10, + }; + + [SettingSource(typeof(ColourHitErrorMeterStrings), nameof(ColourHitErrorMeterStrings.JudgementShape), nameof(ColourHitErrorMeterStrings.JudgementShapeDescription))] + public Bindable JudgementShape { get; } = new Bindable(); private readonly JudgementFlow judgementsFlow; public ColourHitErrorMeter() { AutoSizeAxes = Axes.Both; - InternalChild = judgementsFlow = new JudgementFlow(); + InternalChild = judgementsFlow = new JudgementFlow + { + JudgementShape = { BindTarget = JudgementShape }, + JudgementSpacing = { BindTarget = JudgementSpacing }, + JudgementCount = { BindTarget = JudgementCount } + }; } protected override void OnNewJudgement(JudgementResult judgement) @@ -35,65 +65,140 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters public override void Clear() => judgementsFlow.Clear(); - private class JudgementFlow : FillFlowContainer + private partial class JudgementFlow : FillFlowContainer { - private const int max_available_judgements = 20; - private const int drawable_judgement_size = 8; - private const int spacing = 2; - public override IEnumerable FlowingChildren => base.FlowingChildren.Reverse(); + public readonly Bindable JudgementShape = new Bindable(); + + public readonly Bindable JudgementSpacing = new Bindable(); + + public readonly Bindable JudgementCount = new Bindable(); + public JudgementFlow() { - AutoSizeAxes = Axes.X; - Height = max_available_judgements * (drawable_judgement_size + spacing) - spacing; - Spacing = new Vector2(0, spacing); + Width = drawable_judgement_size; Direction = FillDirection.Vertical; LayoutDuration = animation_duration; LayoutEasing = Easing.OutQuint; } - public void Push(Color4 colour) - { - Add(new HitErrorCircle(colour, drawable_judgement_size)); - - if (Children.Count > max_available_judgements) - Children.FirstOrDefault(c => !c.IsRemoved)?.Remove(); - } - } - - internal class HitErrorCircle : Container - { - public bool IsRemoved { get; private set; } - - private readonly Circle circle; - - public HitErrorCircle(Color4 colour, int size) - { - Size = new Vector2(size); - Child = circle = new Circle - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - Colour = colour - }; - } - protected override void LoadComplete() { base.LoadComplete(); - circle.FadeInFromZero(animation_duration, Easing.OutQuint); - circle.MoveToY(-DrawSize.Y); - circle.MoveToY(0, animation_duration, Easing.OutQuint); + JudgementCount.BindValueChanged(_ => + { + removeExtraJudgements(); + updateMetrics(); + }); + + JudgementSpacing.BindValueChanged(_ => updateMetrics(), true); + } + + private readonly DrawablePool judgementLinePool = new DrawablePool(50); + + public void Push(Color4 colour) + { + judgementLinePool.Get(shape => + { + shape.Colour = colour; + Add(shape); + + removeExtraJudgements(); + }); + } + + private void removeExtraJudgements() + { + var remainingChildren = Children.Where(c => !c.IsRemoved); + + while (remainingChildren.Count() > JudgementCount.Value) + remainingChildren.First().Remove(); + } + + private void updateMetrics() + { + Height = JudgementCount.Value * (drawable_judgement_size + JudgementSpacing.Value) - JudgementSpacing.Value; + Spacing = new Vector2(0, JudgementSpacing.Value); + } + } + + public partial class HitErrorShape : PoolableDrawable + { + public bool IsRemoved { get; private set; } + + public readonly Bindable Shape = new Bindable(); + + [Resolved] + private ColourHitErrorMeter hitErrorMeter { get; set; } = null!; + + private Container content = null!; + + public HitErrorShape() + { + Size = new Vector2(drawable_judgement_size); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + InternalChild = content = new Container + { + RelativeSizeAxes = Axes.Both, + }; + + Shape.BindTo(hitErrorMeter.JudgementShape); + Shape.BindValueChanged(shape => + { + switch (shape.NewValue) + { + case ShapeStyle.Circle: + content.Child = new Circle { RelativeSizeAxes = Axes.Both }; + break; + + case ShapeStyle.Square: + content.Child = new Box { RelativeSizeAxes = Axes.Both }; + break; + } + }, true); + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + this.FadeInFromZero(animation_duration, Easing.OutQuint) + // On pool re-use, start flow animation from (0,0). + .MoveTo(Vector2.Zero); + + content.MoveToY(-DrawSize.Y) + .MoveToY(0, animation_duration, Easing.OutQuint); + } + + protected override void FreeAfterUse() + { + base.FreeAfterUse(); + IsRemoved = false; } public void Remove() { IsRemoved = true; - this.FadeOut(animation_duration, Easing.OutQuint).Expire(); + this.FadeOut(animation_duration, Easing.OutQuint) + .Expire(); } } + + public enum ShapeStyle + { + [LocalisableDescription(typeof(ColourHitErrorMeterStrings), nameof(ColourHitErrorMeterStrings.ShapeStyleCircle))] + Circle, + + [LocalisableDescription(typeof(ColourHitErrorMeterStrings), nameof(ColourHitErrorMeterStrings.ShapeStyleSquare))] + Square + } } } diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs index 1f08cb8aa7..5d65208afe 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; @@ -12,7 +14,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD.HitErrorMeters { - public abstract class HitErrorMeter : CompositeDrawable, ISkinnableDrawable + public abstract partial class HitErrorMeter : CompositeDrawable, ISerialisableDrawable { protected HitWindows HitWindows { get; private set; } @@ -31,6 +33,9 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters private void load(DrawableRuleset drawableRuleset) { HitWindows = drawableRuleset?.FirstAvailableHitWindows ?? HitWindows.Empty; + + // This is to allow the visual state to be correct after HUD comes visible after being hidden. + AlwaysPresent = true; } protected override void LoadComplete() @@ -54,30 +59,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters protected Color4 GetColourForHitResult(HitResult result) { - switch (result) - { - case HitResult.SmallTickMiss: - case HitResult.LargeTickMiss: - case HitResult.Miss: - return colours.Red; - - case HitResult.Meh: - return colours.Yellow; - - case HitResult.Ok: - return colours.Green; - - case HitResult.Good: - return colours.GreenLight; - - case HitResult.SmallTickHit: - case HitResult.LargeTickHit: - case HitResult.Great: - return colours.Blue; - - default: - return colours.BlueLight; - } + return colours.ForHitResult(result); } /// diff --git a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs index 2ba76d0896..0921a9f18a 100644 --- a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs +++ b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using osu.Framework.Allocation; @@ -23,12 +25,14 @@ using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD { - public class HoldForMenuButton : FillFlowContainer + public partial class HoldForMenuButton : FillFlowContainer { public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; public readonly Bindable IsPaused = new Bindable(); + public readonly Bindable ReplayLoaded = new Bindable(); + private HoldButton button; public Action Action { get; set; } @@ -58,6 +62,7 @@ namespace osu.Game.Screens.Play.HUD HoverGained = () => text.FadeIn(500, Easing.OutQuint), HoverLost = () => text.FadeOut(500, Easing.OutQuint), IsPaused = { BindTarget = IsPaused }, + ReplayLoaded = { BindTarget = ReplayLoaded }, Action = () => Action(), } }; @@ -100,7 +105,7 @@ namespace osu.Game.Screens.Play.HUD } } - private class HoldButton : HoldToConfirmContainer, IKeyBindingHandler + private partial class HoldButton : HoldToConfirmContainer, IKeyBindingHandler { private SpriteIcon icon; private CircularProgress circularProgress; @@ -108,6 +113,8 @@ namespace osu.Game.Screens.Play.HUD public readonly Bindable IsPaused = new Bindable(); + public readonly Bindable ReplayLoaded = new Bindable(); + protected override bool AllowMultipleFires => true; public Action HoverGained; @@ -216,7 +223,7 @@ namespace osu.Game.Screens.Play.HUD overlayCircle.ScaleTo(0, 100) .Then().FadeOut().ScaleTo(1).FadeIn(500) - .OnComplete(a => + .OnComplete(_ => { icon.ScaleTo(1, 100); circularProgress.FadeOut(100).OnComplete(_ => @@ -249,7 +256,14 @@ namespace osu.Game.Screens.Play.HUD switch (e.Action) { case GlobalAction.Back: - case GlobalAction.PauseGameplay: // in the future this behaviour will differ for replays etc. + if (!pendingAnimation) + BeginConfirm(); + return true; + + case GlobalAction.PauseGameplay: + // handled by replay player + if (ReplayLoaded.Value) return false; + if (!pendingAnimation) BeginConfirm(); return true; @@ -263,7 +277,12 @@ namespace osu.Game.Screens.Play.HUD switch (e.Action) { case GlobalAction.Back: + AbortConfirm(); + break; + case GlobalAction.PauseGameplay: + if (ReplayLoaded.Value) return; + AbortConfirm(); break; } diff --git a/osu.Game/Screens/Play/HUD/ILeaderboardScore.cs b/osu.Game/Screens/Play/HUD/ILeaderboardScore.cs index 83b6f6621b..428390f90c 100644 --- a/osu.Game/Screens/Play/HUD/ILeaderboardScore.cs +++ b/osu.Game/Screens/Play/HUD/ILeaderboardScore.cs @@ -1,16 +1,24 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; namespace osu.Game.Screens.Play.HUD { public interface ILeaderboardScore { - BindableDouble TotalScore { get; } + BindableLong TotalScore { get; } BindableDouble Accuracy { get; } BindableInt Combo { get; } BindableBool HasQuit { get; } + + /// + /// An optional value to guarantee stable ordering. + /// Lower numbers will appear higher in cases of ties. + /// + Bindable DisplayOrder { get; } } } 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/JudgementCounter/JudgementCounter.cs b/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounter.cs new file mode 100644 index 0000000000..7675d0cc4f --- /dev/null +++ b/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounter.cs @@ -0,0 +1,82 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Screens.Play.HUD.JudgementCounter +{ + public partial class JudgementCounter : VisibilityContainer + { + public BindableBool ShowName = new BindableBool(); + public Bindable Direction = new Bindable(); + + public readonly JudgementTally.JudgementCount Result; + + public JudgementCounter(JudgementTally.JudgementCount result) => Result = result; + + public OsuSpriteText ResultName = null!; + private FillFlowContainer flowContainer = null!; + private JudgementRollingCounter counter = null!; + + [BackgroundDependencyLoader] + private void load(OsuColour colours, IBindable ruleset) + { + AutoSizeAxes = Axes.Both; + + InternalChild = flowContainer = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + counter = new JudgementRollingCounter + { + Current = Result.ResultCount + }, + ResultName = new OsuSpriteText + { + Alpha = 0, + Font = OsuFont.Numeric.With(size: 8), + Text = ruleset.Value.CreateInstance().GetDisplayNameForHitResult(Result.Type) + } + } + }; + + var result = Result.Type; + + Colour = result.IsBasic() ? colours.ForHitResult(Result.Type) : !result.IsBonus() ? colours.PurpleLight : colours.PurpleLighter; + } + + protected override void LoadComplete() + { + ShowName.BindValueChanged(value => + ResultName.FadeTo(value.NewValue ? 1 : 0, JudgementCounterDisplay.TRANSFORM_DURATION, Easing.OutQuint), true); + + Direction.BindValueChanged(direction => + { + flowContainer.Direction = direction.NewValue; + changeAnchor(direction.NewValue == FillDirection.Vertical ? Anchor.TopLeft : Anchor.BottomLeft); + + void changeAnchor(Anchor anchor) => counter.Anchor = ResultName.Anchor = counter.Origin = ResultName.Origin = anchor; + }, true); + + base.LoadComplete(); + } + + protected override void PopIn() => this.FadeIn(JudgementCounterDisplay.TRANSFORM_DURATION, Easing.OutQuint); + protected override void PopOut() => this.FadeOut(100); + + private sealed partial class JudgementRollingCounter : RollingCounter + { + protected override OsuSpriteText CreateSpriteText() + => base.CreateSpriteText().With(s => s.Font = s.Font.With(fixedWidth: true, size: 16)); + } + } +} diff --git a/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounterDisplay.cs b/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounterDisplay.cs new file mode 100644 index 0000000000..a9b59a02b5 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementCounterDisplay.cs @@ -0,0 +1,145 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Localisation.HUD; +using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Screens.Play.HUD.JudgementCounter +{ + public partial class JudgementCounterDisplay : CompositeDrawable, ISerialisableDrawable + { + public const int TRANSFORM_DURATION = 250; + + public bool UsesFixedAnchor { get; set; } + + [SettingSource(typeof(JudgementCounterDisplayStrings), nameof(JudgementCounterDisplayStrings.JudgementDisplayMode))] + public Bindable Mode { get; set; } = new Bindable(); + + [SettingSource(typeof(JudgementCounterDisplayStrings), nameof(JudgementCounterDisplayStrings.FlowDirection))] + public Bindable FlowDirection { get; set; } = new Bindable(); + + [SettingSource(typeof(JudgementCounterDisplayStrings), nameof(JudgementCounterDisplayStrings.ShowJudgementNames))] + public BindableBool ShowJudgementNames { get; set; } = new BindableBool(true); + + [SettingSource(typeof(JudgementCounterDisplayStrings), nameof(JudgementCounterDisplayStrings.ShowMaxJudgement))] + public BindableBool ShowMaxJudgement { get; set; } = new BindableBool(true); + + [Resolved] + private JudgementTally tally { get; set; } = null!; + + protected FillFlowContainer CounterFlow = null!; + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + InternalChild = CounterFlow = new FillFlowContainer + { + Direction = getFillDirection(FlowDirection.Value), + Spacing = new Vector2(10), + AutoSizeAxes = Axes.Both + }; + + foreach (var result in tally.Results) + CounterFlow.Add(createCounter(result)); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + FlowDirection.BindValueChanged(direction => + { + var convertedDirection = getFillDirection(direction.NewValue); + + CounterFlow.Direction = convertedDirection; + + foreach (var counter in CounterFlow.Children) + counter.Direction.Value = convertedDirection; + }, true); + + Mode.BindValueChanged(_ => updateDisplay()); + ShowMaxJudgement.BindValueChanged(_ => updateDisplay(), true); + } + + private void updateDisplay() + { + for (int i = 0; i < CounterFlow.Children.Count; i++) + { + JudgementCounter counter = CounterFlow.Children[i]; + + if (shouldShow(i, counter)) + counter.Show(); + else + counter.Hide(); + } + + bool shouldShow(int index, JudgementCounter counter) + { + if (index == 0 && !ShowMaxJudgement.Value) + return false; + + if (counter.Result.Type.IsBasic()) + return true; + + switch (Mode.Value) + { + case DisplayMode.Simple: + return false; + + case DisplayMode.Normal: + return !counter.Result.Type.IsBonus(); + + case DisplayMode.All: + return true; + + default: + throw new ArgumentOutOfRangeException(); + } + } + } + + private FillDirection getFillDirection(Direction flow) + { + switch (flow) + { + case Direction.Horizontal: + return FillDirection.Horizontal; + + case Direction.Vertical: + return FillDirection.Vertical; + + default: + throw new ArgumentOutOfRangeException(nameof(flow), flow, @"Unsupported direction"); + } + } + + private JudgementCounter createCounter(JudgementTally.JudgementCount info) => + new JudgementCounter(info) + { + State = { Value = Visibility.Hidden }, + ShowName = { BindTarget = ShowJudgementNames } + }; + + public enum DisplayMode + { + [LocalisableDescription(typeof(JudgementCounterDisplayStrings), nameof(JudgementCounterDisplayStrings.JudgementDisplayModeSimple))] + Simple, + + [LocalisableDescription(typeof(JudgementCounterDisplayStrings), nameof(JudgementCounterDisplayStrings.JudgementDisplayModeNormal))] + Normal, + + [LocalisableDescription(typeof(JudgementCounterDisplayStrings), nameof(JudgementCounterDisplayStrings.JudgementDisplayModeAll))] + All + } + } +} diff --git a/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementTally.cs b/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementTally.cs new file mode 100644 index 0000000000..e9e3fde92a --- /dev/null +++ b/osu.Game/Screens/Play/HUD/JudgementCounter/JudgementTally.cs @@ -0,0 +1,60 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Screens.Play.HUD.JudgementCounter +{ + /// + /// Keeps track of judgements for a current play session, exposing bindable counts which can + /// be used for display purposes. + /// + public partial class JudgementTally : CompositeDrawable + { + [Resolved] + private ScoreProcessor scoreProcessor { get; set; } = null!; + + public List Results = new List(); + + [BackgroundDependencyLoader] + private void load(IBindable ruleset) + { + foreach (var result in ruleset.Value.CreateInstance().GetHitResults()) + { + Results.Add(new JudgementCount + { + Type = result.result, + ResultCount = new BindableInt() + }); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + scoreProcessor.NewJudgement += judgement => updateCount(judgement, false); + scoreProcessor.JudgementReverted += judgement => updateCount(judgement, true); + } + + private void updateCount(JudgementResult judgement, bool revert) + { + foreach (JudgementCount result in Results.Where(result => result.Type == judgement.Type)) + result.ResultCount.Value = revert ? result.ResultCount.Value - 1 : result.ResultCount.Value + 1; + } + + public struct JudgementCount + { + public HitResult Type { get; set; } + + public BindableInt ResultCount { get; set; } + } + } +} 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 71% rename from osu.Game/Screens/Play/KeyCounterAction.cs rename to osu.Game/Screens/Play/HUD/KeyCounterActionTrigger.cs index 00eddcc776..e5951a8bf4 100644 --- a/osu.Game/Screens/Play/KeyCounterAction.cs +++ b/osu.Game/Screens/Play/HUD/KeyCounterActionTrigger.cs @@ -3,14 +3,14 @@ using System.Collections.Generic; -namespace osu.Game.Screens.Play +namespace osu.Game.Screens.Play.HUD { - public 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; @@ -21,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; } @@ -32,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 71% rename from osu.Game/Screens/Play/KeyCounterKeyboard.cs rename to osu.Game/Screens/Play/HUD/KeyCounterKeyboardTrigger.cs index 187dcc1264..3052c1e666 100644 --- a/osu.Game/Screens/Play/KeyCounterKeyboard.cs +++ b/osu.Game/Screens/Play/HUD/KeyCounterKeyboardTrigger.cs @@ -4,13 +4,13 @@ using osu.Framework.Input.Events; using osuTK.Input; -namespace osu.Game.Screens.Play +namespace osu.Game.Screens.Play.HUD { - public 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; @@ -20,8 +20,7 @@ namespace osu.Game.Screens.Play { if (e.Key == Key) { - IsLit = true; - Increment(); + Activate(); } return base.OnKeyDown(e); @@ -29,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 80% rename from osu.Game/Screens/Play/KeyCounterMouse.cs rename to osu.Game/Screens/Play/HUD/KeyCounterMouseTrigger.cs index e55525c5e8..369aaa9f74 100644 --- a/osu.Game/Screens/Play/KeyCounterMouse.cs +++ b/osu.Game/Screens/Play/HUD/KeyCounterMouseTrigger.cs @@ -2,16 +2,16 @@ // See the LICENCE file in the repository root for full licence text. 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 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; @@ -37,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/LongestComboCounter.cs b/osu.Game/Screens/Play/HUD/LongestComboCounter.cs new file mode 100644 index 0000000000..fdc3768aab --- /dev/null +++ b/osu.Game/Screens/Play/HUD/LongestComboCounter.cs @@ -0,0 +1,83 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Scoring; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + public partial class LongestComboCounter : ComboCounter + { + [BackgroundDependencyLoader] + private void load(OsuColour colours, ScoreProcessor scoreProcessor) + { + Colour = colours.YellowLighter; + Current.BindTo(scoreProcessor.HighestCombo); + } + + protected override IHasText CreateText() => new TextComponent(); + + private partial class TextComponent : CompositeDrawable, IHasText + { + public LocalisableString Text + { + get => text.Text; + set => text.Text = $"{value}x"; + } + + private readonly OsuSpriteText text; + + public TextComponent() + { + AutoSizeAxes = Axes.Both; + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(2), + Children = new Drawable[] + { + text = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.Numeric.With(size: 20) + }, + new FillFlowContainer + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Font = OsuFont.Numeric.With(size: 8), + Text = @"longest", + }, + new OsuSpriteText + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Font = OsuFont.Numeric.With(size: 8), + Text = @"combo", + Padding = new MarginPadding { Bottom = 3f } + } + } + } + } + }; + } + } + } +} diff --git a/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs b/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs index 2129000268..58bf4eea4b 100644 --- a/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs +++ b/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -14,7 +16,7 @@ using osuTK; namespace osu.Game.Screens.Play.HUD { - public class MatchScoreDisplay : CompositeDrawable + public partial class MatchScoreDisplay : CompositeDrawable { private const float bar_height = 18; private const float font_size = 50; @@ -146,7 +148,7 @@ namespace osu.Game.Screens.Play.HUD Score2Text.X = Math.Max(5 + Score2Text.DrawWidth / 2, score2Bar.DrawWidth); } - protected class MatchScoreCounter : CommaSeparatedScoreCounter + protected partial class MatchScoreCounter : CommaSeparatedScoreCounter { private OsuSpriteText displayedSpriteText; diff --git a/osu.Game/Screens/Play/HUD/ModDisplay.cs b/osu.Game/Screens/Play/HUD/ModDisplay.cs index b4a3eb209a..8b2b8f9464 100644 --- a/osu.Game/Screens/Play/HUD/ModDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ModDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using osu.Framework.Bindables; @@ -18,7 +20,7 @@ namespace osu.Game.Screens.Play.HUD /// /// Displays a single-line horizontal auto-sized flow of mods. For cases where wrapping is required, use instead. /// - public class ModDisplay : CompositeDrawable, IHasCurrentValue> + public partial class ModDisplay : CompositeDrawable, IHasCurrentValue> { private const int fade_duration = 1000; @@ -31,8 +33,7 @@ namespace osu.Game.Screens.Play.HUD get => current.Current; set { - if (value == null) - throw new ArgumentNullException(nameof(value)); + ArgumentNullException.ThrowIfNull(value); current.Current = value; } diff --git a/osu.Game/Screens/Play/HUD/ModFlowDisplay.cs b/osu.Game/Screens/Play/HUD/ModFlowDisplay.cs index ff3ca6460f..38027c64ac 100644 --- a/osu.Game/Screens/Play/HUD/ModFlowDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ModFlowDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using osu.Framework.Bindables; @@ -17,7 +19,7 @@ namespace osu.Game.Screens.Play.HUD /// /// A horizontally wrapping display of mods. For cases where wrapping is not required, use instead. /// - public class ModFlowDisplay : ReverseChildIDFillFlowContainer, IHasCurrentValue> + public partial class ModFlowDisplay : ReverseChildIDFillFlowContainer, IHasCurrentValue> { private const int fade_duration = 1000; @@ -28,8 +30,7 @@ namespace osu.Game.Screens.Play.HUD get => current.Current; set { - if (value == null) - throw new ArgumentNullException(nameof(value)); + ArgumentNullException.ThrowIfNull(value); current.Current = value; } diff --git a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs index 5ee6000cf0..620f3718c2 100644 --- a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs @@ -1,15 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using System.Collections.Specialized; +using System.Diagnostics; using System.Linq; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Graphics; @@ -19,31 +20,33 @@ using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; using osu.Game.Online.Spectator; using osu.Game.Rulesets.Scoring; +using osu.Game.Users; using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD { [LongRunningLoad] - public class MultiplayerGameplayLeaderboard : GameplayLeaderboard + public partial class MultiplayerGameplayLeaderboard : GameplayLeaderboard { protected readonly Dictionary UserScores = new Dictionary(); public readonly SortedDictionary TeamScores = new SortedDictionary(); [Resolved] - private OsuColour colours { get; set; } + private OsuColour colours { get; set; } = null!; [Resolved] - private SpectatorClient spectatorClient { get; set; } + private SpectatorClient spectatorClient { get; set; } = null!; [Resolved] - private MultiplayerClient multiplayerClient { get; set; } + private MultiplayerClient multiplayerClient { get; set; } = null!; [Resolved] - private UserLookupCache userLookupCache { get; set; } + private UserLookupCache userLookupCache { get; set; } = null!; + + private Bindable scoringMode = null!; private readonly MultiplayerRoomUser[] playingUsers; - private Bindable scoringMode; private readonly IBindableList playingUserIds = new BindableList(); @@ -123,14 +126,17 @@ namespace osu.Game.Screens.Play.HUD playingUserIds.BindCollectionChanged(playingUsersChanged); } - protected override GameplayLeaderboardScore CreateLeaderboardScoreDrawable(APIUser user, bool isTracked) + protected override GameplayLeaderboardScore CreateLeaderboardScoreDrawable(IUser? user, bool isTracked) { var leaderboardScore = base.CreateLeaderboardScoreDrawable(user, isTracked); - if (UserScores[user.Id].Team is int team) + if (user != null) { - leaderboardScore.BackgroundColour = getTeamColour(team).Lighten(1.2f); - leaderboardScore.TextColour = Color4.White; + if (UserScores[user.OnlineID].Team is int team) + { + leaderboardScore.BackgroundColour = getTeamColour(team).Lighten(1.2f); + leaderboardScore.TextColour = Color4.White; + } } return leaderboardScore; @@ -148,11 +154,13 @@ namespace osu.Game.Screens.Play.HUD } } - private void playingUsersChanged(object sender, NotifyCollectionChangedEventArgs e) + private void playingUsersChanged(object? sender, NotifyCollectionChangedEventArgs e) { switch (e.Action) { case NotifyCollectionChangedAction.Remove: + Debug.Assert(e.OldItems != null); + foreach (int userId in e.OldItems.OfType()) { spectatorClient.StopWatchingUser(userId); @@ -178,7 +186,7 @@ namespace osu.Game.Screens.Play.HUD continue; if (TeamScores.TryGetValue(u.Team.Value, out var team)) - team.Value += (int)Math.Round(u.ScoreProcessor.TotalScore.Value); + team.Value += u.ScoreProcessor.TotalScore.Value; } } @@ -186,7 +194,7 @@ namespace osu.Game.Screens.Play.HUD { base.Dispose(isDisposing); - if (spectatorClient != null) + if (spectatorClient.IsNotNull()) { foreach (var user in playingUsers) spectatorClient.StopWatchingUser(user.UserID); diff --git a/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs b/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs index bdc98e53f9..4f37c215e9 100644 --- a/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs +++ b/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.IO; @@ -33,7 +35,7 @@ using osuTK; namespace osu.Game.Screens.Play.HUD { - public class PerformancePointsCounter : RollingCounter, ISkinnableDrawable + public partial class PerformancePointsCounter : RollingCounter, ISerialisableDrawable { public bool UsesFixedAnchor { get; set; } @@ -169,7 +171,7 @@ namespace osu.Game.Screens.Play.HUD loadCancellationSource?.Cancel(); } - private class TextComponent : CompositeDrawable, IHasText + private partial class TextComponent : CompositeDrawable, IHasText { public LocalisableString Text { diff --git a/osu.Game/Screens/Play/HUD/PlayerAvatar.cs b/osu.Game/Screens/Play/HUD/PlayerAvatar.cs new file mode 100644 index 0000000000..1d0331593a --- /dev/null +++ b/osu.Game/Screens/Play/HUD/PlayerAvatar.cs @@ -0,0 +1,58 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Configuration; +using osu.Game.Localisation.SkinComponents; +using osu.Game.Overlays.Settings; +using osu.Game.Skinning; +using osu.Game.Users.Drawables; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + public partial class PlayerAvatar : CompositeDrawable, ISerialisableDrawable + { + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.CornerRadius), nameof(SkinnableComponentStrings.CornerRadiusDescription), + SettingControlType = typeof(SettingsPercentageSlider))] + public new BindableFloat CornerRadius { get; set; } = new BindableFloat(0.25f) + { + MinValue = 0, + MaxValue = 0.5f, + Precision = 0.01f + }; + + private readonly UpdateableAvatar avatar; + + private const float default_size = 80f; + + public PlayerAvatar() + { + Size = new Vector2(default_size); + + InternalChild = avatar = new UpdateableAvatar(isInteractive: false) + { + RelativeSizeAxes = Axes.Both, + Masking = true + }; + } + + [BackgroundDependencyLoader] + private void load(GameplayState gameplayState) + { + avatar.User = gameplayState.Score.ScoreInfo.User; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + CornerRadius.BindValueChanged(e => avatar.CornerRadius = e.NewValue * default_size, true); + } + + public bool UsesFixedAnchor { get; set; } + } +} diff --git a/osu.Game/Screens/Play/HUD/PlayerFlag.cs b/osu.Game/Screens/Play/HUD/PlayerFlag.cs new file mode 100644 index 0000000000..85799c03d3 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/PlayerFlag.cs @@ -0,0 +1,36 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Skinning; +using osu.Game.Users.Drawables; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + public partial class PlayerFlag : CompositeDrawable, ISerialisableDrawable + { + private readonly UpdateableFlag flag; + + private const float default_size = 40f; + + public PlayerFlag() + { + Size = new Vector2(default_size, default_size / 1.4f); + InternalChild = flag = new UpdateableFlag + { + RelativeSizeAxes = Axes.Both, + }; + } + + [BackgroundDependencyLoader] + private void load(GameplayState gameplayState) + { + flag.CountryCode = gameplayState.Score.ScoreInfo.User.CountryCode; + } + + public bool UsesFixedAnchor { get; set; } + } +} diff --git a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs index 807b4989c7..45b2c1b13c 100644 --- a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs +++ b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; @@ -10,7 +12,7 @@ using osuTK.Input; namespace osu.Game.Screens.Play.HUD { - public class PlayerSettingsOverlay : VisibilityContainer + public partial class PlayerSettingsOverlay : VisibilityContainer { private const int fade_duration = 200; @@ -39,7 +41,7 @@ namespace osu.Game.Screens.Play.HUD { //CollectionSettings = new CollectionSettings(), //DiscussionSettings = new DiscussionSettings(), - PlaybackSettings = new PlaybackSettings(), + PlaybackSettings = new PlaybackSettings { Expanded = { Value = false } }, VisualSettings = new VisualSettings { Expanded = { Value = false } } } }; diff --git a/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs new file mode 100644 index 0000000000..9f92880919 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs @@ -0,0 +1,118 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Configuration; +using osu.Game.Online.API.Requests; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Select; +using osu.Game.Users; + +namespace osu.Game.Screens.Play.HUD +{ + public partial class SoloGameplayLeaderboard : GameplayLeaderboard + { + private const int duration = 100; + + private readonly Bindable configVisibility = new Bindable(); + + private readonly Bindable scoreSource = new Bindable(); + + private readonly IUser trackingUser; + + public readonly IBindableList Scores = new BindableList(); + + // 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. + /// + public readonly Bindable AlwaysVisible = new Bindable(true); + + public SoloGameplayLeaderboard(IUser trackingUser) + { + this.trackingUser = trackingUser; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + config.BindWith(OsuSetting.GameplayLeaderboard, configVisibility); + config.BindWith(OsuSetting.BeatmapDetailTab, scoreSource); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Scores.BindCollectionChanged((_, _) => Scheduler.AddOnce(showScores), true); + + // Alpha will be updated via `updateVisibility` below. + Alpha = 0; + + AlwaysVisible.BindValueChanged(_ => updateVisibility()); + configVisibility.BindValueChanged(_ => updateVisibility(), true); + } + + private void showScores() + { + Clear(); + scoreBindables.Clear(); + + if (!Scores.Any()) + return; + + foreach (var s in Scores) + { + 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.Accuracy.Value = s.Accuracy; + score.Combo.Value = s.MaxCombo; + score.DisplayOrder.Value = s.OnlineID > 0 ? s.OnlineID : s.Date.ToUnixTimeSeconds(); + } + + ILeaderboardScore local = Add(trackingUser, true); + + local.TotalScore.BindTarget = scoreProcessor.TotalScore; + local.Accuracy.BindTarget = scoreProcessor.Accuracy; + local.Combo.BindTarget = scoreProcessor.HighestCombo; + + // Local score should always show lower than any existing scores in cases of ties. + local.DisplayOrder.Value = long.MaxValue; + } + + protected override bool CheckValidScorePosition(GameplayLeaderboardScore score, int position) + { + // change displayed position to '-' when there are 50 already submitted scores and tracked score is last + if (score.Tracked && scoreSource.Value != PlayBeatmapDetailArea.TabType.Local) + { + if (position == Flow.Count && Flow.Count > GetScoresRequest.MAX_SCORES_PER_REQUEST) + return false; + } + + return base.CheckValidScorePosition(score, position); + } + + private void updateVisibility() => + this.FadeTo(AlwaysVisible.Value || configVisibility.Value ? 1 : 0, duration); + } +} diff --git a/osu.Game/Screens/Play/HUD/SongProgress.cs b/osu.Game/Screens/Play/HUD/SongProgress.cs new file mode 100644 index 0000000000..ebe2fb83e6 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/SongProgress.cs @@ -0,0 +1,124 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Timing; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.UI; +using osu.Game.Skinning; + +namespace osu.Game.Screens.Play.HUD +{ + public abstract partial class SongProgress : OverlayContainer, ISerialisableDrawable + { + // Some implementations of this element allow seeking during gameplay playback. + // Set a sane default of never handling input to override the behaviour provided by OverlayContainer. + public override bool HandleNonPositionalInput => Interactive.Value; + public override bool HandlePositionalInput => Interactive.Value; + + protected override bool BlockScrollInput => false; + + /// + /// Whether interaction should be allowed (ie. seeking). If false, interaction controls will not be displayed. + /// + /// + /// By default, this will be automatically decided based on the gameplay state. + /// + public readonly Bindable Interactive = new Bindable(); + + public bool UsesFixedAnchor { get; set; } + + [Resolved] + protected IGameplayClock GameplayClock { get; private set; } = null!; + + [Resolved] + private IFrameStableClock? frameStableClock { get; set; } + + /// + /// The reference clock is used to accurately tell the current playfield's time (including catch-up lag). + /// However, if none is available (i.e. used in tests), we fall back to the gameplay clock. + /// + protected IClock FrameStableClock => frameStableClock ?? GameplayClock; + + private IEnumerable? objects; + + public IEnumerable Objects + { + 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; + UpdateObjects(objects); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Show(); + } + + protected double FirstHitTime { get; private set; } + + protected double LastHitTime { get; private set; } + + protected abstract void UpdateProgress(double progress, bool isIntro); + protected virtual void UpdateObjects(IEnumerable objects) { } + + [BackgroundDependencyLoader] + private void load(DrawableRuleset? drawableRuleset, Player? player) + { + if (drawableRuleset != null) + { + if (player?.Configuration.AllowUserInteraction == true) + ((IBindable)Interactive).BindTo(drawableRuleset.HasReplayLoaded); + + Objects = drawableRuleset.Objects; + } + } + + protected override void PopIn() => this.FadeIn(500, Easing.OutQuint); + + protected override void PopOut() => this.FadeOut(100); + + protected override void Update() + { + base.Update(); + + if (objects == null) + return; + + double currentTime = FrameStableClock.CurrentTime; + + bool isInIntro = currentTime < FirstHitTime; + + if (isInIntro) + { + double introStartTime = GameplayClock.StartTime; + + double introOffsetCurrent = currentTime - introStartTime; + double introDuration = FirstHitTime - introStartTime; + + UpdateProgress(introOffsetCurrent / introDuration, true); + } + else + { + double objectOffsetCurrent = currentTime - FirstHitTime; + + double objectDuration = LastHitTime - FirstHitTime; + if (objectDuration == 0) + UpdateProgress(0, false); + else + UpdateProgress(objectOffsetCurrent / objectDuration, false); + } + } + } +} diff --git a/osu.Game/Screens/Play/HUD/SongProgressInfo.cs b/osu.Game/Screens/Play/HUD/SongProgressInfo.cs new file mode 100644 index 0000000000..c04ecd671f --- /dev/null +++ b/osu.Game/Screens/Play/HUD/SongProgressInfo.cs @@ -0,0 +1,168 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using System; +using osu.Framework.Graphics.Sprites; + +namespace osu.Game.Screens.Play.HUD +{ + public partial class SongProgressInfo : Container + { + private SizePreservingSpriteText timeCurrent; + private SizePreservingSpriteText timeLeft; + private SizePreservingSpriteText progress; + + private double startTime; + private double endTime; + + private int? previousPercent; + private int? previousSecond; + + private double songLength => endTime - startTime; + + public FontUsage Font + { + set + { + timeCurrent.Font = value; + timeLeft.Font = value; + progress.Font = value; + } + } + + public Colour4 TextColour + { + set + { + timeCurrent.Colour = value; + timeLeft.Colour = value; + progress.Colour = value; + } + } + + public double StartTime + { + set => startTime = value; + } + + public bool ShowProgress { get; init; } = true; + + public double EndTime + { + set => endTime = value; + } + + private IGameplayClock gameplayClock; + + [BackgroundDependencyLoader(true)] + private void load(OsuColour colours, IGameplayClock clock) + { + if (clock != null) + gameplayClock = clock; + + AutoSizeAxes = Axes.Y; + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Child = new UprightAspectMaintainingContainer + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Scaling = ScaleMode.Vertical, + ScalingFactor = 0.5f, + Child = timeCurrent = new SizePreservingSpriteText + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Colour = colours.BlueLighter, + Font = OsuFont.Numeric, + } + } + }, + new Container + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Alpha = ShowProgress ? 1 : 0, + Child = new UprightAspectMaintainingContainer + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Scaling = ScaleMode.Vertical, + ScalingFactor = 0.5f, + Child = progress = new SizePreservingSpriteText + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Colour = colours.BlueLighter, + Font = OsuFont.Numeric, + } + } + }, + new Container + { + Origin = Anchor.CentreRight, + Anchor = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + Child = new UprightAspectMaintainingContainer + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Scaling = ScaleMode.Vertical, + ScalingFactor = 0.5f, + Child = timeLeft = new SizePreservingSpriteText + { + Origin = Anchor.CentreRight, + Anchor = Anchor.CentreRight, + Colour = colours.BlueLighter, + Font = OsuFont.Numeric, + } + } + } + }; + } + + protected override void Update() + { + base.Update(); + + double time = gameplayClock?.CurrentTime ?? Time.Current; + + double songCurrentTime = time - startTime; + int currentPercent = Math.Max(0, Math.Min(100, (int)(songCurrentTime / songLength * 100))); + int currentSecond = (int)Math.Floor(songCurrentTime / 1000.0); + + if (currentPercent != previousPercent) + { + progress.Text = currentPercent + @"%"; + previousPercent = currentPercent; + } + + if (currentSecond != previousSecond && songCurrentTime < songLength) + { + timeCurrent.Text = formatTime(TimeSpan.FromSeconds(currentSecond)); + timeLeft.Text = formatTime(TimeSpan.FromMilliseconds(endTime - time)); + + previousSecond = currentSecond; + } + } + + private string formatTime(TimeSpan timeSpan) => $"{(timeSpan < TimeSpan.Zero ? "-" : "")}{Math.Floor(timeSpan.Duration().TotalMinutes)}:{timeSpan.Duration().Seconds:D2}"; + } +} diff --git a/osu.Game/Screens/Play/HUD/UnstableRateCounter.cs b/osu.Game/Screens/Play/HUD/UnstableRateCounter.cs index a71b661965..4ceca817e2 100644 --- a/osu.Game/Screens/Play/HUD/UnstableRateCounter.cs +++ b/osu.Game/Screens/Play/HUD/UnstableRateCounter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -18,7 +20,7 @@ using osuTK; namespace osu.Game.Screens.Play.HUD { - public class UnstableRateCounter : RollingCounter, ISkinnableDrawable + public partial class UnstableRateCounter : RollingCounter, ISerialisableDrawable { public bool UsesFixedAnchor { get; set; } @@ -81,7 +83,7 @@ namespace osu.Game.Screens.Play.HUD scoreProcessor.JudgementReverted -= updateDisplay; } - private class TextComponent : CompositeDrawable, IHasText + private partial class TextComponent : CompositeDrawable, IHasText { public LocalisableString Text { diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index f6087e0958..9f050a07bd 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.EnumExtensions; -using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; @@ -20,33 +22,49 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Play.HUD.ClicksPerSecond; +using osu.Game.Screens.Play.HUD.JudgementCounter; using osu.Game.Skinning; using osuTK; +using osu.Game.Localisation; +using osu.Game.Rulesets; namespace osu.Game.Screens.Play { [Cached] - public class HUDOverlay : Container, IKeyBindingHandler + public partial class HUDOverlay : Container, IKeyBindingHandler { public const float FADE_DURATION = 300; public const Easing FADE_EASING = Easing.OutQuint; - /// - /// The total height of all the top of screen scoring elements. - /// - public float TopScoringElementsHeight { get; private set; } - /// /// The total height of all the bottom of screen scoring elements. /// public float BottomScoringElementsHeight { get; private set; } + protected override bool ShouldBeConsideredForInput(Drawable child) + { + // HUD uses AlwaysVisible on child components so they can be in an updated state for next display. + // Without blocking input, this would also allow them to be interacted with in such a state. + if (ShowHud.Value) + return base.ShouldBeConsideredForInput(child); + + // hold to quit button should always be interactive. + return child == bottomRightElements; + } + public readonly KeyCounterDisplay KeyCounter; public readonly ModDisplay ModDisplay; public readonly HoldForMenuButton HoldToQuit; public readonly PlayerSettingsOverlay PlayerSettingsOverlay; + [Cached] + private readonly ClicksPerSecondCalculator clicksPerSecondCalculator; + + [Cached] + private readonly JudgementTally tally; + public Bindable ShowHealthBar = new Bindable(true); private readonly DrawableRuleset drawableRuleset; @@ -72,25 +90,39 @@ namespace osu.Game.Screens.Play private readonly BindableBool holdingForHUD = new BindableBool(); - private readonly SkinnableTargetContainer mainComponents; + private readonly SkinComponentsContainer mainComponents; - private IEnumerable hideTargets => new Drawable[] { mainComponents, KeyCounter, topRightElements }; + /// + /// A flow which sits at the left side of the screen to house leaderboard (and related) components. + /// Will automatically be positioned to avoid colliding with top scoring elements. + /// + public readonly FillFlowContainer LeaderboardFlow; - public HUDOverlay(DrawableRuleset drawableRuleset, IReadOnlyList mods) + private readonly List hideTargets; + + public HUDOverlay(DrawableRuleset drawableRuleset, IReadOnlyList mods, bool alwaysShowLeaderboard = true) { + Drawable rulesetComponents; + this.drawableRuleset = drawableRuleset; this.mods = mods; RelativeSizeAxes = Axes.Both; - Children = new Drawable[] + Children = new[] { CreateFailingLayer(), - mainComponents = new MainComponentsContainer(), + //Needs to be initialized before skinnable drawables. + tally = new JudgementTally(), + mainComponents = new HUDComponentsContainer { AlwaysPresent = true, }, + rulesetComponents = drawableRuleset != null + ? new HUDComponentsContainer(drawableRuleset.Ruleset.RulesetInfo) { AlwaysPresent = true, } + : Empty(), topRightElements = new FillFlowContainer { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, + AlwaysPresent = true, Margin = new MarginPadding(10), Spacing = new Vector2(10), AutoSizeAxes = Axes.Both, @@ -116,8 +148,21 @@ namespace osu.Game.Screens.Play KeyCounter = CreateKeyCounter(), HoldToQuit = CreateHoldForMenuButton(), } - } + }, + LeaderboardFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Padding = new MarginPadding(44), // enough margin to avoid the hit error display + Spacing = new Vector2(5) + }, + clicksPerSecondCalculator = new ClicksPerSecondCalculator(), }; + + hideTargets = new List { mainComponents, rulesetComponents, KeyCounter, topRightElements }; + + if (!alwaysShowLeaderboard) + hideTargets.Add(LeaderboardFlow); } [BackgroundDependencyLoader(true)] @@ -138,7 +183,7 @@ namespace osu.Game.Screens.Play notificationOverlay?.Post(new SimpleNotification { - Text = $"The score overlay is currently disabled. You can toggle this by pressing {config.LookupKeyBindings(GlobalAction.ToggleInGameInterface)}." + Text = NotificationsStrings.ScoreOverlayDisabled(config.LookupKeyBindings(GlobalAction.ToggleInGameInterface)) }); } @@ -166,22 +211,36 @@ namespace osu.Game.Screens.Play { base.Update(); - Vector2? lowestTopScreenSpace = null; + float? lowestTopScreenSpaceLeft = null; + float? lowestTopScreenSpaceRight = null; + Vector2? highestBottomScreenSpace = null; // LINQ cast can be removed when IDrawable interface includes Anchor / RelativeSizeAxes. foreach (var element in mainComponents.Components.Cast()) { - // for now align top-right components with the bottom-edge of the lowest top-anchored hud element. - if (element.Anchor.HasFlagFast(Anchor.TopRight) || (element.Anchor.HasFlagFast(Anchor.y0) && element.RelativeSizeAxes == Axes.X)) + // for now align some top components with the bottom-edge of the lowest top-anchored hud element. + if (element.Anchor.HasFlagFast(Anchor.y0)) { // health bars are excluded for the sake of hacky legacy skins which extend the health bar to take up the full screen area. if (element is LegacyHealthDisplay) continue; - var bottomRight = element.ScreenSpaceDrawQuad.BottomRight; - if (lowestTopScreenSpace == null || bottomRight.Y > lowestTopScreenSpace.Value.Y) - lowestTopScreenSpace = bottomRight; + float bottom = element.ScreenSpaceDrawQuad.BottomRight.Y; + + bool isRelativeX = element.RelativeSizeAxes == Axes.X; + + if (element.Anchor.HasFlagFast(Anchor.TopRight) || isRelativeX) + { + if (lowestTopScreenSpaceRight == null || bottom > lowestTopScreenSpaceRight.Value) + lowestTopScreenSpaceRight = bottom; + } + + if (element.Anchor.HasFlagFast(Anchor.TopLeft) || isRelativeX) + { + if (lowestTopScreenSpaceLeft == null || bottom > lowestTopScreenSpaceLeft.Value) + lowestTopScreenSpaceLeft = bottom; + } } // and align bottom-right components with the top-edge of the highest bottom-anchored hud element. else if (element.Anchor.HasFlagFast(Anchor.BottomRight) || (element.Anchor.HasFlagFast(Anchor.y2) && element.RelativeSizeAxes == Axes.X)) @@ -192,11 +251,16 @@ namespace osu.Game.Screens.Play } } - if (lowestTopScreenSpace.HasValue) - topRightElements.Y = TopScoringElementsHeight = MathHelper.Clamp(ToLocalSpace(lowestTopScreenSpace.Value).Y, 0, DrawHeight - topRightElements.DrawHeight); + if (lowestTopScreenSpaceRight.HasValue) + topRightElements.Y = MathHelper.Clamp(ToLocalSpace(new Vector2(0, lowestTopScreenSpaceRight.Value)).Y, 0, DrawHeight - topRightElements.DrawHeight); else topRightElements.Y = 0; + if (lowestTopScreenSpaceLeft.HasValue) + LeaderboardFlow.Y = MathHelper.Clamp(ToLocalSpace(new Vector2(0, lowestTopScreenSpaceLeft.Value)).Y, 0, DrawHeight - LeaderboardFlow.DrawHeight); + else + LeaderboardFlow.Y = 0; + if (highestBottomScreenSpace.HasValue) bottomRightElements.Y = BottomScoringElementsHeight = -MathHelper.Clamp(DrawHeight - ToLocalSpace(highestBottomScreenSpace.Value).Y, 0, DrawHeight - bottomRightElements.DrawHeight); else @@ -253,7 +317,11 @@ namespace osu.Game.Screens.Play protected virtual void BindDrawableRuleset(DrawableRuleset drawableRuleset) { - (drawableRuleset as ICanAttachKeyCounter)?.Attach(KeyCounter); + if (drawableRuleset is ICanAttachHUDPieces attachTarget) + { + attachTarget.Attach(KeyCounter); + attachTarget.Attach(clicksPerSecondCalculator); + } replayLoaded.BindTo(drawableRuleset.HasReplayLoaded); } @@ -263,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, @@ -326,15 +394,15 @@ namespace osu.Game.Screens.Play } } - private class MainComponentsContainer : SkinnableTargetContainer + private partial class HUDComponentsContainer : SkinComponentsContainer { private Bindable scoringMode; [Resolved] private OsuConfigManager config { get; set; } - public MainComponentsContainer() - : base(SkinnableTarget.MainHUDComponents) + public HUDComponentsContainer([CanBeNull] RulesetInfo ruleset = null) + : base(new SkinComponentsContainerLookup(SkinComponentsContainerLookup.TargetArea.MainHUDComponents, ruleset)) { RelativeSizeAxes = Axes.Both; } @@ -346,7 +414,7 @@ namespace osu.Game.Screens.Play // When the scoring mode changes, relative positions of elements may change (see DefaultSkin.GetDrawableComponent). // This is a best effort implementation for cases where users haven't customised layouts. scoringMode = config.GetBindable(OsuSetting.ScoreDisplayMode); - scoringMode.BindValueChanged(val => Reload()); + scoringMode.BindValueChanged(_ => Reload()); } } } diff --git a/osu.Game/Screens/Play/HotkeyExitOverlay.cs b/osu.Game/Screens/Play/HotkeyExitOverlay.cs index 9fe7d17cc7..4c1265c699 100644 --- a/osu.Game/Screens/Play/HotkeyExitOverlay.cs +++ b/osu.Game/Screens/Play/HotkeyExitOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Input.Bindings; @@ -8,7 +10,7 @@ using osu.Game.Overlays; namespace osu.Game.Screens.Play { - public class HotkeyExitOverlay : HoldToConfirmOverlay, IKeyBindingHandler + public partial class HotkeyExitOverlay : HoldToConfirmOverlay, IKeyBindingHandler { public bool OnPressed(KeyBindingPressEvent e) { diff --git a/osu.Game/Screens/Play/HotkeyRetryOverlay.cs b/osu.Game/Screens/Play/HotkeyRetryOverlay.cs index 2812df8bbe..582b5a1691 100644 --- a/osu.Game/Screens/Play/HotkeyRetryOverlay.cs +++ b/osu.Game/Screens/Play/HotkeyRetryOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Input.Bindings; @@ -8,7 +10,7 @@ using osu.Game.Overlays; namespace osu.Game.Screens.Play { - public class HotkeyRetryOverlay : HoldToConfirmOverlay, IKeyBindingHandler + public partial class HotkeyRetryOverlay : HoldToConfirmOverlay, IKeyBindingHandler { public bool OnPressed(KeyBindingPressEvent e) { diff --git a/osu.Game/Screens/Play/IGameplayClock.cs b/osu.Game/Screens/Play/IGameplayClock.cs new file mode 100644 index 0000000000..83ba5f3474 --- /dev/null +++ b/osu.Game/Screens/Play/IGameplayClock.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Timing; + +namespace osu.Game.Screens.Play +{ + public interface IGameplayClock : IFrameBasedClock + { + /// + /// The time from which the clock should start. Will be seeked to on calling . + /// + /// + /// By default, a value of zero will be used. + /// Importantly, the value will be inferred from the current beatmap in by default. + /// + double StartTime { get; } + + /// + /// All adjustments applied to this clock which come from mods. + /// + IAdjustableAudioComponent AdjustmentsFromMods { get; } + + IBindable IsPaused { get; } + } +} diff --git a/osu.Game/Screens/Play/ILocalUserPlayInfo.cs b/osu.Game/Screens/Play/ILocalUserPlayInfo.cs index 9a2259b12f..e4328b2c78 100644 --- a/osu.Game/Screens/Play/ILocalUserPlayInfo.cs +++ b/osu.Game/Screens/Play/ILocalUserPlayInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; diff --git a/osu.Game/Screens/Play/KeyCounterDisplay.cs b/osu.Game/Screens/Play/KeyCounterDisplay.cs deleted file mode 100644 index 66a44e5314..0000000000 --- a/osu.Game/Screens/Play/KeyCounterDisplay.cs +++ /dev/null @@ -1,169 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.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 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, - }; - } - - protected override void Update() - { - base.Update(); - - // Don't use autosize as it will shrink to zero when KeyFlow is hidden. - // In turn this can cause the display to be masked off screen and never become visible again. - Size = KeyFlow.Size; - } - - public override void Add(KeyCounter key) - { - if (key == null) throw new ArgumentNullException(nameof(key)); - - 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 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/KeyCounterState.cs b/osu.Game/Screens/Play/KeyCounterState.cs deleted file mode 100644 index 0bceae9f85..0000000000 --- a/osu.Game/Screens/Play/KeyCounterState.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. - -namespace osu.Game.Screens.Play -{ - public class KeyCounterState - { - public KeyCounterState(double time, int count) - { - Time = time; - Count = count; - } - - public readonly double Time; - public readonly int Count; - } -} diff --git a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs index d87d57ec49..489a4ef8b3 100644 --- a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs +++ b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs @@ -2,9 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Linq; -using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Track; @@ -13,8 +11,7 @@ using osu.Framework.Graphics; using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Configuration; -using osu.Game.Database; +using osu.Game.Overlays; namespace osu.Game.Screens.Play { @@ -28,44 +25,39 @@ namespace osu.Game.Screens.Play /// /// This is intended to be used as a single controller for gameplay, or as a reference source for other s. /// - public class MasterGameplayClockContainer : GameplayClockContainer, IBeatSyncProvider + public partial class MasterGameplayClockContainer : GameplayClockContainer, IBeatSyncProvider { /// /// Duration before gameplay start time required before skip button displays. /// public const double MINIMUM_SKIP_TIME = 1000; - protected Track Track => (Track)SourceClock; - public readonly BindableNumber UserPlaybackRate = new BindableDouble(1) { - Default = 1, MinValue = 0.5, MaxValue = 2, Precision = 0.1, }; - private double totalAppliedOffset => userBeatmapOffsetClock.RateAdjustedOffset + userGlobalOffsetClock.RateAdjustedOffset + platformOffsetClock.RateAdjustedOffset; - - private readonly BindableDouble pauseFreqAdjust = new BindableDouble(); // Important that this starts at zero, matching the paused state of the clock. - private readonly WorkingBeatmap beatmap; - private HardwareCorrectionOffsetClock userGlobalOffsetClock; - private HardwareCorrectionOffsetClock userBeatmapOffsetClock; - private HardwareCorrectionOffsetClock platformOffsetClock; - private MasterGameplayClock masterGameplayClock; - private Bindable userAudioOffset; - - private IDisposable beatmapOffsetSubscription; + private readonly Track track; private readonly double skipTargetTime; - [Resolved] - private RealmAccess realm { get; set; } + /// + /// Stores the time at which the last call was triggered. + /// This is used to ensure we resume from that precise point in time, ignoring the proceeding frequency ramp. + /// + /// Optimally, we'd have gameplay ramp down with the frequency, but I believe this was intentionally disabled + /// to avoid fails occurring after the pause screen has been shown. + /// + /// In the future I want to change this. + /// + private double? actualStopTime; [Resolved] - private OsuConfigManager config { get; set; } + private MusicController musicController { get; set; } = null!; /// /// Create a new master gameplay clock container. @@ -73,32 +65,14 @@ namespace osu.Game.Screens.Play /// The beatmap to be used for time and metadata references. /// The latest time which should be used when introducing gameplay. Will be used when skipping forward. public MasterGameplayClockContainer(WorkingBeatmap beatmap, double skipTargetTime) - : base(beatmap.Track) + : base(beatmap.Track, true) { this.beatmap = beatmap; this.skipTargetTime = skipTargetTime; - } - protected override void LoadComplete() - { - base.LoadComplete(); + track = beatmap.Track; - userAudioOffset = config.GetBindable(OsuSetting.AudioOffset); - userAudioOffset.BindValueChanged(offset => userGlobalOffsetClock.Offset = offset.NewValue, true); - - beatmapOffsetSubscription = realm.SubscribeToPropertyChanged( - r => r.Find(beatmap.BeatmapInfo.ID)?.UserSettings, - settings => settings.Offset, - val => userBeatmapOffsetClock.Offset = val); - - // Reset may have been called externally before LoadComplete. - // If it was, and the clock is in a playing state, we want to ensure that it isn't stopped here. - bool isStarted = !IsPaused.Value; - - // If a custom start time was not specified, calculate the best value to use. - StartTime ??= findEarliestStartTime(); - - Reset(startClock: isStarted); + StartTime = findEarliestStartTime(); } private double findEarliestStartTime() @@ -124,54 +98,70 @@ namespace osu.Game.Screens.Play return time; } - protected override void OnIsPausedChanged(ValueChangedEvent isPaused) + protected override void StopGameplayClock() { + actualStopTime = GameplayClock.CurrentTime; + if (IsLoaded) { // During normal operation, the source is stopped after performing a frequency ramp. - if (isPaused.NewValue) + this.TransformBindableTo(GameplayClock.ExternalPauseFrequencyAdjust, 0, 200, Easing.Out).OnComplete(_ => { - this.TransformBindableTo(pauseFreqAdjust, 0, 200, Easing.Out).OnComplete(_ => - { - if (IsPaused.Value == isPaused.NewValue) - AdjustableSource.Stop(); - }); - } - else - this.TransformBindableTo(pauseFreqAdjust, 1, 200, Easing.In); + if (IsPaused.Value) + base.StopGameplayClock(); + }); } else { - if (isPaused.NewValue) - AdjustableSource.Stop(); + base.StopGameplayClock(); // If not yet loaded, we still want to ensure relevant state is correct, as it is used for offset calculations. - pauseFreqAdjust.Value = isPaused.NewValue ? 0 : 1; + GameplayClock.ExternalPauseFrequencyAdjust.Value = 0; // We must also process underlying gameplay clocks to update rate-adjusted offsets with the new frequency adjustment. // Without doing this, an initial seek may be performed with the wrong offset. - GameplayClock.UnderlyingClock.ProcessFrame(); + GameplayClock.ProcessFrame(); } } - public override void Start() - { - addSourceClockAdjustments(); - base.Start(); - } - - /// - /// Seek to a specific time in gameplay. - /// - /// - /// Adjusts for any offsets which have been applied (so the seek may not be the expected point in time on the underlying audio track). - /// - /// The destination time to seek to. public override void Seek(double time) { - // remove the offset component here because most of the time we want the seek to be aligned to gameplay, not the audio track. - // we may want to consider reversing the application of offsets in the future as it may feel more correct. - base.Seek(time - totalAppliedOffset); + // Safety in case the clock is seeked while stopped. + actualStopTime = null; + + base.Seek(time); + } + + protected override void PrepareStart() + { + if (actualStopTime != null) + { + Seek(actualStopTime.Value); + actualStopTime = null; + } + else + base.PrepareStart(); + } + + protected override void StartGameplayClock() + { + addSourceClockAdjustments(); + + base.StartGameplayClock(); + + if (IsLoaded) + { + this.TransformBindableTo(GameplayClock.ExternalPauseFrequencyAdjust, 1, 200, Easing.In); + } + else + { + // If not yet loaded, we still want to ensure relevant state is correct, as it is used for offset calculations. + GameplayClock.ExternalPauseFrequencyAdjust.Value = 1; + + // We must also process underlying gameplay clocks to update rate-adjusted offsets with the new frequency adjustment. + // Without doing this, an initial seek may be performed with the wrong offset. + GameplayClock.ProcessFrame(); + } } /// @@ -191,19 +181,6 @@ namespace osu.Game.Screens.Play Seek(skipTarget); } - protected override GameplayClock CreateGameplayClock(IFrameBasedClock source) - { - // Lazer's audio timings in general doesn't match stable. This is the result of user testing, albeit limited. - // This only seems to be required on windows. We need to eventually figure out why, with a bit of luck. - platformOffsetClock = new HardwareCorrectionOffsetClock(source, pauseFreqAdjust) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 }; - - // the final usable gameplay clock with user-set offsets applied. - userGlobalOffsetClock = new HardwareCorrectionOffsetClock(platformOffsetClock, pauseFreqAdjust); - userBeatmapOffsetClock = new HardwareCorrectionOffsetClock(userGlobalOffsetClock, pauseFreqAdjust); - - return masterGameplayClock = new MasterGameplayClock(userBeatmapOffsetClock); - } - /// /// Changes the backing clock to avoid using the originally provided track. /// @@ -221,11 +198,11 @@ namespace osu.Game.Screens.Play if (speedAdjustmentsApplied) return; - Track.AddAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust); - Track.AddAdjustment(AdjustableProperty.Tempo, UserPlaybackRate); + musicController.ResetTrackAdjustments(); - masterGameplayClock.MutableNonGameplayAdjustments.Add(pauseFreqAdjust); - masterGameplayClock.MutableNonGameplayAdjustments.Add(UserPlaybackRate); + track.BindAdjustments(AdjustmentsFromMods); + track.AddAdjustment(AdjustableProperty.Frequency, GameplayClock.ExternalPauseFrequencyAdjust); + track.AddAdjustment(AdjustableProperty.Tempo, UserPlaybackRate); speedAdjustmentsApplied = true; } @@ -235,11 +212,9 @@ namespace osu.Game.Screens.Play if (!speedAdjustmentsApplied) return; - Track.RemoveAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust); - Track.RemoveAdjustment(AdjustableProperty.Tempo, UserPlaybackRate); - - masterGameplayClock.MutableNonGameplayAdjustments.Remove(pauseFreqAdjust); - masterGameplayClock.MutableNonGameplayAdjustments.Remove(UserPlaybackRate); + track.UnbindAdjustments(AdjustmentsFromMods); + track.RemoveAdjustment(AdjustableProperty.Frequency, GameplayClock.ExternalPauseFrequencyAdjust); + track.RemoveAdjustment(AdjustableProperty.Tempo, UserPlaybackRate); speedAdjustmentsApplied = false; } @@ -247,68 +222,12 @@ namespace osu.Game.Screens.Play protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - beatmapOffsetSubscription?.Dispose(); removeSourceClockAdjustments(); } ControlPointInfo IBeatSyncProvider.ControlPoints => beatmap.Beatmap.ControlPointInfo; - IClock IBeatSyncProvider.Clock => GameplayClock; - ChannelAmplitudes? IBeatSyncProvider.Amplitudes => beatmap.TrackLoaded ? beatmap.Track.CurrentAmplitudes : (ChannelAmplitudes?)null; + IClock IBeatSyncProvider.Clock => this; - private class HardwareCorrectionOffsetClock : FramedOffsetClock - { - private readonly BindableDouble pauseRateAdjust; - - private double offset; - - public new double Offset - { - get => offset; - set - { - if (value == offset) - return; - - offset = value; - - updateOffset(); - } - } - - public double RateAdjustedOffset => base.Offset; - - public HardwareCorrectionOffsetClock(IClock source, BindableDouble pauseRateAdjust) - : base(source) - { - this.pauseRateAdjust = pauseRateAdjust; - } - - public override void ProcessFrame() - { - base.ProcessFrame(); - updateOffset(); - } - - private void updateOffset() - { - // changing this during the pause transform effect will cause a potentially large offset to be suddenly applied as we approach zero rate. - if (pauseRateAdjust.Value == 1) - { - // we always want to apply the same real-time offset, so it should be adjusted by the difference in playback rate (from realtime) to achieve this. - base.Offset = Offset * Rate; - } - } - } - - private class MasterGameplayClock : GameplayClock - { - public readonly List> MutableNonGameplayAdjustments = new List>(); - public override IEnumerable> NonGameplayAdjustments => MutableNonGameplayAdjustments; - - public MasterGameplayClock(FramedOffsetClock underlyingClock) - : base(underlyingClock) - { - } - } + ChannelAmplitudes IHasAmplitudes.CurrentAmplitudes => beatmap.TrackLoaded ? beatmap.Track.CurrentAmplitudes : ChannelAmplitudes.Empty; } } diff --git a/osu.Game/Screens/Play/OffsetCorrectionClock.cs b/osu.Game/Screens/Play/OffsetCorrectionClock.cs new file mode 100644 index 0000000000..207980f45c --- /dev/null +++ b/osu.Game/Screens/Play/OffsetCorrectionClock.cs @@ -0,0 +1,53 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Timing; + +namespace osu.Game.Screens.Play +{ + public class OffsetCorrectionClock : FramedOffsetClock + { + private readonly BindableDouble pauseRateAdjust; + + private double offset; + + public new double Offset + { + get => offset; + set + { + if (value == offset) + return; + + offset = value; + + updateOffset(); + } + } + + public double RateAdjustedOffset => base.Offset; + + public OffsetCorrectionClock(IClock source, BindableDouble pauseRateAdjust) + : base(source) + { + this.pauseRateAdjust = pauseRateAdjust; + } + + public override void ProcessFrame() + { + base.ProcessFrame(); + updateOffset(); + } + + private void updateOffset() + { + // changing this during the pause transform effect will cause a potentially large offset to be suddenly applied as we approach zero rate. + if (pauseRateAdjust.Value == 1) + { + // we always want to apply the same real-time offset, so it should be adjusted by the difference in playback rate (from realtime) to achieve this. + base.Offset = Offset * Rate; + } + } + } +} diff --git a/osu.Game/Screens/Play/PauseOverlay.cs b/osu.Game/Screens/Play/PauseOverlay.cs index e0b7e5c941..db42998c45 100644 --- a/osu.Game/Screens/Play/PauseOverlay.cs +++ b/osu.Game/Screens/Play/PauseOverlay.cs @@ -1,19 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Graphics; +using osu.Framework.Input.Events; using osu.Game.Audio; using osu.Game.Graphics; +using osu.Game.Input.Bindings; using osu.Game.Skinning; using osuTK.Graphics; namespace osu.Game.Screens.Play { - public class PauseOverlay : GameplayMenuOverlay + public partial class PauseOverlay : GameplayMenuOverlay { public Action OnResume; @@ -24,7 +28,7 @@ namespace osu.Game.Screens.Play private SkinnableSound pauseLoop; - protected override Action BackAction => () => InternalButtons.Children.First().TriggerClick(); + protected override Action BackAction => () => InternalButtons.First().TriggerClick(); [BackgroundDependencyLoader] private void load(OsuColour colours) @@ -40,6 +44,14 @@ namespace osu.Game.Screens.Play }); } + public void StopAllSamples() + { + if (!IsLoaded) + return; + + pauseLoop.Stop(); + } + protected override void PopIn() { base.PopIn(); @@ -54,5 +66,17 @@ namespace osu.Game.Screens.Play pauseLoop.VolumeTo(0, TRANSITION_DURATION, Easing.OutQuad).Finally(_ => pauseLoop.Stop()); } + + public override bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case GlobalAction.PauseGameplay: + InternalButtons.First().TriggerClick(); + return true; + } + + return base.OnPressed(e); + } } } diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index dfc0fa1d1d..5174adfc06 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -1,10 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; +using System.Diagnostics; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -19,10 +24,10 @@ using osu.Framework.Threading; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Graphics.Containers; using osu.Game.IO.Archives; using osu.Game.Online.API; -using osu.Game.Online.Spectator; using osu.Game.Overlays; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -30,6 +35,7 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Scoring; using osu.Game.Scoring.Legacy; +using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Ranking; using osu.Game.Skinning; using osu.Game.Users; @@ -38,7 +44,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Play { [Cached] - public abstract class Player : ScreenWithBeatmapBackground, ISamplePlaybackDisabler, ILocalUserPlayInfo + public abstract partial class Player : ScreenWithBeatmapBackground, ISamplePlaybackDisabler, ILocalUserPlayInfo { /// /// The delay upon completion of the beatmap before displaying the results screen. @@ -52,12 +58,16 @@ namespace osu.Game.Screens.Play public override bool AllowBackButton => false; // handled by HoldForMenuButton + protected override bool PlayExitSound => !isRestarting; + protected override UserActivity InitialActivity => new UserActivity.InSoloGame(Beatmap.Value.BeatmapInfo, Ruleset.Value); public override float BackgroundParallaxAmount => 0.1f; public override bool HideOverlaysOnEnter => true; + public override bool HideMenuCursorOnNonMouseInput => true; + protected override OverlayActivation InitialOverlayActivationMode => OverlayActivation.UserTriggered; // We are managing our own adjustments (see OnEntering/OnExiting). @@ -72,7 +82,9 @@ namespace osu.Game.Screens.Play /// protected virtual bool PauseOnFocusLost => true; - public Action RestartRequested; + public Action RestartRequested; + + private bool isRestarting; private Bindable mouseWheelDisabled; @@ -84,6 +96,11 @@ namespace osu.Game.Screens.Play public int RestartCount; + /// + /// Whether the is currently visible. + /// + public IBindable ShowingOverlayComponents = new Bindable(); + [Resolved] private ScoreManager scoreManager { get; set; } @@ -93,9 +110,6 @@ namespace osu.Game.Screens.Play [Resolved] private MusicController musicController { get; set; } - [Resolved] - private SpectatorClient spectatorClient { get; set; } - public GameplayState GameplayState { get; private set; } private Ruleset ruleset; @@ -166,7 +180,7 @@ namespace osu.Game.Screens.Play PrepareReplay(); - ScoreProcessor.NewJudgement += result => ScoreProcessor.PopulateScore(Score.ScoreInfo); + ScoreProcessor.NewJudgement += _ => ScoreProcessor.PopulateScore(Score.ScoreInfo); ScoreProcessor.OnResetFromReplayFrame += () => ScoreProcessor.PopulateScore(Score.ScoreInfo); gameActive.BindValueChanged(_ => updatePauseOnFocusLostState(), true); @@ -181,7 +195,7 @@ namespace osu.Game.Screens.Play } [BackgroundDependencyLoader(true)] - private void load(AudioManager audio, OsuConfigManager config, OsuGameBase game) + private void load(AudioManager audio, OsuConfigManager config, OsuGameBase game, CancellationToken cancellationToken) { var gameplayMods = Mods.Value.Select(m => m.DeepClone()).ToArray(); @@ -194,7 +208,7 @@ namespace osu.Game.Screens.Play if (Beatmap.Value is DummyWorkingBeatmap) return; - IBeatmap playableBeatmap = loadPlayableBeatmap(gameplayMods); + IBeatmap playableBeatmap = loadPlayableBeatmap(gameplayMods, cancellationToken); if (playableBeatmap == null) return; @@ -234,6 +248,7 @@ namespace osu.Game.Screens.Play // ensure the score is in a consistent state with the current player. Score.ScoreInfo.BeatmapInfo = Beatmap.Value.BeatmapInfo; + Score.ScoreInfo.BeatmapHash = Beatmap.Value.BeatmapInfo.Hash; Score.ScoreInfo.Ruleset = ruleset.RulesetInfo; Score.ScoreInfo.Mods = gameplayMods; @@ -245,6 +260,9 @@ namespace osu.Game.Screens.Play // this is intentionally done in two stages to ensure things are in a loaded state before exposing the ruleset to skin sources. GameplayClockContainer.Add(rulesetSkinProvider); + if (cancellationToken.IsCancellationRequested) + return; + rulesetSkinProvider.AddRange(new Drawable[] { failAnimationLayer = new FailAnimation(DrawableRuleset) @@ -259,7 +277,8 @@ namespace osu.Game.Screens.Play }, FailOverlay = new FailOverlay { - OnRetry = Restart, + SaveReplay = async () => await prepareAndImportScoreAsync(true).ConfigureAwait(false), + OnRetry = () => Restart(), OnQuit = () => PerformExit(true), }, new HotkeyExitOverlay @@ -274,6 +293,9 @@ namespace osu.Game.Screens.Play }, }); + if (cancellationToken.IsCancellationRequested) + return; + if (Configuration.AllowRestart) { rulesetSkinProvider.Add(new HotkeyRetryOverlay @@ -283,11 +305,13 @@ namespace osu.Game.Screens.Play if (!this.IsCurrentScreen()) return; fadeOut(true); - Restart(); + Restart(true); }, }); } + dependencies.CacheAs(DrawableRuleset.FrameStableClock); + // add the overlay components as a separate step as they proxy some elements from the above underlay/gameplay components. // also give the overlays the ruleset skin provider to allow rulesets to potentially override HUD elements (used to disable combo counters etc.) // we may want to limit this in the future to disallow rulesets from outright replacing elements the user expects to be there. @@ -308,7 +332,7 @@ namespace osu.Game.Screens.Play GameplayClockContainer.Start(); }); - DrawableRuleset.IsPaused.BindValueChanged(paused => + DrawableRuleset.IsPaused.BindValueChanged(_ => { updateGameplayState(); updateSampleDisabledState(); @@ -319,7 +343,7 @@ namespace osu.Game.Screens.Play DrawableRuleset.HasReplayLoaded.BindValueChanged(_ => updateGameplayState()); // bind clock into components that require it - DrawableRuleset.IsPaused.BindTo(GameplayClockContainer.IsPaused); + ((IBindable)DrawableRuleset.IsPaused).BindTo(GameplayClockContainer.IsPaused); DrawableRuleset.NewResult += r => { @@ -334,14 +358,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, @@ -360,6 +380,11 @@ namespace osu.Game.Screens.Play IsBreakTime.BindTo(breakTracker.IsBreakTime); IsBreakTime.BindValueChanged(onBreakTimeChanged, true); + + if (Configuration.AutomaticallySkipIntro) + skipIntroOverlay.SkipWhenReady(); + + loadLeaderboard(); } protected virtual GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new MasterGameplayClockContainer(beatmap, gameplayStart); @@ -402,17 +427,21 @@ namespace osu.Game.Screens.Play // display the cursor above some HUD elements. DrawableRuleset.Cursor?.CreateProxy() ?? new Container(), DrawableRuleset.ResumeOverlay?.CreateProxy() ?? new Container(), - HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods) + HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods, Configuration.AlwaysShowLeaderboard) { HoldToQuit = { Action = () => PerformExit(true), - IsPaused = { BindTarget = GameplayClockContainer.IsPaused } + IsPaused = { BindTarget = GameplayClockContainer.IsPaused }, + ReplayLoaded = { BindTarget = DrawableRuleset.HasReplayLoaded }, }, KeyCounter = { + IsCounting = + { + Value = false + }, AlwaysVisible = { BindTarget = DrawableRuleset.HasReplayLoaded }, - IsCounting = false }, Anchor = Anchor.Centre, Origin = Anchor.Centre @@ -430,7 +459,7 @@ namespace osu.Game.Screens.Play { OnResume = Resume, Retries = RestartCount, - OnRetry = Restart, + OnRetry = () => Restart(), OnQuit = () => PerformExit(true), }, }, @@ -452,7 +481,7 @@ namespace osu.Game.Screens.Play { updateGameplayState(); updatePauseOnFocusLostState(); - HUDOverlay.KeyCounter.IsCounting = !isBreakTime.NewValue; + HUDOverlay.KeyCounter.IsCounting.Value = !isBreakTime.NewValue; } private void updateGameplayState() @@ -464,7 +493,7 @@ namespace osu.Game.Screens.Play private void updateSampleDisabledState() { - samplePlaybackDisabled.Value = DrawableRuleset.FrameStableClock.IsCatchingUp.Value || GameplayClockContainer.GameplayClock.IsPaused.Value; + samplePlaybackDisabled.Value = DrawableRuleset.FrameStableClock.IsCatchingUp.Value || GameplayClockContainer.IsPaused.Value; } private void updatePauseOnFocusLostState() @@ -483,7 +512,7 @@ namespace osu.Game.Screens.Play } } - private IBeatmap loadPlayableBeatmap(Mod[] gameplayMods) + private IBeatmap loadPlayableBeatmap(Mod[] gameplayMods, CancellationToken cancellationToken) { IBeatmap playable; @@ -500,7 +529,7 @@ namespace osu.Game.Screens.Play try { - playable = Beatmap.Value.GetPlayableBeatmap(ruleset.RulesetInfo, gameplayMods); + playable = Beatmap.Value.GetPlayableBeatmap(ruleset.RulesetInfo, gameplayMods, cancellationToken); } catch (BeatmapInvalidForRulesetException) { @@ -508,7 +537,7 @@ namespace osu.Game.Screens.Play rulesetInfo = Beatmap.Value.BeatmapInfo.Ruleset; ruleset = rulesetInfo.CreateInstance(); - playable = Beatmap.Value.GetPlayableBeatmap(rulesetInfo, gameplayMods); + playable = Beatmap.Value.GetPlayableBeatmap(rulesetInfo, gameplayMods, cancellationToken); } if (playable.HitObjects.Count == 0) @@ -517,6 +546,11 @@ namespace osu.Game.Screens.Play return null; } } + catch (OperationCanceledException) + { + // Load has been cancelled. No logging is required. + return null; + } catch (Exception e) { Logger.Error(e, "Could not load beatmap successfully!"); @@ -542,9 +576,6 @@ namespace osu.Game.Screens.Play /// protected void PerformExit(bool showDialogFirst) { - // if an exit has been requested, cancel any pending completion (the user has shown intention to exit). - resultsDisplayDelegate?.Cancel(); - // there is a chance that an exit request occurs after the transition to results has already started. // even in such a case, the user has shown intent, so forcefully return to this screen (to proceed with the upwards exit process). if (!this.IsCurrentScreen()) @@ -579,6 +610,12 @@ namespace osu.Game.Screens.Play } } + // if an exit has been requested, cancel any pending completion (the user has shown intention to exit). + resultsDisplayDelegate?.Cancel(); + + // import current score if possible. + prepareAndImportScoreAsync(); + // The actual exit is performed if // - the pause / fail dialog was not requested // - the pause / fail dialog was requested but is already displayed (user showing intention to exit). @@ -621,8 +658,7 @@ namespace osu.Game.Screens.Play bool wasFrameStable = DrawableRuleset.FrameStablePlayback; DrawableRuleset.FrameStablePlayback = false; - GameplayClockContainer.StartTime = time; - GameplayClockContainer.Reset(); + GameplayClockContainer.Reset(time); // Delay resetting frame-stable playback for one frame to give the FrameStabilityContainer a chance to seek. frameStablePlaybackResetDelegate = ScheduleAfterChildren(() => DrawableRuleset.FrameStablePlayback = wasFrameStable); @@ -632,17 +668,20 @@ namespace osu.Game.Screens.Play /// Restart gameplay via a parent . /// This can be called from a child screen in order to trigger the restart process. /// - public void Restart() + /// Whether a quick restart was requested (skipping intro etc.). + public void Restart(bool quickRestart = false) { if (!Configuration.AllowRestart) return; + isRestarting = true; + // at the point of restarting the track should either already be paused or the volume should be zero. // stopping here is to ensure music doesn't become audible after exiting back to PlayerLoader. musicController.Stop(); sampleRestart?.Play(); - RestartRequested?.Invoke(); + RestartRequested?.Invoke(quickRestart); PerformExit(false); } @@ -666,19 +705,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; @@ -699,20 +739,15 @@ namespace osu.Game.Screens.Play // is no chance that a user could return to the (already completed) Player instance from a child screen. ValidForResume = false; - // Ensure we are not writing to the replay any more, as we are about to consume and store the score. - DrawableRuleset.SetRecordTarget(null); - if (!Configuration.ShowResults) return; - prepareScoreForDisplayTask ??= Task.Run(prepareScoreForResults); + bool storyboardStillRunning = DimmableStoryboard.ContentDisplayed && !DimmableStoryboard.HasStoryboardEnded.Value; - bool storyboardHasOutro = 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; } @@ -720,74 +755,97 @@ namespace osu.Game.Screens.Play progressToResults(true); } - /// - /// Asynchronously run score preparation operations (database import, online submission etc.). - /// - /// The final score. - private async Task prepareScoreForResults() - { - var scoreCopy = Score.DeepClone(); - - try - { - await PrepareScoreForResultsAsync(scoreCopy).ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.Error(ex, @"Score preparation failed!"); - } - - try - { - await ImportScore(scoreCopy).ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.Error(ex, @"Score import failed!"); - } - - return scoreCopy.ScoreInfo; - } - /// /// Queue the results screen for display. /// /// /// A final display will only occur once all work is completed in . This means that even after calling this method, the results screen will never be shown until ScoreProcessor.HasCompleted becomes . - /// - /// Calling this method multiple times will have no effect. /// /// Whether a minimum delay () should be added before the screen is displayed. private void progressToResults(bool withDelay) { - if (resultsDisplayDelegate != null) - // Note that if progressToResults is called one withDelay=true and then withDelay=false, this no-delay timing will not be - // accounted for. shouldn't be a huge concern (a user pressing the skip button after a results progression has already been queued - // may take x00 more milliseconds than expected in the very rare edge case). - // - // If required we can handle this more correctly by rescheduling here. - return; + resultsDisplayDelegate?.Cancel(); double delay = withDelay ? RESULTS_DISPLAY_DELAY : 0; resultsDisplayDelegate = new ScheduledDelegate(() => { - if (prepareScoreForDisplayTask?.IsCompleted != true) + if (prepareScoreForDisplayTask == null) + { + // Try importing score since the task hasn't been invoked yet. + prepareAndImportScoreAsync(); + return; + } + + if (!prepareScoreForDisplayTask.IsCompleted) // If the asynchronous preparation has not completed, keep repeating this delegate. return; resultsDisplayDelegate?.Cancel(); + if (prepareScoreForDisplayTask.GetResultSafely() == null) + { + // If score import did not occur, we do not want to show the results screen. + return; + } + if (!this.IsCurrentScreen()) // 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); Scheduler.Add(resultsDisplayDelegate); } + /// + /// Asynchronously run score preparation operations (database import, online submission etc.). + /// + /// Whether the score should be imported even if non-passing (or the current configuration doesn't allow for it). + /// The final score. + [ItemCanBeNull] + private Task prepareAndImportScoreAsync(bool forceImport = false) + { + // Ensure we are not writing to the replay any more, as we are about to consume and store the score. + DrawableRuleset.SetRecordTarget(null); + + if (prepareScoreForDisplayTask != null) + return prepareScoreForDisplayTask; + + // We do not want to import the score in cases where we don't show results + bool canShowResults = Configuration.ShowResults && ScoreProcessor.HasCompleted.Value && GameplayState.HasPassed; + if (!canShowResults && !forceImport) + return Task.FromResult(null); + + return prepareScoreForDisplayTask = Task.Run(async () => + { + var scoreCopy = Score.DeepClone(); + + try + { + await PrepareScoreForResultsAsync(scoreCopy).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Error(ex, @"Score preparation failed!"); + } + + try + { + await ImportScore(scoreCopy).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Error(ex, @"Score import failed!"); + } + + return scoreCopy.ScoreInfo; + }); + } + protected override bool OnScroll(ScrollEvent e) { // During pause, allow global volume adjust regardless of settings. @@ -798,6 +856,41 @@ namespace osu.Game.Screens.Play return mouseWheelDisabled.Value && !e.AltPressed; } + #region Gameplay leaderboard + + protected readonly Bindable LeaderboardExpandedState = new BindableBool(); + + private void loadLeaderboard() + { + HUDOverlay.HoldingForHUD.BindValueChanged(_ => updateLeaderboardExpandedState()); + LocalUserPlaying.BindValueChanged(_ => updateLeaderboardExpandedState(), true); + + var gameplayLeaderboard = CreateGameplayLeaderboard(); + + if (gameplayLeaderboard != null) + { + LoadComponentAsync(gameplayLeaderboard, leaderboard => + { + if (!LoadedBeatmapSuccessfully) + return; + + leaderboard.Expanded.BindTo(LeaderboardExpandedState); + + AddLeaderboardToHUD(leaderboard); + }); + } + } + + [CanBeNull] + protected virtual GameplayLeaderboard CreateGameplayLeaderboard() => null; + + protected virtual void AddLeaderboardToHUD(GameplayLeaderboard leaderboard) => HUDOverlay.LeaderboardFlow.Add(leaderboard); + + private void updateLeaderboardExpandedState() => + LeaderboardExpandedState.Value = !LocalUserPlaying.Value || HUDOverlay.HoldingForHUD.Value; + + #endregion + #region Fail Logic protected FailOverlay FailOverlay { get; private set; } @@ -806,11 +899,18 @@ namespace osu.Game.Screens.Play private bool onFail() { + // Failing after the quit sequence has started may cause weird side effects with the fail animation / effects. + if (GameplayState.HasQuit) + return false; + if (!CheckModsAllowFailure()) return false; + Debug.Assert(!GameplayState.HasFailed); + Debug.Assert(!GameplayState.HasPassed); + Debug.Assert(!GameplayState.HasQuit); + GameplayState.HasFailed = true; - Score.ScoreInfo.Passed = false; updateGameplayState(); @@ -823,14 +923,21 @@ namespace osu.Game.Screens.Play failAnimationLayer.Start(); if (GameplayState.Mods.OfType().Any(m => m.RestartOnFail)) - Restart(); + Restart(true); return true; } - // Called back when the transform finishes + /// + /// Invoked when the fail animation has finished. + /// private void onFailComplete() { + // fail completion is a good point to mark a score as failed, + // since the last judgement that caused the fail only applies to score processor after onFail. + // todo: this should probably be handled better. + ScoreProcessor.FailScore(Score.ScoreInfo); + GameplayClockContainer.Stop(); FailOverlay.Retries = RestartCount; @@ -853,7 +960,7 @@ namespace osu.Game.Screens.Play private double? lastPauseActionTime; protected bool PauseCooldownActive => - lastPauseActionTime.HasValue && GameplayClockContainer.GameplayClock.CurrentTime < lastPauseActionTime + pause_cooldown; + lastPauseActionTime.HasValue && GameplayClockContainer.CurrentTime < lastPauseActionTime + pause_cooldown; /// /// A set of conditionals which defines whether the current game state and configuration allows for @@ -891,7 +998,7 @@ namespace osu.Game.Screens.Play GameplayClockContainer.Stop(); PauseOverlay.Show(); - lastPauseActionTime = GameplayClockContainer.GameplayClock.CurrentTime; + lastPauseActionTime = GameplayClockContainer.CurrentTime; return true; } @@ -948,6 +1055,8 @@ namespace osu.Game.Screens.Play }); HUDOverlay.IsPlaying.BindTo(localUserPlaying); + ShowingOverlayComponents.BindTo(HUDOverlay.ShowHud); + DimmableStoryboard.IsBreakTime.BindTo(breakTracker.IsBreakTime); DimmableStoryboard.StoryboardReplacesBackground.BindTo(storyboardReplacesBackground); @@ -960,12 +1069,8 @@ namespace osu.Game.Screens.Play foreach (var mod in GameplayState.Mods.OfType()) mod.ApplyToHUD(HUDOverlay); - // Our mods are local copies of the global mods so they need to be re-applied to the track. - // This is done through the music controller (for now), because resetting speed adjustments on the beatmap track also removes adjustments provided by DrawableTrack. - // Todo: In the future, player will receive in a track and will probably not have to worry about this... - musicController.ResetTrackAdjustments(); foreach (var mod in GameplayState.Mods.OfType()) - mod.ApplyToTrack(musicController.CurrentTrack); + mod.ApplyToTrack(GameplayClockContainer.AdjustmentsFromMods); updateGameplayState(); @@ -981,10 +1086,10 @@ namespace osu.Game.Screens.Play /// protected virtual void StartGameplay() { - if (GameplayClockContainer.GameplayClock.IsRunning) + if (GameplayClockContainer.IsRunning) throw new InvalidOperationException($"{nameof(StartGameplay)} should not be called when the gameplay clock is already running"); - GameplayClockContainer.Reset(true); + GameplayClockContainer.Reset(startClock: true); } public override void OnSuspending(ScreenTransitionEvent e) @@ -998,7 +1103,10 @@ namespace osu.Game.Screens.Play public override bool OnExiting(ScreenExitEvent e) { screenSuspension?.RemoveAndDisposeImmediately(); - failAnimationLayer?.RemoveFilters(); + + // Eagerly clean these up as disposal of child components is asynchronous and may leave sounds playing beyond user expectations. + failAnimationLayer?.Stop(); + PauseOverlay?.StopAllSamples(); if (LoadedBeatmapSuccessfully) { @@ -1006,17 +1114,8 @@ 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) - { - Score.ScoreInfo.Passed = false; - // potentially should be ScoreRank.F instead? this is the best alternative for now. - Score.ScoreInfo.Rank = ScoreRank.D; - } - - // EndPlaying() is typically called from ReplayRecorder.Dispose(). Disposal is currently asynchronous. - // To resolve test failures, forcefully end playing synchronously when this screen exits. - // Todo: Replace this with a more permanent solution once osu-framework has a synchronous cleanup method. - spectatorClient.EndPlaying(GameplayState); + if (prepareScoreForDisplayTask == null && DrawableRuleset.ReplayScore == null) + ScoreProcessor.FailScore(Score.ScoreInfo); } // GameplayClockContainer performs seeks / start / stop operations on the beatmap's track. @@ -1026,6 +1125,7 @@ namespace osu.Game.Screens.Play musicController.ResetTrackAdjustments(); fadeOut(); + return base.OnExiting(e); } @@ -1050,12 +1150,15 @@ namespace osu.Game.Screens.Play if (DrawableRuleset.ReplayScore != null) return Task.CompletedTask; - LegacyByteArrayReader replayReader; + LegacyByteArrayReader replayReader = null; - using (var stream = new MemoryStream()) + if (score.ScoreInfo.Ruleset.IsLegacyRuleset()) { - new LegacyScoreEncoder(score, GameplayState.Beatmap).Encode(stream); - replayReader = new LegacyByteArrayReader(stream.ToArray(), "replay.osr"); + using (var stream = new MemoryStream()) + { + new LegacyScoreEncoder(score, GameplayState.Beatmap).Encode(stream); + replayReader = new LegacyByteArrayReader(stream.ToArray(), "replay.osr"); + } } // the import process will re-attach managed beatmap/rulesets to this score. we don't want this for now, so create a temporary copy to import. @@ -1092,7 +1195,10 @@ namespace osu.Game.Screens.Play /// /// The to be displayed in the results screen. /// The . - protected virtual ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score, true); + protected virtual ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score, true) + { + ShowUserStatistics = true + }; private void fadeOut(bool instant = false) { diff --git a/osu.Game/Screens/Play/PlayerConfiguration.cs b/osu.Game/Screens/Play/PlayerConfiguration.cs index 3aa424e5d5..b82925ccb8 100644 --- a/osu.Game/Screens/Play/PlayerConfiguration.cs +++ b/osu.Game/Screens/Play/PlayerConfiguration.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Screens.Play { public class PlayerConfiguration @@ -29,5 +31,15 @@ namespace osu.Game.Screens.Play /// Whether the player should be allowed to skip intros/outros, advancing to the start of gameplay or the end of a storyboard. /// public bool AllowSkipping { get; set; } = true; + + /// + /// Whether the intro should be skipped by default. + /// + public bool AutomaticallySkipIntro { get; set; } + + /// + /// Whether the gameplay leaderboard should always be shown (usually in a contracted state). + /// + public bool AlwaysShowLeaderboard { get; set; } } } diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index d75466764d..30ae5ee5aa 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.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 enable - using System; using System.Diagnostics; using System.Threading.Tasks; @@ -22,6 +20,7 @@ using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Input; +using osu.Game.Localisation; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using osu.Game.Screens.Menu; @@ -33,7 +32,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Play { - public class PlayerLoader : ScreenWithBeatmapBackground + public partial class PlayerLoader : ScreenWithBeatmapBackground { protected const float BACKGROUND_BLUR = 15; @@ -51,8 +50,6 @@ namespace osu.Game.Screens.Play // We show the previous screen status protected override UserActivity? InitialActivity => null; - protected override bool PlayResumeSound => false; - protected BeatmapMetadataDisplay MetadataInfo { get; private set; } = null!; /// @@ -68,6 +65,10 @@ namespace osu.Game.Screens.Play protected Task? DisposalTask { get; private set; } + private OsuScrollContainer settingsScroll = null!; + + private Bindable showStoryboards = null!; + private bool backgroundBrightnessReduction; private readonly BindableDouble volumeAdjustment = new BindableDouble(1); @@ -75,6 +76,9 @@ namespace osu.Game.Screens.Play private AudioFilter lowPassFilter = null!; private AudioFilter highPassFilter = null!; + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + protected bool BackgroundBrightnessReduction { set @@ -127,16 +131,18 @@ namespace osu.Game.Screens.Play private EpilepsyWarning? epilepsyWarning; - [Resolved(CanBeNull = true)] + private bool quickRestart; + + [Resolved] private INotificationOverlay? notificationOverlay { get; set; } - [Resolved(CanBeNull = true)] + [Resolved] private VolumeOverlay? volumeOverlay { get; set; } [Resolved] private AudioManager audioManager { get; set; } = null!; - [Resolved(CanBeNull = true)] + [Resolved] private BatteryInfo? batteryInfo { get; set; } public PlayerLoader(Func createPlayer) @@ -145,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; @@ -167,30 +174,30 @@ namespace osu.Game.Screens.Play Anchor = Anchor.Centre, Origin = Anchor.Centre, }, - new OsuScrollContainer - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - RelativeSizeAxes = Axes.Y, - Width = SettingsToolboxGroup.CONTAINER_WIDTH + padding * 2, - Padding = new MarginPadding { Vertical = padding }, - Masking = false, - Child = PlayerSettings = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 20), - Padding = new MarginPadding { Horizontal = padding }, - Children = new PlayerSettingsGroup[] - { - VisualSettings = new VisualSettings(), - AudioSettings = new AudioSettings(), - new InputSettings() - } - }, - }, - idleTracker = new IdleTracker(750), }), + settingsScroll = new OsuScrollContainer + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.Y, + Width = SettingsToolboxGroup.CONTAINER_WIDTH + padding * 2, + Padding = new MarginPadding { Vertical = padding }, + Masking = false, + Child = PlayerSettings = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Padding = new MarginPadding { Horizontal = padding }, + Children = new PlayerSettingsGroup[] + { + VisualSettings = new VisualSettings(), + AudioSettings = new AudioSettings(), + new InputSettings() + } + }, + }, + idleTracker = new IdleTracker(750), lowPassFilter = new AudioFilter(audio.TrackMixer), highPassFilter = new AudioFilter(audio.TrackMixer, BQFType.HighPass) }; @@ -226,6 +233,9 @@ namespace osu.Game.Screens.Play Beatmap.Value.Track.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment); + // Start off-screen. + settingsScroll.MoveToX(settingsScroll.DrawWidth); + content.ScaleTo(0.7f); contentIn(); @@ -315,6 +325,16 @@ namespace osu.Game.Screens.Play content.StopTracking(); } + protected override void LogoSuspending(OsuLogo logo) + { + base.LogoSuspending(logo); + content.StopTracking(); + + logo + .FadeOut(CONTENT_OUT_DURATION / 2, Easing.OutQuint) + .ScaleTo(logo.Scale * 0.8f, CONTENT_OUT_DURATION * 2, Easing.OutQuint); + } + #endregion protected override void Update() @@ -365,6 +385,7 @@ namespace osu.Game.Screens.Play return; CurrentPlayer = createPlayer(); + CurrentPlayer.Configuration.AutomaticallySkipIntro |= quickRestart; CurrentPlayer.RestartCount = restartCount++; CurrentPlayer.RestartRequested = restartRequested; @@ -379,8 +400,9 @@ namespace osu.Game.Screens.Play { } - private void restartRequested() + private void restartRequested(bool quickRestartRequested) { + quickRestart = quickRestartRequested; hideOverlays = true; ValidForResume = true; } @@ -391,10 +413,14 @@ namespace osu.Game.Screens.Play content.FadeInFromZero(400); content.ScaleTo(1, 650, Easing.OutQuint).Then().Schedule(prepareNewPlayer); + + settingsScroll.FadeInFromZero(500, Easing.Out) + .MoveToX(0, 500, Easing.OutQuint); + lowPassFilter.CutoffTo(1000, 650, Easing.OutQuint); highPassFilter.CutoffTo(300).Then().CutoffTo(0, 1250); // 1250 is to line up with the appearance of MetadataInfo (750 delay + 500 fade-in) - ApplyToBackground(b => b?.FadeColour(Color4.White, 800, Easing.OutQuint)); + ApplyToBackground(b => b.FadeColour(Color4.White, 800, Easing.OutQuint)); } protected virtual void ContentOut() @@ -404,6 +430,10 @@ namespace osu.Game.Screens.Play content.ScaleTo(0.7f, CONTENT_OUT_DURATION * 2, Easing.OutQuint); content.FadeOut(CONTENT_OUT_DURATION, Easing.OutQuint); + + settingsScroll.FadeOut(CONTENT_OUT_DURATION, Easing.OutQuint) + .MoveToX(settingsScroll.DrawWidth, CONTENT_OUT_DURATION * 2, Easing.OutQuint); + lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, CONTENT_OUT_DURATION); highPassFilter.CutoffTo(0, CONTENT_OUT_DURATION); } @@ -432,15 +462,19 @@ namespace osu.Game.Screens.Play ContentOut(); - TransformSequence pushSequence = this.Delay(CONTENT_OUT_DURATION); + TransformSequence pushSequence = this.Delay(0); // 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; pushSequence + .Delay(CONTENT_OUT_DURATION) .Schedule(() => epilepsyWarning.State.Value = Visibility.Visible) .TransformBindableTo(volumeAdjustment, 0.25, EpilepsyWarning.FADE_DURATION, Easing.OutQuint) .Delay(epilepsy_display_length) @@ -455,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(() => @@ -502,7 +537,7 @@ namespace osu.Game.Screens.Play private int restartCount; - private const double volume_requirement = 0.05; + private const double volume_requirement = 0.01; private void showMuteWarningIfNeeded() { @@ -517,20 +552,20 @@ namespace osu.Game.Screens.Play } } - private class MutedNotification : SimpleNotification + private partial class MutedNotification : SimpleNotification { public override bool IsImportant => true; public MutedNotification() { - Text = "Your game volume is too low to hear anything! Click here to restore it."; + Text = NotificationsStrings.GameVolumeTooLow; } [BackgroundDependencyLoader] private void load(OsuColour colours, AudioManager audioManager, INotificationOverlay notificationOverlay, VolumeOverlay volumeOverlay) { Icon = FontAwesome.Solid.VolumeMute; - IconBackground.Colour = colours.RedDark; + IconContent.Colour = colours.RedDark; Activated = delegate { @@ -539,10 +574,11 @@ namespace osu.Game.Screens.Play volumeOverlay.IsMuted.Value = false; // Check values before resetting, as the user may have only had mute enabled, in which case we might not need to adjust volumes. + // Note that we only restore halfway to ensure the user isn't suddenly overloaded by unexpectedly high volume. if (audioManager.Volume.Value <= volume_requirement) - audioManager.Volume.SetDefault(); + audioManager.Volume.Value = 0.5f; if (audioManager.VolumeTrack.Value <= volume_requirement) - audioManager.VolumeTrack.SetDefault(); + audioManager.VolumeTrack.Value = 0.5f; return true; }; @@ -553,6 +589,8 @@ namespace osu.Game.Screens.Play #region Low battery warning + private const double low_battery_threshold = 0.25; + private Bindable batteryWarningShownOnce = null!; private void showBatteryWarningIfNeeded() @@ -561,7 +599,7 @@ namespace osu.Game.Screens.Play if (!batteryWarningShownOnce.Value) { - if (!batteryInfo.IsCharging && batteryInfo.ChargeLevel <= 0.25) + if (batteryInfo.OnBattery && batteryInfo.ChargeLevel <= low_battery_threshold) { notificationOverlay?.Post(new BatteryWarningNotification()); batteryWarningShownOnce.Value = true; @@ -569,20 +607,20 @@ namespace osu.Game.Screens.Play } } - private class BatteryWarningNotification : SimpleNotification + private partial class BatteryWarningNotification : SimpleNotification { public override bool IsImportant => true; public BatteryWarningNotification() { - Text = "Your battery level is low! Charge your device to prevent interruptions during gameplay."; + Text = NotificationsStrings.BatteryLow; } [BackgroundDependencyLoader] private void load(OsuColour colours, INotificationOverlay notificationOverlay) { Icon = FontAwesome.Solid.BatteryQuarter; - IconBackground.Colour = colours.RedDark; + IconContent.Colour = colours.RedDark; Activated = delegate { diff --git a/osu.Game/Screens/Play/PlayerSettings/AudioSettings.cs b/osu.Game/Screens/Play/PlayerSettings/AudioSettings.cs index 90caf6f0f3..010d8115fa 100644 --- a/osu.Game/Screens/Play/PlayerSettings/AudioSettings.cs +++ b/osu.Game/Screens/Play/PlayerSettings/AudioSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osu.Game.Scoring; namespace osu.Game.Screens.Play.PlayerSettings { - public class AudioSettings : PlayerSettingsGroup + public partial class AudioSettings : PlayerSettingsGroup { public Bindable ReferenceScore { get; } = new Bindable(); diff --git a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs index 1662ca399f..9492614b66 100644 --- a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs +++ b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.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 enable - using System; using System.Diagnostics; using System.Linq; @@ -27,14 +25,12 @@ using osuTK; namespace osu.Game.Screens.Play.PlayerSettings { - public class BeatmapOffsetControl : CompositeDrawable + public partial class BeatmapOffsetControl : CompositeDrawable { - public Bindable ReferenceScore { get; } = new Bindable(); + public Bindable ReferenceScore { get; } = new Bindable(); public BindableDouble Current { get; } = new BindableDouble { - Default = 0, - Value = 0, MinValue = -50, MaxValue = 50, Precision = 0.1, @@ -91,11 +87,11 @@ namespace osu.Game.Screens.Play.PlayerSettings }; } - public class OffsetSliderBar : PlayerSliderBar + public partial class OffsetSliderBar : PlayerSliderBar { protected override Drawable CreateControl() => new CustomSliderBar(); - protected class CustomSliderBar : SliderBar + protected partial class CustomSliderBar : SliderBar { public override LocalisableString TooltipText => Current.Value == 0 @@ -180,7 +176,7 @@ namespace osu.Game.Screens.Play.PlayerSettings } } - private void scoreChanged(ValueChangedEvent score) + private void scoreChanged(ValueChangedEvent score) { referenceScoreContainer.Clear(); diff --git a/osu.Game/Screens/Play/PlayerSettings/DiscussionSettings.cs b/osu.Game/Screens/Play/PlayerSettings/DiscussionSettings.cs index ac040774ee..7c76936621 100644 --- a/osu.Game/Screens/Play/PlayerSettings/DiscussionSettings.cs +++ b/osu.Game/Screens/Play/PlayerSettings/DiscussionSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Configuration; @@ -8,7 +10,7 @@ using osu.Game.Graphics.UserInterface; namespace osu.Game.Screens.Play.PlayerSettings { - public class DiscussionSettings : PlayerSettingsGroup + public partial class DiscussionSettings : PlayerSettingsGroup { public DiscussionSettings() : base("discussions") diff --git a/osu.Game/Screens/Play/PlayerSettings/InputSettings.cs b/osu.Game/Screens/Play/PlayerSettings/InputSettings.cs index b1063966da..13e5b66a70 100644 --- a/osu.Game/Screens/Play/PlayerSettings/InputSettings.cs +++ b/osu.Game/Screens/Play/PlayerSettings/InputSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Configuration; @@ -8,7 +10,7 @@ using osu.Game.Localisation; namespace osu.Game.Screens.Play.PlayerSettings { - public class InputSettings : PlayerSettingsGroup + public partial class InputSettings : PlayerSettingsGroup { private readonly PlayerCheckbox mouseButtonsCheckbox; diff --git a/osu.Game/Screens/Play/PlayerSettings/PlaybackSettings.cs b/osu.Game/Screens/Play/PlayerSettings/PlaybackSettings.cs index 16e29ac3c8..cb6fcb2413 100644 --- a/osu.Game/Screens/Play/PlayerSettings/PlaybackSettings.cs +++ b/osu.Game/Screens/Play/PlayerSettings/PlaybackSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -9,13 +11,12 @@ using osu.Game.Graphics.Sprites; namespace osu.Game.Screens.Play.PlayerSettings { - public class PlaybackSettings : PlayerSettingsGroup + public partial class PlaybackSettings : PlayerSettingsGroup { private const int padding = 10; public readonly Bindable UserPlaybackRate = new BindableDouble(1) { - Default = 1, MinValue = 0.5, MaxValue = 2, Precision = 0.1, diff --git a/osu.Game/Screens/Play/PlayerSettings/PlayerCheckbox.cs b/osu.Game/Screens/Play/PlayerSettings/PlayerCheckbox.cs index 26887327cd..49c9cbf385 100644 --- a/osu.Game/Screens/Play/PlayerSettings/PlayerCheckbox.cs +++ b/osu.Game/Screens/Play/PlayerSettings/PlayerCheckbox.cs @@ -2,19 +2,26 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.Settings; namespace osu.Game.Screens.Play.PlayerSettings { - public class PlayerCheckbox : OsuCheckbox + public partial class PlayerCheckbox : SettingsCheckbox { - [BackgroundDependencyLoader] - private void load(OsuColour colours) + protected override Drawable CreateControl() => new PlayerCheckboxControl(); + + public partial class PlayerCheckboxControl : OsuCheckbox { - Nub.AccentColour = colours.Yellow; - Nub.GlowingAccentColour = colours.YellowLighter; - Nub.GlowColour = colours.YellowDark; + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Nub.AccentColour = colours.Yellow; + Nub.GlowingAccentColour = colours.YellowLighter; + Nub.GlowColour = colours.YellowDark; + } } } } diff --git a/osu.Game/Screens/Play/PlayerSettings/PlayerSettingsGroup.cs b/osu.Game/Screens/Play/PlayerSettings/PlayerSettingsGroup.cs index 0bbe6902f4..838106e198 100644 --- a/osu.Game/Screens/Play/PlayerSettings/PlayerSettingsGroup.cs +++ b/osu.Game/Screens/Play/PlayerSettings/PlayerSettingsGroup.cs @@ -6,7 +6,7 @@ using osu.Game.Overlays; namespace osu.Game.Screens.Play.PlayerSettings { - public class PlayerSettingsGroup : SettingsToolboxGroup + public partial class PlayerSettingsGroup : SettingsToolboxGroup { public PlayerSettingsGroup(string title) : base(title) diff --git a/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs b/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs index 3f1a5bc0ac..45009684a6 100644 --- a/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs +++ b/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -10,14 +12,14 @@ using osu.Game.Overlays.Settings; namespace osu.Game.Screens.Play.PlayerSettings { - public class PlayerSliderBar : SettingsSlider + public partial class PlayerSliderBar : SettingsSlider where T : struct, IEquatable, IComparable, IConvertible { - public OsuSliderBar Bar => (OsuSliderBar)Control; + public RoundedSliderBar Bar => (RoundedSliderBar)Control; protected override Drawable CreateControl() => new SliderBar(); - protected class SliderBar : OsuSliderBar + protected partial class SliderBar : RoundedSliderBar { public SliderBar() { diff --git a/osu.Game/Screens/Play/PlayerSettings/VisualSettings.cs b/osu.Game/Screens/Play/PlayerSettings/VisualSettings.cs index a999b32cb4..ff857ddb12 100644 --- a/osu.Game/Screens/Play/PlayerSettings/VisualSettings.cs +++ b/osu.Game/Screens/Play/PlayerSettings/VisualSettings.cs @@ -4,15 +4,15 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Configuration; -using osu.Game.Graphics.Sprites; using osu.Game.Localisation; namespace osu.Game.Screens.Play.PlayerSettings { - public class VisualSettings : PlayerSettingsGroup + public partial class VisualSettings : PlayerSettingsGroup { private readonly PlayerSliderBar dimSliderBar; private readonly PlayerSliderBar blurSliderBar; + private readonly PlayerSliderBar comboColourNormalisationSliderBar; private readonly PlayerCheckbox showStoryboardToggle; private readonly PlayerCheckbox beatmapSkinsToggle; private readonly PlayerCheckbox beatmapColorsToggle; @@ -22,29 +22,24 @@ namespace osu.Game.Screens.Play.PlayerSettings { Children = new Drawable[] { - new OsuSpriteText - { - Text = GameplaySettingsStrings.BackgroundDim - }, dimSliderBar = new PlayerSliderBar { + LabelText = GameplaySettingsStrings.BackgroundDim, DisplayAsPercentage = true }, - new OsuSpriteText - { - Text = GameplaySettingsStrings.BackgroundBlur - }, blurSliderBar = new PlayerSliderBar { + LabelText = GameplaySettingsStrings.BackgroundBlur, DisplayAsPercentage = true }, - new OsuSpriteText - { - Text = "Toggles:" - }, showStoryboardToggle = new PlayerCheckbox { LabelText = GraphicsSettingsStrings.StoryboardVideo }, beatmapSkinsToggle = new PlayerCheckbox { LabelText = SkinSettingsStrings.BeatmapSkins }, beatmapColorsToggle = new PlayerCheckbox { LabelText = SkinSettingsStrings.BeatmapColours }, + comboColourNormalisationSliderBar = new PlayerSliderBar + { + LabelText = GraphicsSettingsStrings.ComboColourNormalisation, + DisplayAsPercentage = true, + }, }; } @@ -56,6 +51,7 @@ namespace osu.Game.Screens.Play.PlayerSettings showStoryboardToggle.Current = config.GetBindable(OsuSetting.ShowStoryboard); beatmapSkinsToggle.Current = config.GetBindable(OsuSetting.BeatmapSkins); beatmapColorsToggle.Current = config.GetBindable(OsuSetting.BeatmapColours); + comboColourNormalisationSliderBar.Current = config.GetBindable(OsuSetting.ComboColourNormalisationAmount); } } } diff --git a/osu.Game/Screens/Play/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index 617374b4fd..8a4e63d21c 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.cs @@ -1,31 +1,45 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; +using osu.Framework.Bindables; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Beatmaps; using osu.Game.Input.Bindings; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Objects; using osu.Game.Scoring; +using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Ranking; +using osu.Game.Users; namespace osu.Game.Screens.Play { - public class ReplayPlayer : Player, IKeyBindingHandler + public partial class ReplayPlayer : Player, IKeyBindingHandler { private readonly Func, Score> createScore; + private readonly bool replayIsFailedScore; + + protected override UserActivity InitialActivity => new UserActivity.WatchingReplay(Score.ScoreInfo); + // Disallow replays from failing. (see https://github.com/ppy/osu/issues/6108) - protected override bool CheckModsAllowFailure() => false; + protected override bool CheckModsAllowFailure() + { + if (!replayIsFailedScore) + return false; + + return base.CheckModsAllowFailure(); + } public ReplayPlayer(Score score, PlayerConfiguration configuration = null) - : this((_, __) => score, configuration) + : this((_, _) => score, configuration) { + replayIsFailedScore = score.ScoreInfo.Rank == ScoreRank.F; } public ReplayPlayer(Func, Score> createScore, PlayerConfiguration configuration = null) @@ -44,6 +58,15 @@ namespace osu.Game.Screens.Play // Don't re-import replay scores as they're already present in the database. protected override Task ImportScore(Score score) => Task.CompletedTask; + public readonly BindableList LeaderboardScores = new BindableList(); + + protected override GameplayLeaderboard CreateGameplayLeaderboard() => + new SoloGameplayLeaderboard(Score.ScoreInfo.User) + { + AlwaysVisible = { Value = true }, + Scores = { BindTarget = LeaderboardScores } + }; + protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score, false); public bool OnPressed(KeyBindingPressEvent e) @@ -72,7 +95,7 @@ namespace osu.Game.Screens.Play void keyboardSeek(int direction) { - double target = Math.Clamp(GameplayClockContainer.CurrentTime + direction * keyboard_seek_amount, 0, GameplayState.Beatmap.HitObjects.Last().GetEndTime()); + double target = Math.Clamp(GameplayClockContainer.CurrentTime + direction * keyboard_seek_amount, 0, GameplayState.Beatmap.GetLastObjectTime()); Seek(target); } diff --git a/osu.Game/Screens/Play/ReplayPlayerLoader.cs b/osu.Game/Screens/Play/ReplayPlayerLoader.cs index e78f700af2..1c9d694325 100644 --- a/osu.Game/Screens/Play/ReplayPlayerLoader.cs +++ b/osu.Game/Screens/Play/ReplayPlayerLoader.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Screens; using osu.Game.Scoring; namespace osu.Game.Screens.Play { - public class ReplayPlayerLoader : PlayerLoader + public partial class ReplayPlayerLoader : PlayerLoader { public readonly ScoreInfo Score; diff --git a/osu.Game/Screens/Play/ResumeOverlay.cs b/osu.Game/Screens/Play/ResumeOverlay.cs index 641d5358ba..fae406bd6b 100644 --- a/osu.Game/Screens/Play/ResumeOverlay.cs +++ b/osu.Game/Screens/Play/ResumeOverlay.cs @@ -1,11 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK; @@ -16,7 +19,7 @@ namespace osu.Game.Screens.Play /// /// An overlay which can be used to require further user actions before gameplay is resumed. /// - public abstract class ResumeOverlay : VisibilityContainer + public abstract partial class ResumeOverlay : VisibilityContainer { public CursorContainer GameplayCursor { get; set; } @@ -29,7 +32,7 @@ namespace osu.Game.Screens.Play protected const float TRANSITION_TIME = 500; - protected abstract string Message { get; } + protected abstract LocalisableString Message { get; } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; diff --git a/osu.Game/Screens/Play/RoomSubmittingPlayer.cs b/osu.Game/Screens/Play/RoomSubmittingPlayer.cs index fc96dfa965..e21daa737e 100644 --- a/osu.Game/Screens/Play/RoomSubmittingPlayer.cs +++ b/osu.Game/Screens/Play/RoomSubmittingPlayer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Diagnostics; using osu.Game.Online.API; using osu.Game.Online.Rooms; @@ -11,7 +13,7 @@ namespace osu.Game.Screens.Play /// /// A player instance which submits to a room backing. This is generally used by playlists and multiplayer. /// - public abstract class RoomSubmittingPlayer : SubmittingPlayer + public abstract partial class RoomSubmittingPlayer : SubmittingPlayer { protected readonly PlaylistItem PlaylistItem; protected readonly Room Room; diff --git a/osu.Game/Screens/Play/SaveFailedScoreButton.cs b/osu.Game/Screens/Play/SaveFailedScoreButton.cs new file mode 100644 index 0000000000..20d2130e76 --- /dev/null +++ b/osu.Game/Screens/Play/SaveFailedScoreButton.cs @@ -0,0 +1,91 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Database; +using osu.Game.Scoring; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online; +using osuTK; + +namespace osu.Game.Screens.Play +{ + public partial class SaveFailedScoreButton : CompositeDrawable + { + private readonly Bindable state = new Bindable(); + + private readonly Func> importFailedScore; + + private ScoreInfo? importedScore; + + private DownloadButton button = null!; + + public SaveFailedScoreButton(Func> importFailedScore) + { + Size = new Vector2(50, 30); + + this.importFailedScore = importFailedScore; + } + + [BackgroundDependencyLoader] + private void load(OsuGame? game, Player? player, RealmAccess realm) + { + InternalChild = button = new DownloadButton + { + RelativeSizeAxes = Axes.Both, + State = { BindTarget = state }, + Action = () => + { + switch (state.Value) + { + case DownloadState.LocallyAvailable: + game?.PresentScore(importedScore, ScorePresentType.Gameplay); + break; + + case DownloadState.NotDownloaded: + state.Value = DownloadState.Importing; + Task.Run(importFailedScore).ContinueWith(t => + { + importedScore = realm.Run(r => r.Find(t.GetResultSafely().ID)?.Detach()); + Schedule(() => state.Value = importedScore != null ? DownloadState.LocallyAvailable : DownloadState.NotDownloaded); + }); + break; + } + } + }; + + if (player != null) + { + importedScore = realm.Run(r => r.Find(player.Score.ScoreInfo.ID)?.Detach()); + state.Value = importedScore != null ? DownloadState.LocallyAvailable : DownloadState.NotDownloaded; + } + + state.BindValueChanged(state => + { + switch (state.NewValue) + { + case DownloadState.LocallyAvailable: + button.TooltipText = @"watch replay"; + button.Enabled.Value = true; + break; + + case DownloadState.Importing: + button.TooltipText = @"importing score"; + button.Enabled.Value = false; + break; + + default: + button.TooltipText = @"save score"; + button.Enabled.Value = true; + break; + } + }, true); + } + } +} diff --git a/osu.Game/Screens/Play/ScreenSuspensionHandler.cs b/osu.Game/Screens/Play/ScreenSuspensionHandler.cs index 30ca15c311..85948ea3f7 100644 --- a/osu.Game/Screens/Play/ScreenSuspensionHandler.cs +++ b/osu.Game/Screens/Play/ScreenSuspensionHandler.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -13,10 +15,10 @@ namespace osu.Game.Screens.Play /// /// Ensures screen is not suspended / dimmed while gameplay is active. /// - public class ScreenSuspensionHandler : Component + public partial class ScreenSuspensionHandler : Component { private readonly GameplayClockContainer gameplayClockContainer; - private Bindable isPaused; + private IBindable isPaused; private readonly Bindable disableSuspensionBindable = new Bindable(); diff --git a/osu.Game/Screens/Play/ScreenWithBeatmapBackground.cs b/osu.Game/Screens/Play/ScreenWithBeatmapBackground.cs index 88dab88d42..a5d1961bd8 100644 --- a/osu.Game/Screens/Play/ScreenWithBeatmapBackground.cs +++ b/osu.Game/Screens/Play/ScreenWithBeatmapBackground.cs @@ -2,14 +2,21 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; +using osu.Framework.Screens; using osu.Game.Screens.Backgrounds; namespace osu.Game.Screens.Play { - public abstract class ScreenWithBeatmapBackground : OsuScreen + public abstract partial class ScreenWithBeatmapBackground : OsuScreen { protected override BackgroundScreen CreateBackground() => new BackgroundScreenBeatmap(Beatmap.Value); - public void ApplyToBackground(Action action) => base.ApplyToBackground(b => action.Invoke((BackgroundScreenBeatmap)b)); + public void ApplyToBackground(Action action) + { + Debug.Assert(this.IsCurrentScreen()); + + base.ApplyToBackground(b => action.Invoke((BackgroundScreenBeatmap)b)); + } } } diff --git a/osu.Game/Screens/Play/SkipOverlay.cs b/osu.Game/Screens/Play/SkipOverlay.cs index 84d5507dce..7d69f0ca18 100644 --- a/osu.Game/Screens/Play/SkipOverlay.cs +++ b/osu.Game/Screens/Play/SkipOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework; using osu.Framework.Allocation; @@ -23,8 +25,13 @@ using osuTK.Graphics; namespace osu.Game.Screens.Play { - public class SkipOverlay : CompositeDrawable, IKeyBindingHandler + public partial class SkipOverlay : CompositeDrawable, IKeyBindingHandler { + /// + /// The total number of successful skips performed by this overlay. + /// + public int SkipCount { get; private set; } + private readonly double startTime; public Action RequestSkip; @@ -37,9 +44,12 @@ namespace osu.Game.Screens.Play private double displayTime; private bool isClickable; + private bool skipQueued; [Resolved] - private GameplayClock gameplayClock { get; set; } + private IGameplayClock gameplayClock { get; set; } + + internal bool IsButtonVisible => fadeContainer.State == Visibility.Visible && buttonContainer.State.Value == Visibility.Visible; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; @@ -109,25 +119,58 @@ namespace osu.Game.Screens.Play { base.LoadComplete(); + displayTime = gameplayClock.CurrentTime; + // skip is not required if there is no extra "empty" time to skip. // we may need to remove this if rewinding before the initial player load position becomes a thing. - if (fadeOutBeginTime < gameplayClock.CurrentTime) + if (fadeOutBeginTime <= displayTime) { Expire(); return; } - button.Action = () => RequestSkip?.Invoke(); - displayTime = gameplayClock.CurrentTime; + button.Action = () => + { + SkipCount++; + RequestSkip?.Invoke(); + }; fadeContainer.TriggerShow(); } + /// + /// Triggers an "automated" skip to happen as soon as available. + /// + public void SkipWhenReady() + { + if (skipQueued) return; + + skipQueued = true; + attemptNextSkip(); + + void attemptNextSkip() => Scheduler.AddDelayed(() => + { + if (!button.Enabled.Value) + { + skipQueued = false; + return; + } + + button.TriggerClick(); + attemptNextSkip(); + }, 200); + } + protected override void Update() { base.Update(); - double progress = fadeOutBeginTime <= displayTime ? 1 : Math.Max(0, 1 - (gameplayClock.CurrentTime - displayTime) / (fadeOutBeginTime - displayTime)); + // This case causes an immediate expire in `LoadComplete`, but `Update` may run once after that. + // Avoid div-by-zero below. + if (fadeOutBeginTime <= displayTime) + return; + + double progress = Math.Max(0, 1 - (gameplayClock.CurrentTime - displayTime) / (fadeOutBeginTime - displayTime)); remainingTimeBox.Width = (float)Interpolation.Lerp(remainingTimeBox.Width, progress, Math.Clamp(Time.Elapsed / 40, 0, 1)); @@ -166,7 +209,7 @@ namespace osu.Game.Screens.Play { } - public class FadeContainer : Container, IStateful + public partial class FadeContainer : Container, IStateful { public event Action StateChanged; @@ -244,14 +287,14 @@ namespace osu.Game.Screens.Play public override void Show() => State = Visibility.Visible; } - private class ButtonContainer : VisibilityContainer + private partial class ButtonContainer : VisibilityContainer { protected override void PopIn() => this.FadeIn(fade_time); protected override void PopOut() => this.FadeOut(fade_time); } - private class Button : OsuClickableContainer + private partial class Button : OsuClickableContainer { private Color4 colourNormal; private Color4 colourHover; diff --git a/osu.Game/Screens/Play/SoloPlayer.cs b/osu.Game/Screens/Play/SoloPlayer.cs index a935ce49eb..dafdf00136 100644 --- a/osu.Game/Screens/Play/SoloPlayer.cs +++ b/osu.Game/Screens/Play/SoloPlayer.cs @@ -1,18 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; +using System.Threading.Tasks; +using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Online.Solo; using osu.Game.Scoring; +using osu.Game.Screens.Play.HUD; namespace osu.Game.Screens.Play { - public class SoloPlayer : SubmittingPlayer + public partial class SoloPlayer : SubmittingPlayer { public SoloPlayer() : this(null) @@ -35,11 +40,30 @@ namespace osu.Game.Screens.Play if (!Ruleset.Value.IsLegacyRuleset()) return null; - return new CreateSoloScoreRequest(beatmapId, rulesetId, Game.VersionHash); + return new CreateSoloScoreRequest(Beatmap.Value.BeatmapInfo, rulesetId, Game.VersionHash); } + public readonly BindableList LeaderboardScores = new BindableList(); + + protected override GameplayLeaderboard CreateGameplayLeaderboard() => + new SoloGameplayLeaderboard(Score.ScoreInfo.User) + { + AlwaysVisible = { Value = false }, + Scores = { BindTarget = LeaderboardScores } + }; + protected override bool HandleTokenRetrievalFailure(Exception exception) => false; + protected override Task ImportScore(Score score) + { + // Before importing a score, stop binding the leaderboard with its score source. + // This avoids a case where the imported score may cause a leaderboard refresh + // (if the leaderboard's source is local). + LeaderboardScores.UnbindBindings(); + + return base.ImportScore(score); + } + protected override APIRequest CreateSubmissionRequest(Score score, long token) { IBeatmapInfo beatmap = score.ScoreInfo.BeatmapInfo; diff --git a/osu.Game/Screens/Play/SoloSpectator.cs b/osu.Game/Screens/Play/SoloSpectator.cs index 202527f308..a5c84e97ab 100644 --- a/osu.Game/Screens/Play/SoloSpectator.cs +++ b/osu.Game/Screens/Play/SoloSpectator.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Diagnostics; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -16,7 +18,7 @@ using osu.Game.Beatmaps.Drawables.Cards; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; @@ -31,7 +33,7 @@ using osuTK; namespace osu.Game.Screens.Play { [Cached(typeof(IPreviewTrackOwner))] - public class SoloSpectator : SpectatorScreen, IPreviewTrackOwner + public partial class SoloSpectator : SpectatorScreen, IPreviewTrackOwner { [NotNull] private readonly APIUser targetUser; @@ -52,7 +54,7 @@ namespace osu.Game.Screens.Play private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); private Container beatmapPanelContainer; - private TriangleButton watchButton; + private RoundedButton watchButton; private SettingsCheckbox automaticDownload; /// @@ -145,7 +147,7 @@ namespace osu.Game.Screens.Play Anchor = Anchor.Centre, Origin = Anchor.Centre, }, - watchButton = new PurpleTriangleButton + watchButton = new PurpleRoundedButton { Text = "Start Watching", Width = 250, @@ -180,7 +182,7 @@ namespace osu.Game.Screens.Play scheduleStart(spectatorGameplayState); } - protected override void EndGameplay(int userId, SpectatorState state) + protected override void QuitGameplay(int userId) { scheduledStart?.Cancel(); immediateSpectatorGameplayState = null; diff --git a/osu.Game/Screens/Play/SoloSpectatorPlayer.cs b/osu.Game/Screens/Play/SoloSpectatorPlayer.cs index 5b601083c2..c9d1f4acaa 100644 --- a/osu.Game/Screens/Play/SoloSpectatorPlayer.cs +++ b/osu.Game/Screens/Play/SoloSpectatorPlayer.cs @@ -1,17 +1,22 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Screens; using osu.Game.Online.Spectator; using osu.Game.Scoring; +using osu.Game.Users; namespace osu.Game.Screens.Play { - public class SoloSpectatorPlayer : SpectatorPlayer + public partial class SoloSpectatorPlayer : SpectatorPlayer { private readonly Score score; + protected override UserActivity InitialActivity => new UserActivity.SpectatingUser(Score.ScoreInfo); + public SoloSpectatorPlayer(Score score, PlayerConfiguration configuration = null) : base(score, configuration) { diff --git a/osu.Game/Screens/Play/SongProgress.cs b/osu.Game/Screens/Play/SongProgress.cs deleted file mode 100644 index b38dcb937d..0000000000 --- a/osu.Game/Screens/Play/SongProgress.cs +++ /dev/null @@ -1,245 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Timing; -using osu.Game.Configuration; -using osu.Game.Graphics; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.UI; -using osu.Game.Skinning; -using osuTK; - -namespace osu.Game.Screens.Play -{ - public class SongProgress : OverlayContainer, ISkinnableDrawable - { - public const float MAX_HEIGHT = info_height + bottom_bar_height + graph_height + handle_height; - - private const float info_height = 20; - private const float bottom_bar_height = 5; - private const float graph_height = SquareGraph.Column.WIDTH * 6; - private const float handle_height = 18; - - private static readonly Vector2 handle_size = new Vector2(10, handle_height); - - private const float transition_duration = 200; - - private readonly SongProgressBar bar; - private readonly SongProgressGraph graph; - private readonly SongProgressInfo info; - - public Action RequestSeek; - - /// - /// Whether seeking is allowed and the progress bar should be shown. - /// - public readonly Bindable AllowSeeking = new Bindable(); - - [SettingSource("Show difficulty graph", "Whether a graph displaying difficulty throughout the beatmap should be shown")] - public Bindable ShowGraph { get; } = new BindableBool(true); - - public override bool HandleNonPositionalInput => AllowSeeking.Value; - public override bool HandlePositionalInput => AllowSeeking.Value; - - protected override bool BlockScrollInput => false; - - private double firstHitTime => objects.First().StartTime; - - //TODO: this isn't always correct (consider mania where a non-last object may last for longer than the last in the list). - private double lastHitTime => objects.Last().GetEndTime() + 1; - - private IEnumerable objects; - - public IEnumerable Objects - { - set - { - graph.Objects = objects = value; - - info.StartTime = firstHitTime; - info.EndTime = lastHitTime; - - bar.StartTime = firstHitTime; - bar.EndTime = lastHitTime; - } - } - - [Resolved(canBeNull: true)] - private Player player { get; set; } - - [Resolved] - private GameplayClock gameplayClock { get; set; } - - [Resolved(canBeNull: true)] - private DrawableRuleset drawableRuleset { get; set; } - - private IClock referenceClock; - - public bool UsesFixedAnchor { get; set; } - - public SongProgress() - { - RelativeSizeAxes = Axes.X; - Anchor = Anchor.BottomRight; - Origin = Anchor.BottomRight; - - Children = new Drawable[] - { - info = new SongProgressInfo - { - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - Height = info_height, - }, - graph = new SongProgressGraph - { - RelativeSizeAxes = Axes.X, - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - Height = graph_height, - Margin = new MarginPadding { Bottom = bottom_bar_height }, - }, - bar = new SongProgressBar(bottom_bar_height, graph_height, handle_size) - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - OnSeek = time => player?.Seek(time), - }, - }; - } - - [BackgroundDependencyLoader(true)] - private void load(OsuColour colours) - { - base.LoadComplete(); - - if (drawableRuleset != null) - { - if (player?.Configuration.AllowUserInteraction == true) - ((IBindable)AllowSeeking).BindTo(drawableRuleset.HasReplayLoaded); - - referenceClock = drawableRuleset.FrameStableClock; - Objects = drawableRuleset.Objects; - } - - graph.FillColour = bar.FillColour = colours.BlueLighter; - } - - protected override void LoadComplete() - { - Show(); - - AllowSeeking.BindValueChanged(_ => updateBarVisibility(), true); - ShowGraph.BindValueChanged(_ => updateGraphVisibility(), true); - - migrateSettingFromConfig(); - } - - [Resolved] - private OsuConfigManager config { get; set; } - - [Resolved] - private SkinManager skinManager { get; set; } - - /// - /// This setting has been migrated to a per-component level. - /// Only take the value from the config if it is in a non-default state (then reset it to default so it only applies once). - /// - /// Can be removed 20221027. - /// - private void migrateSettingFromConfig() - { - Bindable configShowGraph = config.GetBindable(OsuSetting.ShowProgressGraph); - - if (!configShowGraph.IsDefault) - { - ShowGraph.Value = configShowGraph.Value; - - // This is pretty ugly, but the only way to make this stick... - if (skinManager != null) - { - var skinnableTarget = this.FindClosestParent(); - - if (skinnableTarget != null) - { - // If the skin is not mutable, a mutable instance will be created, causing this migration logic to run again on the correct skin. - // Therefore we want to avoid resetting the config value on this invocation. - if (skinManager.EnsureMutableSkin()) - return; - - // If `EnsureMutableSkin` actually changed the skin, default layout may take a frame to apply. - // See `SkinnableTargetComponentsContainer`'s use of ScheduleAfterChildren. - ScheduleAfterChildren(() => - { - var skin = skinManager.CurrentSkin.Value; - skin.UpdateDrawableTarget(skinnableTarget); - - skinManager.Save(skin); - }); - - configShowGraph.SetDefault(); - } - } - } - } - - protected override void PopIn() - { - this.FadeIn(500, Easing.OutQuint); - } - - protected override void PopOut() - { - this.FadeOut(100); - } - - protected override void Update() - { - base.Update(); - - if (objects == null) - return; - - double gameplayTime = gameplayClock?.CurrentTime ?? Time.Current; - double frameStableTime = referenceClock?.CurrentTime ?? gameplayTime; - - double progress = Math.Min(1, (frameStableTime - firstHitTime) / (lastHitTime - firstHitTime)); - - bar.CurrentTime = gameplayTime; - graph.Progress = (int)(graph.ColumnCount * progress); - - Height = bottom_bar_height + graph_height + handle_size.Y + info_height - graph.Y; - } - - private void updateBarVisibility() - { - bar.ShowHandle = AllowSeeking.Value; - - updateInfoMargin(); - } - - private void updateGraphVisibility() - { - float barHeight = bottom_bar_height + handle_size.Y; - - bar.ResizeHeightTo(ShowGraph.Value ? barHeight + graph_height : barHeight, transition_duration, Easing.In); - graph.FadeTo(ShowGraph.Value ? 1 : 0, transition_duration, Easing.In); - - updateInfoMargin(); - } - - private void updateInfoMargin() - { - float finalMargin = bottom_bar_height + (AllowSeeking.Value ? handle_size.Y : 0) + (ShowGraph.Value ? graph_height : 0); - info.TransformTo(nameof(info.Margin), new MarginPadding { Bottom = finalMargin }, transition_duration, Easing.In); - } - } -} diff --git a/osu.Game/Screens/Play/SongProgressInfo.cs b/osu.Game/Screens/Play/SongProgressInfo.cs deleted file mode 100644 index 7a458cdde0..0000000000 --- a/osu.Game/Screens/Play/SongProgressInfo.cs +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using System; - -namespace osu.Game.Screens.Play -{ - public class SongProgressInfo : Container - { - private OsuSpriteText timeCurrent; - private OsuSpriteText timeLeft; - private OsuSpriteText progress; - - private double startTime; - private double endTime; - - private int? previousPercent; - private int? previousSecond; - - private double songLength => endTime - startTime; - - private const int margin = 10; - - public double StartTime - { - set => startTime = value; - } - - public double EndTime - { - set => endTime = value; - } - - private GameplayClock gameplayClock; - - [BackgroundDependencyLoader(true)] - private void load(OsuColour colours, GameplayClock clock) - { - if (clock != null) - gameplayClock = clock; - - Children = new Drawable[] - { - timeCurrent = new OsuSpriteText - { - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - Colour = colours.BlueLighter, - Font = OsuFont.Numeric, - Margin = new MarginPadding - { - Left = margin, - }, - }, - progress = new OsuSpriteText - { - Origin = Anchor.BottomCentre, - Anchor = Anchor.BottomCentre, - Colour = colours.BlueLighter, - Font = OsuFont.Numeric, - }, - timeLeft = new OsuSpriteText - { - Origin = Anchor.BottomRight, - Anchor = Anchor.BottomRight, - Colour = colours.BlueLighter, - Font = OsuFont.Numeric, - Margin = new MarginPadding - { - Right = margin, - }, - } - }; - } - - protected override void Update() - { - base.Update(); - - double time = gameplayClock?.CurrentTime ?? Time.Current; - - double songCurrentTime = time - startTime; - int currentPercent = Math.Max(0, Math.Min(100, (int)(songCurrentTime / songLength * 100))); - int currentSecond = (int)Math.Floor(songCurrentTime / 1000.0); - - if (currentPercent != previousPercent) - { - progress.Text = currentPercent.ToString() + @"%"; - previousPercent = currentPercent; - } - - if (currentSecond != previousSecond && songCurrentTime < songLength) - { - timeCurrent.Text = formatTime(TimeSpan.FromSeconds(currentSecond)); - timeLeft.Text = formatTime(TimeSpan.FromMilliseconds(endTime - time)); - - previousSecond = currentSecond; - } - } - - private string formatTime(TimeSpan timeSpan) => $"{(timeSpan < TimeSpan.Zero ? "-" : "")}{Math.Floor(timeSpan.Duration().TotalMinutes)}:{timeSpan.Duration().Seconds:D2}"; - } -} diff --git a/osu.Game/Screens/Play/SpectatorPlayer.cs b/osu.Game/Screens/Play/SpectatorPlayer.cs index 09bec9b89f..30a5ac3741 100644 --- a/osu.Game/Screens/Play/SpectatorPlayer.cs +++ b/osu.Game/Screens/Play/SpectatorPlayer.cs @@ -1,6 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Screens; @@ -15,7 +18,7 @@ using osu.Game.Screens.Ranking; namespace osu.Game.Screens.Play { - public abstract class SpectatorPlayer : Player + public abstract partial class SpectatorPlayer : Player { [Resolved] protected SpectatorClient SpectatorClient { get; private set; } @@ -43,6 +46,20 @@ namespace osu.Game.Screens.Play }); } + protected override void LoadComplete() + { + base.LoadComplete(); + + DrawableRuleset.FrameStableClock.WaitingOnFrames.BindValueChanged(waiting => + { + if (GameplayClockContainer is MasterGameplayClockContainer master) + { + if (master.UserPlaybackRate.Value > 1 && waiting.NewValue) + master.UserPlaybackRate.Value = 1; + } + }, true); + } + protected override void StartGameplay() { base.StartGameplay(); @@ -68,6 +85,7 @@ namespace osu.Game.Screens.Play foreach (var frame in bundle.Frames) { IConvertibleReplayFrame convertibleFrame = GameplayState.Ruleset.CreateConvertibleReplayFrame(); + Debug.Assert(convertibleFrame != null); convertibleFrame.FromLegacy(frame, GameplayState.Beatmap); var convertedFrame = (ReplayFrame)convertibleFrame; diff --git a/osu.Game/Screens/Play/SpectatorPlayerLoader.cs b/osu.Game/Screens/Play/SpectatorPlayerLoader.cs index 9ca5475ee4..3830443ce8 100644 --- a/osu.Game/Screens/Play/SpectatorPlayerLoader.cs +++ b/osu.Game/Screens/Play/SpectatorPlayerLoader.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Screens; using osu.Game.Scoring; namespace osu.Game.Screens.Play { - public class SpectatorPlayerLoader : PlayerLoader + public partial class SpectatorPlayerLoader : PlayerLoader { public readonly ScoreInfo Score; diff --git a/osu.Game/Screens/Play/SpectatorResultsScreen.cs b/osu.Game/Screens/Play/SpectatorResultsScreen.cs index fd7af3af85..b54dbb387a 100644 --- a/osu.Game/Screens/Play/SpectatorResultsScreen.cs +++ b/osu.Game/Screens/Play/SpectatorResultsScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Screens; using osu.Game.Online.Spectator; @@ -9,7 +11,7 @@ using osu.Game.Screens.Ranking; namespace osu.Game.Screens.Play { - public class SpectatorResultsScreen : SoloResultsScreen + public partial class SpectatorResultsScreen : SoloResultsScreen { public SpectatorResultsScreen(ScoreInfo score) : base(score, false) diff --git a/osu.Game/Screens/Play/SquareGraph.cs b/osu.Game/Screens/Play/SquareGraph.cs index a7d4454701..57b7c84e89 100644 --- a/osu.Game/Screens/Play/SquareGraph.cs +++ b/osu.Game/Screens/Play/SquareGraph.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -13,20 +15,14 @@ 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 { - public class SquareGraph : Container + public partial class SquareGraph : Container { private BufferedContainer columns; - public SquareGraph() - { - AddLayout(layout); - } - public int ColumnCount => columns?.Children.Count ?? 0; private int progress; @@ -55,7 +51,7 @@ namespace osu.Game.Screens.Play if (value == values) return; values = value; - layout.Invalidate(); + graphNeedsUpdate = true; } } @@ -73,21 +69,25 @@ namespace osu.Game.Screens.Play } } - private readonly LayoutValue layout = new LayoutValue(Invalidation.DrawSize); private ScheduledDelegate scheduledCreate; + private bool graphNeedsUpdate; + + private Vector2 previousDrawSize; + protected override void Update() { base.Update(); - if (values != null && !layout.IsValid) + if (graphNeedsUpdate || (values != null && DrawSize != previousDrawSize)) { columns?.FadeOut(500, Easing.OutQuint).Expire(); scheduledCreate?.Cancel(); scheduledCreate = Scheduler.AddDelayed(RecreateGraph, 500); - layout.Validate(); + previousDrawSize = DrawSize; + graphNeedsUpdate = false; } } @@ -176,7 +176,7 @@ namespace osu.Game.Screens.Play calculatedValues = newValues.ToArray(); } - public class Column : Container, IStateful + public partial class Column : Container, IStateful { protected readonly Color4 EmptyColour = Color4.White.Opacity(20); public Color4 LitColour = Color4.LightBlue; diff --git a/osu.Game/Screens/Play/SubmittingPlayer.cs b/osu.Game/Screens/Play/SubmittingPlayer.cs index b62dc1e5a6..5fa6508a31 100644 --- a/osu.Game/Screens/Play/SubmittingPlayer.cs +++ b/osu.Game/Screens/Play/SubmittingPlayer.cs @@ -1,16 +1,21 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Allocation; -using osu.Framework.Extensions; using osu.Framework.Logging; using osu.Framework.Screens; +using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.Online.API; +using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; +using osu.Game.Online.Spectator; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; @@ -19,7 +24,7 @@ namespace osu.Game.Screens.Play /// /// A player instance which supports submitting scores to an online store. /// - public abstract class SubmittingPlayer : Player + public abstract partial class SubmittingPlayer : Player { /// /// The token to be used for the current submission. This is fetched via a request created by . @@ -29,6 +34,9 @@ namespace osu.Game.Screens.Play [Resolved] private IAPIProvider api { get; set; } + [Resolved] + private SpectatorClient spectatorClient { get; set; } + private TaskCompletionSource scoreSubmissionSource; protected SubmittingPlayer(PlayerConfiguration configuration = null) @@ -69,6 +77,7 @@ namespace osu.Game.Screens.Play req.Success += r => { + Logger.Log($"Score submission token retrieved ({r.ID})"); token = r.ID; tcs.SetResult(true); }; @@ -76,11 +85,16 @@ namespace osu.Game.Screens.Play api.Queue(req); - tcs.Task.WaitSafely(); + // Generally a timeout would not happen here as APIAccess will timeout first. + if (!tcs.Task.Wait(30000)) + req.TriggerFailure(new InvalidOperationException("Token retrieval timed out (request never run)")); + return true; void handleTokenFailure(Exception exception) { + tcs.SetResult(false); + if (HandleTokenRetrievalFailure(exception)) { if (string.IsNullOrEmpty(exception.Message)) @@ -94,8 +108,12 @@ namespace osu.Game.Screens.Play this.Exit(); }); } - - tcs.SetResult(false); + else + { + // Gameplay is allowed to continue, but we still should keep track of the error. + // In the future, this should be visible to the user in some way. + Logger.Log($"Score submission token retrieval failed ({exception.Message})"); + } } } @@ -113,6 +131,26 @@ namespace osu.Game.Screens.Play score.ScoreInfo.Date = DateTimeOffset.Now; await submitScore(score).ConfigureAwait(false); + spectatorClient.EndPlaying(GameplayState); + } + + [Resolved] + private RealmAccess realm { get; set; } + + protected override void StartGameplay() + { + base.StartGameplay(); + + // User expectation is that last played should be updated when entering the gameplay loop + // from multiplayer / playlists / solo. + realm.WriteAsync(r => + { + var realmBeatmap = r.Find(Beatmap.Value.BeatmapInfo.ID); + if (realmBeatmap != null) + realmBeatmap.LastPlayed = DateTimeOffset.Now; + }); + + spectatorClient.BeginPlaying(token, GameplayState, Score); } public override bool OnExiting(ScreenExitEvent e) @@ -120,7 +158,13 @@ namespace osu.Game.Screens.Play bool exiting = base.OnExiting(e); if (LoadedBeatmapSuccessfully) - submitScore(Score.DeepClone()); + { + Task.Run(async () => + { + await submitScore(Score.DeepClone()).ConfigureAwait(false); + spectatorClient.EndPlaying(GameplayState); + }).FireAndForget(); + } return exiting; } @@ -166,7 +210,7 @@ namespace osu.Game.Screens.Play request.Failure += e => { - Logger.Error(e, "Failed to submit score"); + Logger.Error(e, $"Failed to submit score ({e.Message})"); scoreSubmissionSource.SetResult(false); }; diff --git a/osu.Game/Screens/Ranking/AspectContainer.cs b/osu.Game/Screens/Ranking/AspectContainer.cs index 84329d2a04..9ec2a15044 100644 --- a/osu.Game/Screens/Ranking/AspectContainer.cs +++ b/osu.Game/Screens/Ranking/AspectContainer.cs @@ -1,12 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; namespace osu.Game.Screens.Ranking { - public class AspectContainer : Container + public partial class AspectContainer : Container { protected override void Update() { diff --git a/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs index bb286f41c0..402322c611 100644 --- a/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs @@ -1,9 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; @@ -28,7 +31,7 @@ namespace osu.Game.Screens.Ranking.Contracted /// /// The content that appears in the middle of a contracted . /// - public class ContractedPanelMiddleContent : CompositeDrawable + public partial class ContractedPanelMiddleContent : CompositeDrawable { private readonly ScoreInfo score; @@ -212,7 +215,7 @@ namespace osu.Game.Screens.Ranking.Contracted { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Text = key, + Text = key.ToTitle(), Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold) }, new OsuSpriteText diff --git a/osu.Game/Screens/Ranking/Contracted/ContractedPanelTopContent.cs b/osu.Game/Screens/Ranking/Contracted/ContractedPanelTopContent.cs index beff509dc6..93bc7c41e1 100644 --- a/osu.Game/Screens/Ranking/Contracted/ContractedPanelTopContent.cs +++ b/osu.Game/Screens/Ranking/Contracted/ContractedPanelTopContent.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -10,7 +12,7 @@ using osu.Game.Graphics.Sprites; namespace osu.Game.Screens.Ranking.Contracted { - public class ContractedPanelTopContent : CompositeDrawable + public partial class ContractedPanelTopContent : CompositeDrawable { public readonly Bindable ScorePosition = new Bindable(); diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs index b9248bd67e..2ec4270c3c 100644 --- a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using osu.Framework.Allocation; @@ -10,10 +12,12 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Graphics; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Skinning; using osuTK; @@ -23,8 +27,15 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy /// /// The component that displays the player's accuracy on the results screen. /// - public class AccuracyCircle : CompositeDrawable + public partial class AccuracyCircle : CompositeDrawable { + private static readonly double accuracy_x = ScoreProcessor.AccuracyCutoffFromRank(ScoreRank.X); + private static readonly double accuracy_s = ScoreProcessor.AccuracyCutoffFromRank(ScoreRank.S); + private static readonly double accuracy_a = ScoreProcessor.AccuracyCutoffFromRank(ScoreRank.A); + private static readonly double accuracy_b = ScoreProcessor.AccuracyCutoffFromRank(ScoreRank.B); + private static readonly double accuracy_c = ScoreProcessor.AccuracyCutoffFromRank(ScoreRank.C); + private static readonly double accuracy_d = ScoreProcessor.AccuracyCutoffFromRank(ScoreRank.D); + /// /// Duration for the transforms causing this component to appear. /// @@ -70,6 +81,11 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy /// private const double virtual_ss_percentage = 0.01; + /// + /// The width of a in terms of accuracy. + /// + public const double NOTCH_WIDTH_PERCENTAGE = 1.0 / 360; + /// /// The easing for the circle filling transforms. /// @@ -77,8 +93,8 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy private readonly ScoreInfo score; - private SmoothCircularProgress accuracyCircle; - private SmoothCircularProgress innerMask; + private CircularProgress accuracyCircle; + private CircularProgress innerMask; private Container badges; private RankText rankText; @@ -107,7 +123,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy { InternalChildren = new Drawable[] { - new SmoothCircularProgress + new CircularProgress { Name = "Background circle", Anchor = Anchor.Centre, @@ -118,7 +134,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy InnerRadius = accuracy_circle_radius + 0.01f, // Extends a little bit into the circle Current = { Value = 1 }, }, - accuracyCircle = new SmoothCircularProgress + accuracyCircle = new CircularProgress { Name = "Accuracy circle", Anchor = Anchor.Centre, @@ -137,54 +153,54 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy Padding = new MarginPadding(2), Children = new Drawable[] { - new SmoothCircularProgress + new CircularProgress { RelativeSizeAxes = Axes.Both, Colour = OsuColour.ForRank(ScoreRank.X), InnerRadius = RANK_CIRCLE_RADIUS, - Current = { Value = 1 } + Current = { Value = accuracy_x } }, - new SmoothCircularProgress + new CircularProgress { RelativeSizeAxes = Axes.Both, Colour = OsuColour.ForRank(ScoreRank.S), InnerRadius = RANK_CIRCLE_RADIUS, - Current = { Value = 1 - virtual_ss_percentage } + Current = { Value = accuracy_x - virtual_ss_percentage } }, - new SmoothCircularProgress + new CircularProgress { RelativeSizeAxes = Axes.Both, Colour = OsuColour.ForRank(ScoreRank.A), InnerRadius = RANK_CIRCLE_RADIUS, - Current = { Value = 0.95f } + Current = { Value = accuracy_s } }, - new SmoothCircularProgress + new CircularProgress { RelativeSizeAxes = Axes.Both, Colour = OsuColour.ForRank(ScoreRank.B), InnerRadius = RANK_CIRCLE_RADIUS, - Current = { Value = 0.9f } + Current = { Value = accuracy_a } }, - new SmoothCircularProgress + new CircularProgress { RelativeSizeAxes = Axes.Both, Colour = OsuColour.ForRank(ScoreRank.C), InnerRadius = RANK_CIRCLE_RADIUS, - Current = { Value = 0.8f } + Current = { Value = accuracy_b } }, - new SmoothCircularProgress + new CircularProgress { RelativeSizeAxes = Axes.Both, Colour = OsuColour.ForRank(ScoreRank.D), InnerRadius = RANK_CIRCLE_RADIUS, - Current = { Value = 0.7f } + Current = { Value = accuracy_c } }, - new RankNotch(0), - new RankNotch((float)(1 - virtual_ss_percentage)), - new RankNotch(0.95f), - new RankNotch(0.9f), - new RankNotch(0.8f), - new RankNotch(0.7f), + new RankNotch((float)accuracy_x), + new RankNotch((float)(accuracy_x - virtual_ss_percentage)), + new RankNotch((float)accuracy_s), + new RankNotch((float)accuracy_a), + new RankNotch((float)accuracy_b), + new RankNotch((float)accuracy_c), new BufferedContainer { Name = "Graded circle mask", @@ -193,14 +209,14 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy Blending = new BlendingParameters { Source = BlendingType.DstColor, - Destination = BlendingType.OneMinusSrcAlpha, + Destination = BlendingType.OneMinusSrcColor, SourceAlpha = BlendingType.One, DestinationAlpha = BlendingType.SrcAlpha }, - Child = innerMask = new SmoothCircularProgress + Child = innerMask = new CircularProgress { RelativeSizeAxes = Axes.Both, - InnerRadius = RANK_CIRCLE_RADIUS - 0.01f, + InnerRadius = RANK_CIRCLE_RADIUS - 0.02f, } } } @@ -212,12 +228,13 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy Padding = new MarginPadding { Vertical = -15, Horizontal = -20 }, Children = new[] { - new RankBadge(1, getRank(ScoreRank.X)), - new RankBadge(0.95, getRank(ScoreRank.S)), - new RankBadge(0.9, getRank(ScoreRank.A)), - new RankBadge(0.8, getRank(ScoreRank.B)), - new RankBadge(0.7, getRank(ScoreRank.C)), - new RankBadge(0.35, getRank(ScoreRank.D)), + // The S and A badges are moved down slightly to prevent collision with the SS badge. + new RankBadge(accuracy_x, accuracy_x, getRank(ScoreRank.X)), + new RankBadge(accuracy_s, Interpolation.Lerp(accuracy_s, (accuracy_x - virtual_ss_percentage), 0.25), getRank(ScoreRank.S)), + new RankBadge(accuracy_a, Interpolation.Lerp(accuracy_a, accuracy_s, 0.25), getRank(ScoreRank.A)), + new RankBadge(accuracy_b, Interpolation.Lerp(accuracy_b, accuracy_a, 0.5), getRank(ScoreRank.B)), + new RankBadge(accuracy_c, Interpolation.Lerp(accuracy_c, accuracy_b, 0.5), getRank(ScoreRank.C)), + new RankBadge(accuracy_d, Interpolation.Lerp(accuracy_d, accuracy_c, 0.5), getRank(ScoreRank.D)), } }, rankText = new RankText(score.Rank) @@ -260,7 +277,39 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy using (BeginDelayedSequence(ACCURACY_TRANSFORM_DELAY)) { - double targetAccuracy = score.Rank == ScoreRank.X || score.Rank == ScoreRank.XH ? 1 : Math.Min(1 - virtual_ss_percentage, score.Accuracy); + double targetAccuracy = score.Accuracy; + double[] notchPercentages = + { + accuracy_s, + accuracy_a, + accuracy_b, + accuracy_c, + }; + + // Ensure the gauge overshoots or undershoots a bit so it doesn't land in the gaps of the inner graded circle (caused by `RankNotch`es), + // to prevent ambiguity on what grade it's pointing at. + foreach (double p in notchPercentages) + { + if (Precision.AlmostEquals(p, targetAccuracy, NOTCH_WIDTH_PERCENTAGE / 2)) + { + int tippingDirection = targetAccuracy - p >= 0 ? 1 : -1; // We "round up" here to match rank criteria + targetAccuracy = p + tippingDirection * (NOTCH_WIDTH_PERCENTAGE / 2); + break; + } + } + + // The final gap between 99.999...% (S) and 100% (SS) is exaggerated by `virtual_ss_percentage`. We don't want to land there either. + if (score.Rank == ScoreRank.X || score.Rank == ScoreRank.XH) + targetAccuracy = 1; + else + targetAccuracy = Math.Min(accuracy_x - virtual_ss_percentage - NOTCH_WIDTH_PERCENTAGE / 2, targetAccuracy); + + // The accuracy circle gauge visually fills up a bit too much. + // This wouldn't normally matter but we want it to align properly with the inner graded circle in the above cases. + const double visual_alignment_offset = 0.001; + + if (targetAccuracy < 1 && targetAccuracy >= visual_alignment_offset) + targetAccuracy -= visual_alignment_offset; accuracyCircle.FillTo(targetAccuracy, ACCURACY_TRANSFORM_DURATION, ACCURACY_TRANSFORM_EASING); @@ -290,7 +339,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy if (badge.Accuracy > score.Accuracy) continue; - using (BeginDelayedSequence(inverseEasing(ACCURACY_TRANSFORM_EASING, Math.Min(1 - virtual_ss_percentage, badge.Accuracy) / targetAccuracy) * ACCURACY_TRANSFORM_DURATION)) + using (BeginDelayedSequence(inverseEasing(ACCURACY_TRANSFORM_EASING, Math.Min(accuracy_x - virtual_ss_percentage, badge.Accuracy) / targetAccuracy) * ACCURACY_TRANSFORM_DURATION)) { badge.Appear(); diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/RankBadge.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankBadge.cs index d0b79aa4c7..7af327828e 100644 --- a/osu.Game/Screens/Ranking/Expanded/Accuracy/RankBadge.cs +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankBadge.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -18,13 +20,18 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy /// /// Contains a that is positioned around the . /// - public class RankBadge : CompositeDrawable + public partial class RankBadge : CompositeDrawable { /// /// The accuracy value corresponding to the displayed by this badge. /// public readonly double Accuracy; + /// + /// The position around the to display this badge. + /// + private readonly double displayPosition; + private readonly ScoreRank rank; private Drawable rankContainer; @@ -34,10 +41,12 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy /// Creates a new . /// /// The accuracy value corresponding to . + /// The position around the to display this badge. /// The to be displayed in this . - public RankBadge(double accuracy, ScoreRank rank) + public RankBadge(double accuracy, double position, ScoreRank rank) { Accuracy = accuracy; + displayPosition = position; this.rank = rank; RelativeSizeAxes = Axes.Both; @@ -90,7 +99,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy base.Update(); // Starts at -90deg (top) and moves counter-clockwise by the accuracy - rankContainer.Position = circlePosition(-MathF.PI / 2 - (1 - (float)Accuracy) * MathF.PI * 2); + rankContainer.Position = circlePosition(-MathF.PI / 2 - (1 - (float)displayPosition) * MathF.PI * 2); } private Vector2 circlePosition(float t) diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/RankNotch.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankNotch.cs index 894790b5b6..32f2eb2fa5 100644 --- a/osu.Game/Screens/Ranking/Expanded/Accuracy/RankNotch.cs +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankNotch.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -13,7 +15,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy /// /// A solid "notch" of the that appears at the ends of the rank circles to add separation. /// - public class RankNotch : CompositeDrawable + public partial class RankNotch : CompositeDrawable { private readonly float position; @@ -39,7 +41,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy Origin = Anchor.TopCentre, RelativeSizeAxes = Axes.Y, Height = AccuracyCircle.RANK_CIRCLE_RADIUS, - Width = 1f, + Width = (float)AccuracyCircle.NOTCH_WIDTH_PERCENTAGE * 360f, Colour = OsuColour.Gray(0.3f), EdgeSmoothness = new Vector2(1f) } diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/RankText.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankText.cs index 5aea0184ee..b7adcb032f 100644 --- a/osu.Game/Screens/Ranking/Expanded/Accuracy/RankText.cs +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankText.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -17,7 +19,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy /// /// The text that appears in the middle of the displaying the user's rank. /// - public class RankText : CompositeDrawable + public partial class RankText : CompositeDrawable { private readonly ScoreRank rank; diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/SmoothCircularProgress.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/SmoothCircularProgress.cs deleted file mode 100644 index 106af31cae..0000000000 --- a/osu.Game/Screens/Ranking/Expanded/Accuracy/SmoothCircularProgress.cs +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Transforms; -using osu.Framework.Graphics.UserInterface; -using osu.Game.Graphics; -using osuTK; - -namespace osu.Game.Screens.Ranking.Expanded.Accuracy -{ - /// - /// Contains a with smoothened edges. - /// - public class SmoothCircularProgress : CompositeDrawable - { - public Bindable Current - { - get => progress.Current; - set => progress.Current = value; - } - - public float InnerRadius - { - get => progress.InnerRadius; - set - { - progress.InnerRadius = value; - innerSmoothingContainer.Size = new Vector2(1 - value); - smoothingWedge.Height = value / 2; - } - } - - private readonly CircularProgress progress; - private readonly Container innerSmoothingContainer; - private readonly Drawable smoothingWedge; - - public SmoothCircularProgress() - { - Container smoothingWedgeContainer; - - InternalChild = new BufferedContainer - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - progress = new CircularProgress { RelativeSizeAxes = Axes.Both }, - smoothingWedgeContainer = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Child = smoothingWedge = new Box - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.Y, - Width = 1f, - EdgeSmoothness = new Vector2(2, 0), - } - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding(-1), - Child = new CircularContainer - { - RelativeSizeAxes = Axes.Both, - BorderThickness = 2, - Masking = true, - BorderColour = OsuColour.Gray(0.5f).Opacity(0.75f), - Blending = new BlendingParameters - { - AlphaEquation = BlendingEquation.ReverseSubtract, - }, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true - } - } - }, - innerSmoothingContainer = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Size = Vector2.Zero, - Padding = new MarginPadding(-1), - Child = new CircularContainer - { - RelativeSizeAxes = Axes.Both, - BorderThickness = 2, - BorderColour = OsuColour.Gray(0.5f).Opacity(0.75f), - Masking = true, - Blending = new BlendingParameters - { - AlphaEquation = BlendingEquation.ReverseSubtract, - }, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true - } - } - }, - } - }; - - Current.BindValueChanged(c => - { - smoothingWedgeContainer.Alpha = c.NewValue > 0 ? 1 : 0; - smoothingWedgeContainer.Rotation = (float)(360 * c.NewValue); - }, true); - } - - public TransformSequence FillTo(double newValue, double duration = 0, Easing easing = Easing.None) - => progress.FillTo(newValue, duration, easing); - } -} diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs index b924fbd5df..f23b469f5c 100644 --- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs @@ -1,16 +1,21 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -27,7 +32,7 @@ namespace osu.Game.Screens.Ranking.Expanded /// /// The content that appears in the middle section of the . /// - public class ExpandedPanelMiddleContent : CompositeDrawable + public partial class ExpandedPanelMiddleContent : CompositeDrawable { private const float padding = 10; @@ -65,12 +70,10 @@ namespace osu.Game.Screens.Ranking.Expanded var metadata = beatmap.BeatmapSet?.Metadata ?? beatmap.Metadata; string creator = metadata.Author.Username; - int? beatmapMaxCombo = scoreManager.GetMaximumAchievableComboAsync(score).GetResultSafely(); - var topStatistics = new List { new AccuracyStatistic(score.Accuracy), - new ComboStatistic(score.MaxCombo, beatmapMaxCombo), + new ComboStatistic(score.MaxCombo, scoreManager.GetMaximumAchievableCombo(score)), new PerformanceStatistic(score), }; @@ -131,7 +134,7 @@ namespace osu.Game.Screens.Ranking.Expanded FillMode = FillMode.Fit, } }, - scoreCounter = new TotalScoreCounter + scoreCounter = new TotalScoreCounter(!withFlair) { Margin = new MarginPadding { Top = 0, Bottom = 5 }, Current = { Value = 0 }, @@ -278,14 +281,37 @@ namespace osu.Game.Screens.Ranking.Expanded }); } - public class PlayedOnText : OsuSpriteText + public partial class PlayedOnText : OsuSpriteText { + private readonly DateTimeOffset time; + private readonly Bindable prefer24HourTime = new Bindable(); + public PlayedOnText(DateTimeOffset time) { + this.time = time; + Anchor = Anchor.BottomCentre; Origin = Anchor.BottomCentre; Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold); - Text = $"Played on {time.ToLocalTime():d MMMM yyyy HH:mm}"; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager configManager) + { + configManager.BindWith(OsuSetting.Prefer24HourTime, prefer24HourTime); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + prefer24HourTime.BindValueChanged(_ => updateDisplay(), true); + } + + private void updateDisplay() + { + Text = LocalisableString.Format("Played on {0}", + time.ToLocalTime().ToLocalisableString(prefer24HourTime.Value ? @"d MMMM yyyy HH:mm" : @"d MMMM yyyy h:mm tt")); } } } diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelTopContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelTopContent.cs index bf06bc2227..c834d541eb 100644 --- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelTopContent.cs +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelTopContent.cs @@ -1,7 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; @@ -15,17 +19,23 @@ namespace osu.Game.Screens.Ranking.Expanded /// /// The content that appears in the middle section of the . /// - public class ExpandedPanelTopContent : CompositeDrawable + public partial class ExpandedPanelTopContent : CompositeDrawable { private readonly APIUser user; + private Sample appearanceSample; + + private readonly bool playAppearanceSound; + /// /// Creates a new . /// /// The to display. - public ExpandedPanelTopContent(APIUser user) + /// Whether the appearance sample should play + public ExpandedPanelTopContent(APIUser user, bool playAppearanceSound = false) { this.user = user; + this.playAppearanceSound = playAppearanceSound; Anchor = Anchor.TopCentre; Origin = Anchor.Centre; @@ -33,8 +43,10 @@ namespace osu.Game.Screens.Ranking.Expanded } [BackgroundDependencyLoader] - private void load() + private void load(AudioManager audio) { + appearanceSample = audio.Samples.Get(@"Results/score-panel-top-appear"); + InternalChild = new FillFlowContainer { AutoSizeAxes = Axes.Both, @@ -60,5 +72,13 @@ namespace osu.Game.Screens.Ranking.Expanded } }; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (playAppearanceSound) + appearanceSample?.Play(); + } } } diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/AccuracyStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/AccuracyStatistic.cs index 25a644d8d9..4b8c057235 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/AccuracyStatistic.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/AccuracyStatistic.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Graphics; @@ -16,7 +18,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics /// /// A to display the player's accuracy. /// - public class AccuracyStatistic : StatisticDisplay + public partial class AccuracyStatistic : StatisticDisplay { private readonly double accuracy; @@ -40,7 +42,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics protected override Drawable CreateContent() => counter = new Counter(); - private class Counter : RollingCounter + private partial class Counter : RollingCounter { protected override double RollingDuration => AccuracyCircle.ACCURACY_TRANSFORM_DURATION; diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs index cb25736f6e..6290cee6da 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -16,7 +18,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics /// /// A to display the player's combo. /// - public class ComboStatistic : CounterStatistic + public partial class ComboStatistic : CounterStatistic { private readonly bool isPerfect; @@ -51,7 +53,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics Spacing = new Vector2(10, 0), Children = new[] { - base.CreateContent().With(d => + base.CreateContent().With(_ => { Anchor = Anchor.CentreLeft; Origin = Anchor.CentreLeft; diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/CounterStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/CounterStatistic.cs index b1c72173da..8528dac83b 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/CounterStatistic.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/CounterStatistic.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; @@ -14,7 +16,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics /// /// A to display general numeric values. /// - public class CounterStatistic : StatisticDisplay + public partial class CounterStatistic : StatisticDisplay { private readonly int count; private readonly int? maxCount; diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/HitResultStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/HitResultStatistic.cs index ada8dfabf0..863c450617 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/HitResultStatistic.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/HitResultStatistic.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Game.Graphics; using osu.Game.Rulesets.Scoring; @@ -8,7 +10,7 @@ using osu.Game.Scoring; namespace osu.Game.Screens.Ranking.Expanded.Statistics { - public class HitResultStatistic : CounterStatistic + public partial class HitResultStatistic : CounterStatistic { public readonly HitResult Result; diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs index c681946a2f..22509b2cea 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Threading; using osu.Framework.Allocation; @@ -13,7 +15,7 @@ using osu.Game.Scoring; namespace osu.Game.Screens.Ranking.Expanded.Statistics { - public class PerformanceStatistic : StatisticDisplay + public partial class PerformanceStatistic : StatisticDisplay { private readonly ScoreInfo score; diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/StatisticCounter.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/StatisticCounter.cs index bbcfc43dc8..ecadc9eed6 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/StatisticCounter.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/StatisticCounter.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -10,7 +12,7 @@ using osuTK; namespace osu.Game.Screens.Ranking.Expanded.Statistics { - public class StatisticCounter : RollingCounter + public partial class StatisticCounter : RollingCounter { protected override double RollingDuration => AccuracyCircle.ACCURACY_TRANSFORM_DURATION; diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/StatisticDisplay.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/StatisticDisplay.cs index c034abc916..686b6c7d47 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/StatisticDisplay.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/StatisticDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.LocalisationExtensions; @@ -17,7 +19,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics /// /// A statistic from the score to be displayed in the . /// - public abstract class StatisticDisplay : CompositeDrawable + public abstract partial class StatisticDisplay : CompositeDrawable { protected SpriteText HeaderText { get; private set; } diff --git a/osu.Game/Screens/Ranking/Expanded/TotalScoreCounter.cs b/osu.Game/Screens/Ranking/Expanded/TotalScoreCounter.cs index c54bca9e3a..b017a3a63d 100644 --- a/osu.Game/Screens/Ranking/Expanded/TotalScoreCounter.cs +++ b/osu.Game/Screens/Ranking/Expanded/TotalScoreCounter.cs @@ -1,7 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Audio; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -14,17 +20,41 @@ namespace osu.Game.Screens.Ranking.Expanded /// /// A counter for the player's total score to be displayed in the . /// - public class TotalScoreCounter : RollingCounter + public partial class TotalScoreCounter : RollingCounter { protected override double RollingDuration => AccuracyCircle.ACCURACY_TRANSFORM_DURATION; protected override Easing RollingEasing => AccuracyCircle.ACCURACY_TRANSFORM_EASING; - public TotalScoreCounter() + private readonly bool playSamples; + + private readonly Bindable tickPlaybackRate = new Bindable(); + + private double lastSampleTime; + + private DrawableSample sampleTick; + + public TotalScoreCounter(bool playSamples = false) { // Todo: AutoSize X removed here due to https://github.com/ppy/osu-framework/issues/3369 AutoSizeAxes = Axes.Y; RelativeSizeAxes = Axes.X; + + this.playSamples = playSamples; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + AddInternal(sampleTick = new DrawableSample(audio.Samples.Get(@"Results/score-tick-lesser"))); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (playSamples) + Current.BindValueChanged(_ => startTicking()); } protected override LocalisableString FormatCount(long count) => count.ToString("N0"); @@ -37,5 +67,35 @@ namespace osu.Game.Screens.Ranking.Expanded s.Font = OsuFont.Torus.With(size: 60, weight: FontWeight.Light, fixedWidth: true); s.Spacing = new Vector2(-5, 0); }); + + public override long DisplayedCount + { + get => base.DisplayedCount; + set + { + if (base.DisplayedCount == value) + return; + + base.DisplayedCount = value; + + if (playSamples && Time.Current > lastSampleTime + tickPlaybackRate.Value) + { + sampleTick?.Play(); + lastSampleTime = Time.Current; + } + } + } + + private void startTicking() + { + const double tick_debounce_rate_start = 10f; + const double tick_debounce_rate_end = 100f; + const double tick_volume_start = 0.5f; + const double tick_volume_end = 1.0f; + + this.TransformBindableTo(tickPlaybackRate, tick_debounce_rate_start); + this.TransformBindableTo(tickPlaybackRate, tick_debounce_rate_end, RollingDuration, Easing.OutSine); + sampleTick.VolumeTo(tick_volume_start).Then().VolumeTo(tick_volume_end, RollingDuration, Easing.OutSine); + } } } diff --git a/osu.Game/Screens/Ranking/PanelState.cs b/osu.Game/Screens/Ranking/PanelState.cs index 94e2c7cef4..3af74fe0f3 100644 --- a/osu.Game/Screens/Ranking/PanelState.cs +++ b/osu.Game/Screens/Ranking/PanelState.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Screens.Ranking { public enum PanelState diff --git a/osu.Game/Screens/Ranking/ReplayDownloadButton.cs b/osu.Game/Screens/Ranking/ReplayDownloadButton.cs index 0c9c909395..5c5cb61b79 100644 --- a/osu.Game/Screens/Ranking/ReplayDownloadButton.cs +++ b/osu.Game/Screens/Ranking/ReplayDownloadButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -13,7 +15,7 @@ using osuTK; namespace osu.Game.Screens.Ranking { - public class ReplayDownloadButton : CompositeDrawable + public partial class ReplayDownloadButton : CompositeDrawable { public readonly Bindable Score = new Bindable(); @@ -31,7 +33,7 @@ namespace osu.Game.Screens.Ranking if (State.Value == DownloadState.LocallyAvailable) return ReplayAvailability.Local; - if (!string.IsNullOrEmpty(Score.Value?.Hash)) + if (Score.Value?.HasReplay == true) return ReplayAvailability.Online; return ReplayAvailability.NotAvailable; diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs index 98514cd846..78239e0dbe 100644 --- a/osu.Game/Screens/Ranking/ResultsScreen.cs +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -1,10 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -25,7 +29,7 @@ using osuTK; namespace osu.Game.Screens.Ranking { - public abstract class ResultsScreen : ScreenWithBeatmapBackground, IKeyBindingHandler + public abstract partial class ResultsScreen : ScreenWithBeatmapBackground, IKeyBindingHandler { protected const float BACKGROUND_BLUR = 20; private static readonly float screen_height = 768 - TwoLayerButton.SIZE_EXTENDED.Y; @@ -58,6 +62,8 @@ namespace osu.Game.Screens.Ranking private readonly bool allowRetry; private readonly bool allowWatchingReplay; + private Sample popInSample; + protected ResultsScreen(ScoreInfo score, bool allowRetry, bool allowWatchingReplay = true) { Score = score; @@ -68,10 +74,12 @@ namespace osu.Game.Screens.Ranking } [BackgroundDependencyLoader] - private void load() + private void load(AudioManager audio) { FillFlowContainer buttons; + popInSample = audio.Samples.Get(@"UI/overlay-pop-in"); + InternalChild = new GridContainer { RelativeSizeAxes = Axes.Both, @@ -88,11 +96,11 @@ namespace osu.Game.Screens.Ranking RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - statisticsPanel = new StatisticsPanel + statisticsPanel = CreateStatisticsPanel().With(panel => { - RelativeSizeAxes = Axes.Both, - Score = { BindTarget = SelectedScore } - }, + panel.RelativeSizeAxes = Axes.Both; + panel.Score.BindTarget = SelectedScore; + }), ScorePanelList = new ScorePanelList { RelativeSizeAxes = Axes.Both, @@ -169,7 +177,7 @@ namespace osu.Game.Screens.Ranking { if (!this.IsCurrentScreen()) return; - player?.Restart(); + player?.Restart(true); }, }); } @@ -223,6 +231,11 @@ namespace osu.Game.Screens.Ranking /// An responsible for the fetch operation. This will be queued and performed automatically. protected virtual APIRequest FetchNextPage(int direction, Action> scoresCallback) => null; + /// + /// Creates the to be used to display extended information about scores. + /// + protected virtual StatisticsPanel CreateStatisticsPanel() => new StatisticsPanel(); + private void fetchScoresCallback(IEnumerable scores) => Schedule(() => { foreach (var s in scores) @@ -242,6 +255,8 @@ namespace osu.Game.Screens.Ranking }); bottomPanel.FadeTo(1, 250); + + popInSample?.Play(); } public override bool OnExiting(ScreenExitEvent e) @@ -307,7 +322,7 @@ namespace osu.Game.Screens.Ranking var screenSpacePos = detachedPanel.ScreenSpaceDrawQuad.TopLeft; // Remove from the local container and re-attach. - detachedPanelContainer.Remove(detachedPanel); + detachedPanelContainer.Remove(detachedPanel, false); ScorePanelList.Attach(detachedPanel); // Move into its original location in the attached container first, then to the final location. @@ -347,7 +362,7 @@ namespace osu.Game.Screens.Ranking { } - protected class VerticalScrollContainer : OsuScrollContainer + protected partial class VerticalScrollContainer : OsuScrollContainer { protected override Container Content => content; diff --git a/osu.Game/Screens/Ranking/RetryButton.cs b/osu.Game/Screens/Ranking/RetryButton.cs index 59b69bc949..c7d2416e29 100644 --- a/osu.Game/Screens/Ranking/RetryButton.cs +++ b/osu.Game/Screens/Ranking/RetryButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; @@ -12,7 +14,7 @@ using osuTK; namespace osu.Game.Screens.Ranking { - public class RetryButton : OsuAnimatedButton + public partial class RetryButton : OsuAnimatedButton { private readonly Box background; @@ -35,7 +37,7 @@ namespace osu.Game.Screens.Ranking Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(13), - Icon = FontAwesome.Solid.ArrowCircleLeft, + Icon = FontAwesome.Solid.Redo, }, }; diff --git a/osu.Game/Screens/Ranking/ScorePanel.cs b/osu.Game/Screens/Ranking/ScorePanel.cs index bc6eb9e366..1d332d6b27 100644 --- a/osu.Game/Screens/Ranking/ScorePanel.cs +++ b/osu.Game/Screens/Ranking/ScorePanel.cs @@ -1,16 +1,21 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework; using osu.Framework.Allocation; +using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Audio; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Utils; using osu.Game.Scoring; using osu.Game.Screens.Ranking.Contracted; using osu.Game.Screens.Ranking.Expanded; @@ -20,7 +25,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Ranking { - public class ScorePanel : CompositeDrawable, IStateful + public partial class ScorePanel : CompositeDrawable, IStateful { /// /// Width of the panel when contracted. @@ -91,9 +96,12 @@ namespace osu.Game.Screens.Ranking public readonly ScoreInfo Score; - private bool displayWithFlair; + [Resolved] + private OsuGameBase game { get; set; } - private Container content; + private AudioContainer audioContent; + + private bool displayWithFlair; private Container topLayerContainer; private Drawable topLayerBackground; @@ -105,6 +113,8 @@ namespace osu.Game.Screens.Ranking private Container middleLayerContentContainer; private Drawable middleLayerContent; + private DrawableSample samplePanelFocus; + public ScorePanel(ScoreInfo score, bool isNewLocalScore = false) { Score = score; @@ -114,13 +124,13 @@ namespace osu.Game.Screens.Ranking } [BackgroundDependencyLoader] - private void load() + private void load(AudioManager audio) { // ScorePanel doesn't include the top extruding area in its own size. // Adding a manual offset here allows the expanded version to take on an "acceptable" vertical centre when at 100% UI scale. const float vertical_fudge = 20; - InternalChild = content = new Container + InternalChild = audioContent = new AudioContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -172,7 +182,8 @@ namespace osu.Game.Screens.Ranking }, middleLayerContentContainer = new Container { RelativeSizeAxes = Axes.Both } } - } + }, + samplePanelFocus = new DrawableSample(audio.Samples.Get(@"Results/score-panel-focus")) } }; } @@ -200,12 +211,32 @@ namespace osu.Game.Screens.Ranking state = value; if (IsLoaded) + { updateState(); + if (value == PanelState.Expanded) + playAppearSample(); + } + StateChanged?.Invoke(value); } } + protected override void Update() + { + base.Update(); + audioContent.Balance.Value = ((ScreenSpaceDrawQuad.Centre.X / game.ScreenSpaceDrawQuad.Width) * 2 - 1) * OsuGameBase.SFX_STEREO_STRENGTH; + } + + private void playAppearSample() + { + var channel = samplePanelFocus?.GetChannel(); + if (channel == null) return; + + channel.Frequency.Value = 0.99 + RNG.NextDouble(0.2); + channel.Play(); + } + private void updateState() { topLayerContent?.FadeOut(content_fade_duration).Expire(); @@ -219,7 +250,8 @@ namespace osu.Game.Screens.Ranking topLayerBackground.FadeColour(expanded_top_layer_colour, RESIZE_DURATION, Easing.OutQuint); middleLayerBackground.FadeColour(expanded_middle_layer_colour, RESIZE_DURATION, Easing.OutQuint); - topLayerContentContainer.Add(topLayerContent = new ExpandedPanelTopContent(Score.User) { Alpha = 0 }); + bool firstLoad = topLayerContent == null; + topLayerContentContainer.Add(topLayerContent = new ExpandedPanelTopContent(Score.User, firstLoad) { Alpha = 0 }); middleLayerContentContainer.Add(middleLayerContent = new ExpandedPanelMiddleContent(Score, displayWithFlair) { Alpha = 0 }); // only the first expanded display should happen with flair. @@ -242,7 +274,7 @@ namespace osu.Game.Screens.Ranking break; } - content.ResizeTo(Size, RESIZE_DURATION, Easing.OutQuint); + audioContent.ResizeTo(Size, RESIZE_DURATION, Easing.OutQuint); bool topLayerExpanded = topLayerContainer.Y < 0; diff --git a/osu.Game/Screens/Ranking/ScorePanelList.cs b/osu.Game/Screens/Ranking/ScorePanelList.cs index a5341242e2..29dec42083 100644 --- a/osu.Game/Screens/Ranking/ScorePanelList.cs +++ b/osu.Game/Screens/Ranking/ScorePanelList.cs @@ -1,16 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; @@ -21,7 +21,7 @@ using osuTK.Input; namespace osu.Game.Screens.Ranking { - public class ScorePanelList : CompositeDrawable + public partial class ScorePanelList : CompositeDrawable { /// /// Normal spacing between all panels. @@ -149,32 +149,27 @@ namespace osu.Game.Screens.Ranking var score = trackingContainer.Panel.Score; - // Calculating score can take a while in extreme scenarios, so only display scores after the process completes. - scoreManager.GetTotalScoreAsync(score) - .ContinueWith(task => Schedule(() => - { - flow.SetLayoutPosition(trackingContainer, task.GetResultSafely()); + flow.SetLayoutPosition(trackingContainer, scoreManager.GetTotalScore(score)); - trackingContainer.Show(); + trackingContainer.Show(); - if (SelectedScore.Value?.Equals(score) == true) - { - SelectedScore.TriggerChange(); - } - else - { - // We want the scroll position to remain relative to the expanded panel. When a new panel is added after the expanded panel, nothing needs to be done. - // But when a panel is added before the expanded panel, we need to offset the scroll position by the width of the new panel. - if (expandedPanel != null && flow.GetPanelIndex(score) < flow.GetPanelIndex(expandedPanel.Score)) - { - // A somewhat hacky property is used here because we need to: - // 1) Scroll after the scroll container's visible range is updated. - // 2) Scroll before the scroll container's scroll position is updated. - // Without this, we would have a 1-frame positioning error which looks very jarring. - scroll.InstantScrollTarget = (scroll.InstantScrollTarget ?? scroll.Target) + ScorePanel.CONTRACTED_WIDTH + panel_spacing; - } - } - }), TaskContinuationOptions.OnlyOnRanToCompletion); + if (SelectedScore.Value?.Equals(score) == true) + { + SelectedScore.TriggerChange(); + } + else + { + // We want the scroll position to remain relative to the expanded panel. When a new panel is added after the expanded panel, nothing needs to be done. + // But when a panel is added before the expanded panel, we need to offset the scroll position by the width of the new panel. + if (expandedPanel != null && flow.GetPanelIndex(score) < flow.GetPanelIndex(expandedPanel.Score)) + { + // A somewhat hacky property is used here because we need to: + // 1) Scroll after the scroll container's visible range is updated. + // 2) Scroll before the scroll container's scroll position is updated. + // Without this, we would have a 1-frame positioning error which looks very jarring. + scroll.InstantScrollTarget = (scroll.InstantScrollTarget ?? scroll.Target) + ScorePanel.CONTRACTED_WIDTH + panel_spacing; + } + } } /// @@ -327,7 +322,7 @@ namespace osu.Game.Screens.Ranking loadCancellationSource?.Cancel(); } - private class Flow : FillFlowContainer + private partial class Flow : FillFlowContainer { public override IEnumerable FlowingChildren => applySorting(AliveInternalChildren); @@ -344,7 +339,7 @@ namespace osu.Game.Screens.Ranking .ThenBy(s => s.Panel.Score.OnlineID); } - private class Scroll : OsuScrollContainer + private partial class Scroll : OsuScrollContainer { public new float Target => base.Target; diff --git a/osu.Game/Screens/Ranking/ScorePanelTrackingContainer.cs b/osu.Game/Screens/Ranking/ScorePanelTrackingContainer.cs index c8010d1c32..ec153cbd63 100644 --- a/osu.Game/Screens/Ranking/ScorePanelTrackingContainer.cs +++ b/osu.Game/Screens/Ranking/ScorePanelTrackingContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Graphics.Containers; @@ -9,7 +11,7 @@ namespace osu.Game.Screens.Ranking /// /// A which tracks the size of a , to which the can be added or removed. /// - public class ScorePanelTrackingContainer : CompositeDrawable + public partial class ScorePanelTrackingContainer : CompositeDrawable { /// /// The that created this . @@ -32,7 +34,7 @@ namespace osu.Game.Screens.Ranking if (InternalChildren.Count == 0) throw new InvalidOperationException("Score panel container is not attached."); - RemoveInternal(Panel); + RemoveInternal(Panel, false); } /// diff --git a/osu.Game/Screens/Ranking/SoloResultsScreen.cs b/osu.Game/Screens/Ranking/SoloResultsScreen.cs index 2ec6c38287..c8920a734d 100644 --- a/osu.Game/Screens/Ranking/SoloResultsScreen.cs +++ b/osu.Game/Screens/Ranking/SoloResultsScreen.cs @@ -5,33 +5,69 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Online.API.Requests; +using osu.Game.Online.Solo; using osu.Game.Rulesets; using osu.Game.Scoring; +using osu.Game.Screens.Ranking.Statistics; namespace osu.Game.Screens.Ranking { - public class SoloResultsScreen : ResultsScreen + public partial class SoloResultsScreen : ResultsScreen { - private GetScoresRequest getScoreRequest; + /// + /// Whether the user's personal statistics should be shown on the extended statistics panel + /// after clicking the score panel associated with the being presented. + /// + public bool ShowUserStatistics { get; init; } + + private GetScoresRequest? getScoreRequest; [Resolved] - private RulesetStore rulesets { get; set; } + private RulesetStore rulesets { get; set; } = null!; + + [Resolved] + private SoloStatisticsWatcher soloStatisticsWatcher { get; set; } = null!; + + private IDisposable? statisticsSubscription; + private readonly Bindable statisticsUpdate = new Bindable(); public SoloResultsScreen(ScoreInfo score, bool allowRetry) : base(score, allowRetry) { } - protected override APIRequest FetchScores(Action> scoresCallback) + protected override void LoadComplete() + { + base.LoadComplete(); + + if (ShowUserStatistics) + statisticsSubscription = soloStatisticsWatcher.RegisterForStatisticsUpdateAfter(Score, update => statisticsUpdate.Value = update); + } + + protected override StatisticsPanel CreateStatisticsPanel() + { + if (ShowUserStatistics) + { + return new SoloStatisticsPanel(Score) + { + StatisticsUpdate = { BindTarget = statisticsUpdate } + }; + } + + return base.CreateStatisticsPanel(); + } + + protected override APIRequest? FetchScores(Action>? scoresCallback) { if (Score.BeatmapInfo.OnlineID <= 0 || Score.BeatmapInfo.Status <= BeatmapOnlineStatus.Pending) return null; getScoreRequest = new GetScoresRequest(Score.BeatmapInfo, Score.Ruleset); - getScoreRequest.Success += r => scoresCallback?.Invoke(r.Scores.Where(s => s.OnlineID != Score.OnlineID).Select(s => s.CreateScoreInfo(rulesets, Beatmap.Value.BeatmapInfo))); + getScoreRequest.Success += r => scoresCallback?.Invoke(r.Scores.Where(s => s.OnlineID != Score.OnlineID).Select(s => s.ToScoreInfo(rulesets, Beatmap.Value.BeatmapInfo))); return getScoreRequest; } @@ -40,6 +76,7 @@ namespace osu.Game.Screens.Ranking base.Dispose(isDisposing); getScoreRequest?.Cancel(); + statisticsSubscription?.Dispose(); } } } diff --git a/osu.Game/Screens/Ranking/Statistics/AverageHitError.cs b/osu.Game/Screens/Ranking/Statistics/AverageHitError.cs index d0e70251e7..bb9905d29c 100644 --- a/osu.Game/Screens/Ranking/Statistics/AverageHitError.cs +++ b/osu.Game/Screens/Ranking/Statistics/AverageHitError.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using osu.Game.Rulesets.Scoring; @@ -10,7 +12,7 @@ namespace osu.Game.Screens.Ranking.Statistics /// /// Displays the unstable rate statistic for a given play. /// - public class AverageHitError : SimpleStatisticItem + public partial class AverageHitError : SimpleStatisticItem { /// /// Creates and computes an statistic. diff --git a/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs index f7c9d36cc4..6b1850002d 100644 --- a/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs +++ b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -19,7 +18,7 @@ namespace osu.Game.Screens.Ranking.Statistics /// /// A graph which displays the distribution of hit timing in a series of s. /// - public class HitEventTimingDistributionGraph : CompositeDrawable + public partial class HitEventTimingDistributionGraph : CompositeDrawable { /// /// The number of bins on each side of the timing distribution. @@ -46,6 +45,12 @@ namespace osu.Game.Screens.Ranking.Statistics /// private readonly IReadOnlyList hitEvents; + private readonly IDictionary[] bins; + private double binSize; + private double hitOffset; + + private Bar[]? barDrawables; + /// /// Creates a new . /// @@ -53,22 +58,15 @@ namespace osu.Game.Screens.Ranking.Statistics public HitEventTimingDistributionGraph(IReadOnlyList hitEvents) { this.hitEvents = hitEvents.Where(e => !(e.HitObject.HitWindows is HitWindows.EmptyHitWindows) && e.Result.IsHit()).ToList(); + bins = Enumerable.Range(0, total_timing_distribution_bins).Select(_ => new Dictionary()).ToArray>(); } - private int[] bins; - private double binSize; - private double hitOffset; - - private Bar[] barDrawables; - [BackgroundDependencyLoader] private void load() { - if (hitEvents == null || hitEvents.Count == 0) + if (hitEvents.Count == 0) return; - bins = new int[total_timing_distribution_bins]; - binSize = Math.Ceiling(hitEvents.Max(e => Math.Abs(e.TimeOffset)) / timing_distribution_bins); // Prevent div-by-0 by enforcing a minimum bin size @@ -87,7 +85,8 @@ namespace osu.Game.Screens.Ranking.Statistics { bool roundUp = true; - Array.Clear(bins, 0, bins.Length); + foreach (var bin in bins) + bin.Clear(); foreach (var e in hitEvents) { @@ -108,23 +107,23 @@ namespace osu.Game.Screens.Ranking.Statistics // may be out of range when applying an offset. for such cases we can just drop the results. if (index >= 0 && index < bins.Length) - bins[index]++; + { + bins[index].TryGetValue(e.Result, out int value); + bins[index][e.Result] = ++value; + } } if (barDrawables != null) { for (int i = 0; i < barDrawables.Length; i++) { - barDrawables[i].UpdateOffset(bins[i]); + barDrawables[i].UpdateOffset(bins[i].Sum(b => b.Value)); } } else { - int maxCount = bins.Max(); - barDrawables = new Bar[total_timing_distribution_bins]; - - for (int i = 0; i < barDrawables.Length; i++) - barDrawables[i] = new Bar(bins[i], maxCount, i == timing_distribution_centre_bin_index); + int maxCount = bins.Max(b => b.Values.Sum()); + barDrawables = bins.Select((bin, i) => new Bar(bins[i], maxCount, i == timing_distribution_centre_bin_index)).ToArray(); Container axisFlow; @@ -205,52 +204,104 @@ namespace osu.Game.Screens.Ranking.Statistics } } - private class Bar : CompositeDrawable + private partial class Bar : CompositeDrawable { - private readonly float value; + private readonly IReadOnlyList> values; private readonly float maxValue; + private readonly bool isCentre; + private readonly float totalValue; - private readonly Circle boxOriginal; - private Circle boxAdjustment; + private float basalHeight; + private float offsetAdjustment; - private const float minimum_height = 0.05f; + private Circle[] boxOriginals = null!; - public Bar(float value, float maxValue, bool isCentre) + private Circle? boxAdjustment; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + private const double duration = 300; + + public Bar(IDictionary values, float maxValue, bool isCentre) { - this.value = value; + this.values = values.OrderBy(v => v.Key.GetIndexForOrderedDisplay()).ToList(); this.maxValue = maxValue; + this.isCentre = isCentre; + totalValue = values.Sum(v => v.Value); + offsetAdjustment = totalValue; RelativeSizeAxes = Axes.Both; Masking = true; + } - InternalChildren = new Drawable[] + [BackgroundDependencyLoader] + private void load() + { + if (values.Any()) { - boxOriginal = new Circle + boxOriginals = values.Select((v, i) => new Circle { RelativeSizeAxes = Axes.Both, Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, - Colour = isCentre ? Color4.White : Color4Extensions.FromHex("#66FFCC"), - Height = minimum_height, - }, - }; + Colour = isCentre && i == 0 ? Color4.White : colours.ForHitResult(v.Key), + Height = 0, + }).ToArray(); + // The bars of the stacked bar graph will be processed (stacked) from the bottom, which is the base position, + // to the top, and the bottom bar should be drawn more toward the front by design, + // while the drawing order is from the back to the front, so the order passed to `InternalChildren` is the opposite. + InternalChildren = boxOriginals.Reverse().ToArray(); + } + else + { + // A bin with no value draws a grey dot instead. + Circle dot = new Circle + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Colour = isCentre ? Color4.White : Color4.Gray, + Height = 0, + }; + InternalChildren = boxOriginals = new[] { dot }; + } } - private const double duration = 300; - protected override void LoadComplete() { base.LoadComplete(); - float height = Math.Clamp(value / maxValue, minimum_height, 1); + if (!values.Any()) + return; - if (height > minimum_height) - boxOriginal.ResizeHeightTo(height, duration, Easing.OutQuint); + updateBasalHeight(); + + foreach (var boxOriginal in boxOriginals) + { + boxOriginal.Y = 0; + boxOriginal.Height = basalHeight; + } + + float offsetValue = 0; + + for (int i = 0; i < values.Count; i++) + { + boxOriginals[i].MoveToY(offsetForValue(offsetValue) * BoundingBox.Height, duration, Easing.OutQuint); + boxOriginals[i].ResizeHeightTo(heightForValue(values[i].Value), duration, Easing.OutQuint); + offsetValue -= values[i].Value; + } + } + + protected override void Update() + { + base.Update(); + updateBasalHeight(); } public void UpdateOffset(float adjustment) { - bool hasAdjustment = adjustment != value && adjustment / maxValue >= minimum_height; + bool hasAdjustment = adjustment != totalValue; if (boxAdjustment == null) { @@ -269,7 +320,53 @@ namespace osu.Game.Screens.Ranking.Statistics }); } - boxAdjustment.ResizeHeightTo(Math.Clamp(adjustment / maxValue, minimum_height, 1), duration, Easing.OutQuint); + offsetAdjustment = adjustment; + drawAdjustmentBar(); + } + + private void updateBasalHeight() + { + float newBasalHeight = DrawHeight > DrawWidth ? DrawWidth / DrawHeight : 1; + + if (newBasalHeight == basalHeight) + return; + + basalHeight = newBasalHeight; + foreach (var dot in boxOriginals) + dot.Height = basalHeight; + + draw(); + } + + private float offsetForValue(float value) => (1 - basalHeight) * value / maxValue; + + private float heightForValue(float value) => MathF.Max(basalHeight + offsetForValue(value), 0); + + private void draw() + { + resizeBars(); + + if (boxAdjustment != null) + drawAdjustmentBar(); + } + + private void resizeBars() + { + float offsetValue = 0; + + for (int i = 0; i < values.Count; i++) + { + boxOriginals[i].Y = offsetForValue(offsetValue) * DrawHeight; + boxOriginals[i].Height = heightForValue(values[i].Value); + offsetValue -= values[i].Value; + } + } + + private void drawAdjustmentBar() + { + bool hasAdjustment = offsetAdjustment != totalValue; + + boxAdjustment.ResizeHeightTo(heightForValue(offsetAdjustment), duration, Easing.OutQuint); boxAdjustment.FadeTo(!hasAdjustment ? 0 : 1, duration, Easing.OutQuint); } } diff --git a/osu.Game/Screens/Ranking/Statistics/PerformanceBreakdownChart.cs b/osu.Game/Screens/Ranking/Statistics/PerformanceBreakdownChart.cs index 5b42554716..10cb77fa91 100644 --- a/osu.Game/Screens/Ranking/Statistics/PerformanceBreakdownChart.cs +++ b/osu.Game/Screens/Ranking/Statistics/PerformanceBreakdownChart.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -24,7 +26,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Ranking.Statistics { - public class PerformanceBreakdownChart : Container + public partial class PerformanceBreakdownChart : Container { private readonly ScoreInfo score; private readonly IBeatmap playableBeatmap; diff --git a/osu.Game/Screens/Ranking/Statistics/SimpleStatisticItem.cs b/osu.Game/Screens/Ranking/Statistics/SimpleStatisticItem.cs index 6fe7e4eda8..99f4e1e342 100644 --- a/osu.Game/Screens/Ranking/Statistics/SimpleStatisticItem.cs +++ b/osu.Game/Screens/Ranking/Statistics/SimpleStatisticItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; @@ -12,7 +14,7 @@ namespace osu.Game.Screens.Ranking.Statistics /// Represents a simple statistic item (one that only needs textual display). /// Richer visualisations should be done with s. /// - public abstract class SimpleStatisticItem : Container + public abstract partial class SimpleStatisticItem : Container { /// /// The text to display as the statistic's value. @@ -57,7 +59,7 @@ namespace osu.Game.Screens.Ranking.Statistics /// /// Strongly-typed generic specialisation for . /// - public class SimpleStatisticItem : SimpleStatisticItem + public partial class SimpleStatisticItem : SimpleStatisticItem { private TValue value; diff --git a/osu.Game/Screens/Ranking/Statistics/SimpleStatisticTable.cs b/osu.Game/Screens/Ranking/Statistics/SimpleStatisticTable.cs index 8b503cc04e..d10888be43 100644 --- a/osu.Game/Screens/Ranking/Statistics/SimpleStatisticTable.cs +++ b/osu.Game/Screens/Ranking/Statistics/SimpleStatisticTable.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -17,7 +19,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. /// - public class SimpleStatisticTable : CompositeDrawable + public partial class SimpleStatisticTable : CompositeDrawable { private readonly SimpleStatisticItem[] items; private readonly int columnCount; @@ -96,7 +98,7 @@ namespace osu.Game.Screens.Ranking.Statistics Direction = FillDirection.Vertical }; - private class Spacer : CompositeDrawable + private partial class Spacer : CompositeDrawable { public Spacer() { diff --git a/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs new file mode 100644 index 0000000000..57d072b7de --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs @@ -0,0 +1,54 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Online.Solo; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking.Statistics.User; + +namespace osu.Game.Screens.Ranking.Statistics +{ + public partial class SoloStatisticsPanel : StatisticsPanel + { + private readonly ScoreInfo achievedScore; + + public SoloStatisticsPanel(ScoreInfo achievedScore) + { + this.achievedScore = achievedScore; + } + + public Bindable StatisticsUpdate { get; } = new Bindable(); + + protected override ICollection CreateStatisticRows(ScoreInfo newScore, IBeatmap playableBeatmap) + { + var rows = base.CreateStatisticRows(newScore, playableBeatmap); + + if (newScore.UserID > 1 + && newScore.UserID == achievedScore.UserID + && newScore.OnlineID > 0 + && newScore.OnlineID == achievedScore.OnlineID) + { + rows = rows.Append(new StatisticRow + { + Columns = new[] + { + new StatisticItem("Overall Ranking", () => new OverallRanking + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f, + StatisticsUpdate = { BindTarget = StatisticsUpdate } + }) + } + }).ToArray(); + } + + return rows; + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticContainer.cs b/osu.Game/Screens/Ranking/Statistics/StatisticContainer.cs index 79f813ef64..d3327224dc 100644 --- a/osu.Game/Screens/Ranking/Statistics/StatisticContainer.cs +++ b/osu.Game/Screens/Ranking/Statistics/StatisticContainer.cs @@ -1,11 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Diagnostics.CodeAnalysis; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK; @@ -15,7 +18,7 @@ namespace osu.Game.Screens.Ranking.Statistics /// /// Wraps a to add a header and suitable layout for use in . /// - internal class StatisticContainer : CompositeDrawable + internal partial class StatisticContainer : CompositeDrawable { /// /// Creates a new . @@ -57,7 +60,7 @@ namespace osu.Game.Screens.Ranking.Statistics private static Drawable createHeader(StatisticItem item) { - if (string.IsNullOrEmpty(item.Name)) + if (LocalisableString.IsNullOrEmpty(item.Name)) return Empty(); return new FillFlowContainer diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs b/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs index b43fbbdeee..5bbd260d3f 100644 --- a/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs +++ b/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs @@ -1,10 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using JetBrains.Annotations; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; namespace osu.Game.Screens.Ranking.Statistics { @@ -16,7 +19,7 @@ namespace osu.Game.Screens.Ranking.Statistics /// /// The name of this item. /// - public readonly string Name; + public readonly LocalisableString Name; /// /// A function returning the content to be displayed. @@ -33,20 +36,14 @@ namespace osu.Game.Screens.Ranking.Statistics /// public readonly bool RequiresHitEvents; - [Obsolete("Use constructor which takes creation function instead.")] // Can be removed 20220803. - public StatisticItem([NotNull] string name, [NotNull] Drawable content, [CanBeNull] Dimension dimension = null) - : this(name, () => content, true, dimension) - { - } - /// /// Creates a new , to be displayed inside a in the results screen. /// - /// The name of the item. Can be to hide the item header. + /// 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([NotNull] string name, [NotNull] Func createContent, bool requiresHitEvents = false, [CanBeNull] Dimension dimension = null) + public StatisticItem(LocalisableString name, [NotNull] Func createContent, bool requiresHitEvents = false, [CanBeNull] Dimension dimension = null) { Name = name; RequiresHitEvents = requiresHitEvents; diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticRow.cs b/osu.Game/Screens/Ranking/Statistics/StatisticRow.cs index e1ca9799a3..9f5f44918e 100644 --- a/osu.Game/Screens/Ranking/Statistics/StatisticRow.cs +++ b/osu.Game/Screens/Ranking/Statistics/StatisticRow.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using JetBrains.Annotations; namespace osu.Game.Screens.Ranking.Statistics diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs index 898bd69b2c..4c22afd8f7 100644 --- a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs +++ b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs @@ -1,12 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; @@ -19,7 +24,7 @@ using osuTK; namespace osu.Game.Screens.Ranking.Statistics { - public class StatisticsPanel : VisibilityContainer + public partial class StatisticsPanel : VisibilityContainer { public const float SIDE_PADDING = 30; @@ -33,6 +38,10 @@ namespace osu.Game.Screens.Ranking.Statistics private readonly Container content; private readonly LoadingSpinner spinner; + private bool wasOpened; + private Sample popInSample; + private Sample popOutSample; + public StatisticsPanel() { InternalChild = new Container @@ -54,9 +63,12 @@ namespace osu.Game.Screens.Ranking.Statistics } [BackgroundDependencyLoader] - private void load() + private void load(AudioManager audio) { Score.BindValueChanged(populateStatistics, true); + + popInSample = audio.Samples.Get(@"Results/statistics-panel-pop-in"); + popOutSample = audio.Samples.Get(@"Results/statistics-panel-pop-out"); } private CancellationTokenSource loadCancellation; @@ -79,18 +91,16 @@ namespace osu.Game.Screens.Ranking.Statistics spinner.Show(); var localCancellationSource = loadCancellation = new CancellationTokenSource(); - IBeatmap playableBeatmap = null; + + var workingBeatmap = beatmapManager.GetWorkingBeatmap(newScore.BeatmapInfo); // Todo: The placement of this is temporary. Eventually we'll both generate the playable beatmap _and_ run through it in a background task to generate the hit events. - Task.Run(() => - { - playableBeatmap = beatmapManager.GetWorkingBeatmap(newScore.BeatmapInfo).GetPlayableBeatmap(newScore.Ruleset, newScore.Mods); - }, loadCancellation.Token).ContinueWith(t => Schedule(() => + Task.Run(() => workingBeatmap.GetPlayableBeatmap(newScore.Ruleset, newScore.Mods), loadCancellation.Token).ContinueWith(task => Schedule(() => { bool hitEventsAvailable = newScore.HitEvents.Count != 0; Container container; - var statisticRows = newScore.Ruleset.CreateInstance().CreateStatisticsForScore(newScore, playableBeatmap); + var statisticRows = CreateStatisticRows(newScore, task.GetResultSafely()); if (!hitEventsAvailable && statisticRows.SelectMany(r => r.Columns).All(c => c.RequiresHitEvents)) { @@ -208,15 +218,35 @@ namespace osu.Game.Screens.Ranking.Statistics }), localCancellationSource.Token); } + /// + /// 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) + => newScore.Ruleset.CreateInstance().CreateStatisticsForScore(newScore, playableBeatmap); + protected override bool OnClick(ClickEvent e) { ToggleVisibility(); return true; } - protected override void PopIn() => this.FadeIn(150, Easing.OutQuint); + protected override void PopIn() + { + this.FadeIn(150, Easing.OutQuint); - protected override void PopOut() => this.FadeOut(150, Easing.OutQuint); + popInSample?.Play(); + wasOpened = true; + } + + protected override void PopOut() + { + this.FadeOut(150, Easing.OutQuint); + + if (wasOpened) + popOutSample?.Play(); + } protected override void Dispose(bool isDisposing) { diff --git a/osu.Game/Screens/Ranking/Statistics/UnstableRate.cs b/osu.Game/Screens/Ranking/Statistics/UnstableRate.cs index 0d23490f40..de01668029 100644 --- a/osu.Game/Screens/Ranking/Statistics/UnstableRate.cs +++ b/osu.Game/Screens/Ranking/Statistics/UnstableRate.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Game.Rulesets.Scoring; @@ -9,7 +11,7 @@ namespace osu.Game.Screens.Ranking.Statistics /// /// Displays the unstable rate statistic for a given play. /// - public class UnstableRate : SimpleStatisticItem + public partial class UnstableRate : SimpleStatisticItem { /// /// Creates and computes an statistic. diff --git a/osu.Game/Screens/Ranking/Statistics/User/AccuracyChangeRow.cs b/osu.Game/Screens/Ranking/Statistics/User/AccuracyChangeRow.cs new file mode 100644 index 0000000000..0fd666e9d0 --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/User/AccuracyChangeRow.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Utils; + +namespace osu.Game.Screens.Ranking.Statistics.User +{ + public partial class AccuracyChangeRow : RankingChangeRow + { + public AccuracyChangeRow() + : base(stats => stats.Accuracy) + { + } + + protected override LocalisableString Label => UsersStrings.ShowStatsHitAccuracy; + + protected override LocalisableString FormatCurrentValue(double current) => (current / 100).FormatAccuracy(); + + protected override int CalculateDifference(double previous, double current, out LocalisableString formattedDifference) + { + double difference = (current - previous) / 100; + + if (difference < 0) + formattedDifference = difference.FormatAccuracy(); + else if (difference > 0) + formattedDifference = LocalisableString.Interpolate($@"+{difference.FormatAccuracy()}"); + else + formattedDifference = string.Empty; + + return current.CompareTo(previous); + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/User/GlobalRankChangeRow.cs b/osu.Game/Screens/Ranking/Statistics/User/GlobalRankChangeRow.cs new file mode 100644 index 0000000000..0d91d6f8f9 --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/User/GlobalRankChangeRow.cs @@ -0,0 +1,58 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Utils; + +namespace osu.Game.Screens.Ranking.Statistics.User +{ + public partial class GlobalRankChangeRow : RankingChangeRow + { + public GlobalRankChangeRow() + : base(stats => stats.GlobalRank) + { + } + + protected override LocalisableString Label => UsersStrings.ShowRankGlobalSimple; + + protected override LocalisableString FormatCurrentValue(int? current) + => current == null ? string.Empty : current.Value.FormatRank(); + + protected override int CalculateDifference(int? previous, int? current, out LocalisableString formattedDifference) + { + if (previous == null && current == null) + { + formattedDifference = string.Empty; + return 0; + } + + if (previous == null && current != null) + { + formattedDifference = LocalisableString.Interpolate($"+{current.Value.FormatRank()}"); + return 1; + } + + if (previous != null && current == null) + { + formattedDifference = LocalisableString.Interpolate($"-{previous.Value.FormatRank()}"); + return -1; + } + + Debug.Assert(previous != null && current != null); + + // note that ranks work backwards, i.e. lower rank is _better_. + int difference = previous.Value - current.Value; + + if (difference < 0) + formattedDifference = difference.FormatRank(); + else if (difference > 0) + formattedDifference = LocalisableString.Interpolate($"+{difference.FormatRank()}"); + else + formattedDifference = string.Empty; + + return difference; + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/User/MaximumComboChangeRow.cs b/osu.Game/Screens/Ranking/Statistics/User/MaximumComboChangeRow.cs new file mode 100644 index 0000000000..37e3cec52f --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/User/MaximumComboChangeRow.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Screens.Ranking.Statistics.User +{ + public partial class MaximumComboChangeRow : RankingChangeRow + { + public MaximumComboChangeRow() + : base(stats => stats.MaxCombo) + { + } + + protected override LocalisableString Label => UsersStrings.ShowStatsMaximumCombo; + + protected override LocalisableString FormatCurrentValue(int current) => LocalisableString.Interpolate($@"{current:N0}x"); + + protected override int CalculateDifference(int previous, int current, out LocalisableString formattedDifference) + { + int difference = current - previous; + + if (difference < 0) + formattedDifference = LocalisableString.Interpolate($@"{difference:N0}x"); + else if (difference > 0) + formattedDifference = LocalisableString.Interpolate($@"+{difference:N0}x"); + else + formattedDifference = string.Empty; + + return current.CompareTo(previous); + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/User/OverallRanking.cs b/osu.Game/Screens/Ranking/Statistics/User/OverallRanking.cs new file mode 100644 index 0000000000..447f206128 --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/User/OverallRanking.cs @@ -0,0 +1,78 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Solo; +using osuTK; + +namespace osu.Game.Screens.Ranking.Statistics.User +{ + public partial class OverallRanking : CompositeDrawable + { + private const float transition_duration = 300; + + public Bindable StatisticsUpdate { get; } = new Bindable(); + + private LoadingLayer loadingLayer = null!; + private FillFlowContainer content = null!; + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Y; + AutoSizeEasing = Easing.OutQuint; + AutoSizeDuration = transition_duration; + + InternalChildren = new Drawable[] + { + loadingLayer = new LoadingLayer(withBox: false) + { + RelativeSizeAxes = Axes.Both, + }, + content = new FillFlowContainer + { + AlwaysPresent = true, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + Children = new Drawable[] + { + new GlobalRankChangeRow { StatisticsUpdate = { BindTarget = StatisticsUpdate } }, + new AccuracyChangeRow { StatisticsUpdate = { BindTarget = StatisticsUpdate } }, + new MaximumComboChangeRow { StatisticsUpdate = { BindTarget = StatisticsUpdate } }, + new RankedScoreChangeRow { StatisticsUpdate = { BindTarget = StatisticsUpdate } }, + new TotalScoreChangeRow { StatisticsUpdate = { BindTarget = StatisticsUpdate } }, + new PerformancePointsChangeRow { StatisticsUpdate = { BindTarget = StatisticsUpdate } } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + StatisticsUpdate.BindValueChanged(onUpdateReceived, true); + FinishTransforms(true); + } + + private void onUpdateReceived(ValueChangedEvent update) + { + if (update.NewValue == null) + { + loadingLayer.Show(); + content.FadeOut(transition_duration, Easing.OutQuint); + } + else + { + loadingLayer.Hide(); + content.FadeIn(transition_duration, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/User/PerformancePointsChangeRow.cs b/osu.Game/Screens/Ranking/Statistics/User/PerformancePointsChangeRow.cs new file mode 100644 index 0000000000..c1faf1a3e3 --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/User/PerformancePointsChangeRow.cs @@ -0,0 +1,56 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Screens.Ranking.Statistics.User +{ + public partial class PerformancePointsChangeRow : RankingChangeRow + { + public PerformancePointsChangeRow() + : base(stats => stats.PP) + { + } + + protected override LocalisableString Label => RankingsStrings.StatPerformance; + + protected override LocalisableString FormatCurrentValue(decimal? current) + => current == null ? string.Empty : LocalisableString.Interpolate($@"{current:N0}pp"); + + protected override int CalculateDifference(decimal? previous, decimal? current, out LocalisableString formattedDifference) + { + if (previous == null && current == null) + { + formattedDifference = string.Empty; + return 0; + } + + if (previous == null && current != null) + { + formattedDifference = LocalisableString.Interpolate($"+{current.Value:N0}pp"); + return 1; + } + + if (previous != null && current == null) + { + formattedDifference = LocalisableString.Interpolate($"-{previous.Value:N0}pp"); + return -1; + } + + Debug.Assert(previous != null && current != null); + + decimal difference = current.Value - previous.Value; + + if (difference < 0) + formattedDifference = LocalisableString.Interpolate($@"{difference:N0}pp"); + else if (difference > 0) + formattedDifference = LocalisableString.Interpolate($@"+{difference:N0}pp"); + else + formattedDifference = string.Empty; + + return current.Value.CompareTo(previous.Value); + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/User/RankedScoreChangeRow.cs b/osu.Game/Screens/Ranking/Statistics/User/RankedScoreChangeRow.cs new file mode 100644 index 0000000000..1cdf22bd75 --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/User/RankedScoreChangeRow.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Screens.Ranking.Statistics.User +{ + public partial class RankedScoreChangeRow : RankingChangeRow + { + public RankedScoreChangeRow() + : base(stats => stats.RankedScore) + { + } + + protected override LocalisableString Label => UsersStrings.ShowStatsRankedScore; + + protected override LocalisableString FormatCurrentValue(long current) => current.ToLocalisableString(@"N0"); + + protected override int CalculateDifference(long previous, long current, out LocalisableString formattedDifference) + { + long difference = current - previous; + + if (difference < 0) + formattedDifference = difference.ToLocalisableString(@"N0"); + else if (difference > 0) + formattedDifference = LocalisableString.Interpolate($@"+{difference:N0}"); + else + formattedDifference = string.Empty; + + return current.CompareTo(previous); + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/User/RankingChangeRow.cs b/osu.Game/Screens/Ranking/Statistics/User/RankingChangeRow.cs new file mode 100644 index 0000000000..5348b4a522 --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/User/RankingChangeRow.cs @@ -0,0 +1,144 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Solo; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Screens.Ranking.Statistics.User +{ + public abstract partial class RankingChangeRow : CompositeDrawable + { + public Bindable StatisticsUpdate { get; } = new Bindable(); + + private readonly Func accessor; + + private OsuSpriteText currentValueText = null!; + private SpriteIcon changeIcon = null!; + private OsuSpriteText changeText = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + protected RankingChangeRow( + Func accessor) + { + this.accessor = accessor; + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChildren = new Drawable[] + { + new OsuSpriteText + { + Text = Label, + Font = OsuFont.Default.With(size: 18) + }, + new FillFlowContainer + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new FillFlowContainer + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(5), + Children = new Drawable[] + { + changeIcon = new SpriteIcon + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Size = new Vector2(18) + }, + currentValueText = new OsuSpriteText + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Font = OsuFont.Default.With(size: 18, weight: FontWeight.Bold) + }, + } + }, + changeText = new OsuSpriteText + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Font = OsuFont.Default.With(weight: FontWeight.Bold) + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + StatisticsUpdate.BindValueChanged(onStatisticsUpdate, true); + } + + private void onStatisticsUpdate(ValueChangedEvent statisticsUpdate) + { + var update = statisticsUpdate.NewValue; + + if (update == null) + return; + + T previousValue = accessor.Invoke(update.Before); + T currentValue = accessor.Invoke(update.After); + int comparisonResult = CalculateDifference(previousValue, currentValue, out var formattedDifference); + + Colour4 comparisonColour; + IconUsage icon; + + if (comparisonResult < 0) + { + comparisonColour = colours.Red1; + icon = FontAwesome.Solid.ArrowDown; + } + else if (comparisonResult > 0) + { + comparisonColour = colours.Lime1; + icon = FontAwesome.Solid.ArrowUp; + } + else + { + comparisonColour = colours.Orange1; + icon = FontAwesome.Solid.Minus; + } + + currentValueText.Text = FormatCurrentValue(currentValue); + + changeIcon.Icon = icon; + changeIcon.Colour = comparisonColour; + + changeText.Text = formattedDifference; + changeText.Colour = comparisonColour; + } + + protected abstract LocalisableString Label { get; } + + protected abstract LocalisableString FormatCurrentValue(T current); + protected abstract int CalculateDifference(T previous, T current, out LocalisableString formattedDifference); + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/User/TotalScoreChangeRow.cs b/osu.Game/Screens/Ranking/Statistics/User/TotalScoreChangeRow.cs new file mode 100644 index 0000000000..346de18e14 --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/User/TotalScoreChangeRow.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Screens.Ranking.Statistics.User +{ + public partial class TotalScoreChangeRow : RankingChangeRow + { + public TotalScoreChangeRow() + : base(stats => stats.TotalScore) + { + } + + protected override LocalisableString Label => UsersStrings.ShowStatsTotalScore; + + protected override LocalisableString FormatCurrentValue(long current) => current.ToLocalisableString(@"N0"); + + protected override int CalculateDifference(long previous, long current, out LocalisableString formattedDifference) + { + long difference = current - previous; + + if (difference < 0) + formattedDifference = difference.ToLocalisableString(@"N0"); + else if (difference > 0) + formattedDifference = LocalisableString.Interpolate($@"+{difference:N0}"); + else + formattedDifference = string.Empty; + + return current.CompareTo(previous); + } + } +} diff --git a/osu.Game/Screens/ScorePresentType.cs b/osu.Game/Screens/ScorePresentType.cs index 3216f92091..24105467f1 100644 --- a/osu.Game/Screens/ScorePresentType.cs +++ b/osu.Game/Screens/ScorePresentType.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Screens { public enum ScorePresentType diff --git a/osu.Game/Screens/ScreenWhiteBox.cs b/osu.Game/Screens/ScreenWhiteBox.cs index 3a9e7b8f18..25afe119be 100644 --- a/osu.Game/Screens/ScreenWhiteBox.cs +++ b/osu.Game/Screens/ScreenWhiteBox.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using osu.Framework.Screens; @@ -18,7 +20,7 @@ using osu.Framework.Graphics.Sprites; namespace osu.Game.Screens { - public class ScreenWhiteBox : OsuScreen + public partial class ScreenWhiteBox : OsuScreen { private readonly UnderConstructionMessage message; @@ -93,7 +95,7 @@ namespace osu.Game.Screens return new Color4(r, g, b, 255); } - private class ChildModeButton : TwoLayerButton + private partial class ChildModeButton : TwoLayerButton { public ChildModeButton() { @@ -103,7 +105,7 @@ namespace osu.Game.Screens } } - public class UnderConstructionMessage : CompositeDrawable + public partial class UnderConstructionMessage : CompositeDrawable { public FillFlowContainer TextContainer { get; } diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index e62b285966..6ba9843f7b 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -32,7 +32,7 @@ using Realms; namespace osu.Game.Screens.Select { - public class BeatmapCarousel : CompositeDrawable, IKeyBindingHandler + public partial class BeatmapCarousel : CompositeDrawable, IKeyBindingHandler { /// /// Height of the area above the carousel that should be treated as visible due to transparency of elements in front of it. @@ -45,33 +45,43 @@ 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; + public Action? BeatmapSetsChanged; + + /// + /// Triggered after filter conditions have finished being applied to the model hierarchy. + /// + public Action? FilterApplied; /// /// The currently selected beatmap. /// - public BeatmapInfo SelectedBeatmapInfo => selectedBeatmap?.BeatmapInfo; + public BeatmapInfo? SelectedBeatmapInfo => selectedBeatmap?.BeatmapInfo; - private CarouselBeatmap selectedBeatmap => selectedBeatmapSet?.Beatmaps.FirstOrDefault(s => s.State.Value == CarouselItemState.Selected); + 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. /// - public BeatmapSetInfo SelectedBeatmapSet => selectedBeatmapSet?.BeatmapSet; + public BeatmapSetInfo? SelectedBeatmapSet => selectedBeatmapSet?.BeatmapSet; /// /// A function to optionally decide on a recommended difficulty from a beatmap set. /// - public Func, BeatmapInfo> GetRecommendedBeatmap; + public Func, BeatmapInfo?>? GetRecommendedBeatmap; - private CarouselBeatmapSet selectedBeatmapSet; + private CarouselBeatmapSet? selectedBeatmapSet; /// /// Raised when the is changed. /// - public Action SelectionChanged; + public Action? SelectionChanged; public override bool HandleNonPositionalInput => AllowSelection; public override bool HandlePositionalInput => AllowSelection; @@ -100,7 +110,7 @@ namespace osu.Game.Screens.Select private readonly NoResultsPlaceholder noResultsPlaceholder; - private IEnumerable beatmapSets => root.Children.OfType(); + private IEnumerable beatmapSets => root.Items.OfType(); // todo: only used for testing, maybe remove. private bool loadedTestBeatmaps; @@ -119,7 +129,7 @@ namespace osu.Game.Screens.Select { CarouselRoot newRoot = new CarouselRoot(this); - newRoot.AddChildren(beatmapSets.Select(s => createCarouselSet(s.Detach())).Where(g => g != null)); + newRoot.AddItems(beatmapSets.Select(s => createCarouselSet(s.Detach())).OfType()); root = newRoot; @@ -149,15 +159,15 @@ namespace osu.Game.Screens.Select private CarouselRoot root; - private IDisposable subscriptionSets; - private IDisposable subscriptionDeletedSets; - private IDisposable subscriptionBeatmaps; - private IDisposable subscriptionHiddenBeatmaps; + private IDisposable? subscriptionSets; + private IDisposable? subscriptionDeletedSets; + private IDisposable? subscriptionBeatmaps; + private IDisposable? subscriptionHiddenBeatmaps; private readonly DrawablePool setPool = new DrawablePool(100); - private Sample spinSample; - private Sample randomSelectSample; + private Sample? spinSample; + private Sample? randomSelectSample; private int visibleSetsCount; @@ -198,7 +208,7 @@ namespace osu.Game.Screens.Select } [Resolved] - private RealmAccess realm { get; set; } + private RealmAccess realm { get; set; } = null!; protected override void LoadComplete() { @@ -213,7 +223,7 @@ namespace osu.Game.Screens.Select subscriptionHiddenBeatmaps = realm.RegisterForNotifications(r => r.All().Where(b => b.Hidden), beatmapsChanged); } - private void deletedBeatmapSetsChanged(IRealmCollection sender, ChangeSet changes, Exception error) + private void deletedBeatmapSetsChanged(IRealmCollection sender, ChangeSet? changes, Exception? error) { // If loading test beatmaps, avoid overwriting with realm subscription callbacks. if (loadedTestBeatmaps) @@ -226,7 +236,7 @@ namespace osu.Game.Screens.Select removeBeatmapSet(sender[i].ID); } - private void beatmapSetsChanged(IRealmCollection sender, ChangeSet changes, Exception error) + private void beatmapSetsChanged(IRealmCollection sender, ChangeSet? changes, Exception? error) { // If loading test beatmaps, avoid overwriting with realm subscription callbacks. if (loadedTestBeatmaps) @@ -263,9 +273,49 @@ namespace osu.Game.Screens.Select foreach (int i in changes.InsertedIndices) UpdateBeatmapSet(sender[i].Detach()); + + if (changes.DeletedIndices.Length > 0 && SelectedBeatmapInfo != null) + { + // If SelectedBeatmapInfo is non-null, the set should also be non-null. + Debug.Assert(SelectedBeatmapSet != null); + + // To handle the beatmap update flow, attempt to track selection changes across delete-insert transactions. + // When an update occurs, the previous beatmap set is either soft or hard deleted. + // Check if the current selection was potentially deleted by re-querying its validity. + bool selectedSetMarkedDeleted = realm.Run(r => r.Find(SelectedBeatmapSet.ID))?.DeletePending != false; + + int[] modifiedAndInserted = changes.NewModifiedIndices.Concat(changes.InsertedIndices).ToArray(); + + if (selectedSetMarkedDeleted && modifiedAndInserted.Any()) + { + // If it is no longer valid, make the bold assumption that an updated version will be available in the modified/inserted indices. + // This relies on the full update operation being in a single transaction, so please don't change that. + foreach (int i in modifiedAndInserted) + { + var beatmapSetInfo = sender[i]; + + foreach (var beatmapInfo in beatmapSetInfo.Beatmaps) + { + if (!((IBeatmapMetadataInfo)beatmapInfo.Metadata).Equals(SelectedBeatmapInfo.Metadata)) + continue; + + // Best effort matching. We can't use ID because in the update flow a new version will get its own GUID. + if (beatmapInfo.DifficultyName == SelectedBeatmapInfo.DifficultyName) + { + SelectBeatmap(beatmapInfo); + return; + } + } + } + + // If a direct selection couldn't be made, it's feasible that the difficulty name (or beatmap metadata) changed. + // Let's attempt to follow set-level selection anyway. + SelectBeatmap(sender[modifiedAndInserted.First()].Beatmaps.First()); + } + } } - private void beatmapsChanged(IRealmCollection sender, ChangeSet changes, Exception error) + private void beatmapsChanged(IRealmCollection sender, ChangeSet? changes, Exception? error) { // we only care about actual changes in hidden status. if (changes == null) @@ -298,11 +348,13 @@ namespace osu.Game.Screens.Select if (!root.BeatmapSetsByID.TryGetValue(beatmapSetID, out var existingSet)) return; - root.RemoveChild(existingSet); + root.RemoveItem(existingSet); itemsCache.Invalidate(); if (!Scroll.UserScrolling) ScrollToSelected(true); + + BeatmapSetsChanged?.Invoke(); }); public void UpdateBeatmapSet(BeatmapSetInfo beatmapSet) => Schedule(() => @@ -314,16 +366,21 @@ namespace osu.Game.Screens.Select previouslySelectedID = selectedBeatmap?.BeatmapInfo.ID; var newSet = createCarouselSet(beatmapSet); + var removedSet = root.RemoveChild(beatmapSet.ID); - root.RemoveChild(beatmapSet.ID); + // If we don't remove this here, it may remain in a hidden state until scrolled off screen. + // Doesn't really affect anything during actual user interaction, but makes testing annoying. + var removedDrawable = Scroll.FirstOrDefault(c => c.Item == removedSet); + if (removedDrawable != null) + expirePanelImmediately(removedDrawable); if (newSet != null) { - root.AddChild(newSet); + root.AddItem(newSet); // check if we can/need to maintain our current selection. if (previouslySelectedID != null) - select((CarouselItem)newSet.Beatmaps.FirstOrDefault(b => b.BeatmapInfo.ID == previouslySelectedID) ?? newSet); + select((CarouselItem?)newSet.Beatmaps.FirstOrDefault(b => b.BeatmapInfo.ID == previouslySelectedID) ?? newSet); } itemsCache.Invalidate(); @@ -340,7 +397,7 @@ namespace osu.Game.Screens.Select /// The beatmap to select. /// Whether to select the beatmap even if it is filtered (i.e., not visible on carousel). /// True if a selection was made, False if it wasn't. - public bool SelectBeatmap(BeatmapInfo beatmapInfo, bool bypassFilters = true) + public bool SelectBeatmap(BeatmapInfo? beatmapInfo, bool bypassFilters = true) { // ensure that any pending events from BeatmapManager have been run before attempting a selection. Scheduler.Update(); @@ -398,6 +455,9 @@ namespace osu.Game.Screens.Select private void selectNextSet(int direction, bool skipDifficulties) { + if (selectedBeatmap == null || selectedBeatmapSet == null) + return; + var unfilteredSets = beatmapSets.Where(s => !s.Filtered.Value).ToList(); var nextSet = unfilteredSets[(unfilteredSets.IndexOf(selectedBeatmapSet) + direction + unfilteredSets.Count) % unfilteredSets.Count]; @@ -410,10 +470,10 @@ namespace osu.Game.Screens.Select private void selectNextDifficulty(int direction) { - if (selectedBeatmap == null) + if (selectedBeatmap == null || selectedBeatmapSet == null) return; - var unfilteredDifficulties = selectedBeatmapSet.Children.Where(s => !s.Filtered.Value).ToList(); + var unfilteredDifficulties = selectedBeatmapSet.Items.Where(s => !s.Filtered.Value).ToList(); int index = unfilteredDifficulties.IndexOf(selectedBeatmap); @@ -439,7 +499,7 @@ namespace osu.Game.Screens.Select if (!visibleSets.Any()) return false; - if (selectedBeatmap != null) + if (selectedBeatmap != null && selectedBeatmapSet != null) { randomSelectedBeatmaps.Push(selectedBeatmap); @@ -482,11 +542,13 @@ namespace osu.Game.Screens.Select if (!beatmap.Filtered.Value) { - if (RandomAlgorithm.Value == RandomSelectAlgorithm.RandomPermutation) - previouslyVisitedRandomSets.Remove(selectedBeatmapSet); - if (selectedBeatmapSet != null) + { + if (RandomAlgorithm.Value == RandomSelectAlgorithm.RandomPermutation) + previouslyVisitedRandomSets.Remove(selectedBeatmapSet); + playSpinSample(distanceBetween(beatmap, selectedBeatmapSet)); + } select(beatmap); break; @@ -498,14 +560,18 @@ namespace osu.Game.Screens.Select private void playSpinSample(double distance) { - var chan = spinSample.GetChannel(); - chan.Frequency.Value = 1f + Math.Min(1f, distance / visibleSetsCount); - chan.Play(); + var chan = spinSample?.GetChannel(); + + if (chan != null) + { + chan.Frequency.Value = 1f + Math.Min(1f, distance / visibleSetsCount); + chan.Play(); + } randomSelectSample?.Play(); } - private void select(CarouselItem item) + private void select(CarouselItem? item) { if (!AllowSelection) return; @@ -517,7 +583,7 @@ namespace osu.Game.Screens.Select private FilterCriteria activeCriteria = new FilterCriteria(); - protected ScheduledDelegate PendingFilter; + protected ScheduledDelegate? PendingFilter; public bool AllowSelection = true; @@ -542,6 +608,9 @@ namespace osu.Game.Screens.Select public void FlushPendingFilterOperations() { + if (!IsLoaded) + return; + if (PendingFilter?.Completed == false) { applyActiveCriteria(false); @@ -549,7 +618,7 @@ namespace osu.Game.Screens.Select } } - public void Filter(FilterCriteria newCriteria, bool debounce = true) + public void Filter(FilterCriteria? newCriteria, bool debounce = true) { if (newCriteria != null) activeCriteria = newCriteria; @@ -582,6 +651,8 @@ namespace osu.Game.Screens.Select if (alwaysResetScrollPosition || !Scroll.UserScrolling) ScrollToSelected(true); + + FilterApplied?.Invoke(); } } @@ -685,6 +756,8 @@ namespace osu.Game.Screens.Select foreach (var panel in Scroll.Children) { + Debug.Assert(panel.Item != null); + if (toDisplay.Remove(panel.Item)) { // panel already displayed. @@ -694,11 +767,7 @@ namespace osu.Game.Screens.Select // panel loaded as drawable but not required by visible range. // remove but only if too far off-screen if (panel.Y + panel.DrawHeight < visibleUpperBound - distance_offscreen_before_unload || panel.Y > visibleBottomBound + distance_offscreen_before_unload) - { - // may want a fade effect here (could be seen if a huge change happens, like a set with 20 difficulties becomes selected). - panel.ClearTransforms(); - panel.Expire(); - } + expirePanelImmediately(panel); } // Add those items within the previously found index range that should be displayed. @@ -720,6 +789,28 @@ namespace osu.Game.Screens.Select { updateItem(item); + Debug.Assert(item.Item != null); + + if (item.Item.Visible) + { + bool isSelected = item.Item.State.Value == CarouselItemState.Selected; + + // Cheap way of doing animations when entering / exiting song select. + const double half_time = 50; + const float panel_x_offset_when_inactive = 200; + + if (isSelected || AllowSelection) + { + item.Alpha = (float)Interpolation.DampContinuously(item.Alpha, 1, half_time, Clock.ElapsedFrameTime); + item.X = (float)Interpolation.DampContinuously(item.X, 0, half_time, Clock.ElapsedFrameTime); + } + else + { + item.Alpha = (float)Interpolation.DampContinuously(item.Alpha, 0, half_time, Clock.ElapsedFrameTime); + item.X = (float)Interpolation.DampContinuously(item.X, panel_x_offset_when_inactive, half_time, Clock.ElapsedFrameTime); + } + } + if (item is DrawableCarouselBeatmapSet set) { foreach (var diff in set.DrawableBeatmaps) @@ -728,6 +819,13 @@ namespace osu.Game.Screens.Select } } + private static void expirePanelImmediately(DrawableCarouselItem panel) + { + // may want a fade effect here (could be seen if a huge change happens, like a set with 20 difficulties becomes selected). + panel.ClearTransforms(); + panel.Expire(); + } + private readonly CarouselBoundsItem carouselBoundsItem = new CarouselBoundsItem(); private (int firstIndex, int lastIndex) getDisplayRange() @@ -749,7 +847,7 @@ namespace osu.Game.Screens.Select return (firstIndex, lastIndex); } - private CarouselBeatmapSet createCarouselSet(BeatmapSetInfo beatmapSet) + private CarouselBeatmapSet? createCarouselSet(BeatmapSetInfo beatmapSet) { // This can be moved to the realm query if required using: // .Filter("DeletePending == false && Protected == false && ANY Beatmaps.Hidden == false") @@ -796,7 +894,7 @@ namespace osu.Game.Screens.Select scrollTarget = null; - foreach (CarouselItem item in root.Children) + foreach (CarouselItem item in root.Items) { if (item.Filtered.Value) continue; @@ -915,7 +1013,7 @@ namespace osu.Game.Screens.Select /// /// The item to be updated. /// For nested items, the parent of the item to be updated. - private void updateItem(DrawableCarouselItem item, DrawableCarouselItem parent = null) + private void updateItem(DrawableCarouselItem item, DrawableCarouselItem? parent = null) { Vector2 posInScroll = Scroll.ScrollContent.ToLocalSpace(item.Header.ScreenSpaceDrawQuad.Centre); float itemDrawY = posInScroll.Y - visibleUpperBound; @@ -943,13 +1041,13 @@ namespace osu.Game.Screens.Select /// private class CarouselBoundsItem : CarouselItem { - public override DrawableCarouselItem CreateDrawableRepresentation() => - throw new NotImplementedException(); + public override DrawableCarouselItem CreateDrawableRepresentation() => throw new NotImplementedException(); } private class CarouselRoot : CarouselGroupEagerSelect { - private readonly BeatmapCarousel carousel; + // May only be null during construction (State.Value set causes PerformSelection to be triggered). + private readonly BeatmapCarousel? carousel; public readonly Dictionary BeatmapSetsByID = new Dictionary(); @@ -957,43 +1055,48 @@ namespace osu.Game.Screens.Select { // root should always remain selected. if not, PerformSelection will not be called. State.Value = CarouselItemState.Selected; - State.ValueChanged += state => State.Value = CarouselItemState.Selected; + State.ValueChanged += _ => State.Value = CarouselItemState.Selected; this.carousel = carousel; } - public override void AddChild(CarouselItem i) + public override void AddItem(CarouselItem i) { CarouselBeatmapSet set = (CarouselBeatmapSet)i; BeatmapSetsByID.Add(set.BeatmapSet.ID, set); - base.AddChild(i); + base.AddItem(i); } - public void RemoveChild(Guid beatmapSetID) + public CarouselBeatmapSet? RemoveChild(Guid beatmapSetID) { if (BeatmapSetsByID.TryGetValue(beatmapSetID, out var carouselBeatmapSet)) - RemoveChild(carouselBeatmapSet); + { + RemoveItem(carouselBeatmapSet); + return carouselBeatmapSet; + } + + return null; } - public override void RemoveChild(CarouselItem i) + public override void RemoveItem(CarouselItem i) { CarouselBeatmapSet set = (CarouselBeatmapSet)i; BeatmapSetsByID.Remove(set.BeatmapSet.ID); - base.RemoveChild(i); + base.RemoveItem(i); } protected override void PerformSelection() { - if (LastSelected == null || LastSelected.Filtered.Value) + if (LastSelected == null) carousel?.SelectNextRandom(); else base.PerformSelection(); } } - protected class CarouselScrollContainer : UserTrackingScrollContainer + protected partial class CarouselScrollContainer : UserTrackingScrollContainer { private bool rightMouseScrollBlocked; diff --git a/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs b/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs index 4a16be4a3a..8efad451df 100644 --- a/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs +++ b/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs @@ -4,38 +4,24 @@ using System; using System.Threading.Tasks; using osu.Framework.Allocation; -using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; using osu.Game.Overlays.Dialog; using osu.Game.Scoring; namespace osu.Game.Screens.Select { - public class BeatmapClearScoresDialog : PopupDialog + public partial class BeatmapClearScoresDialog : DangerousActionDialog { [Resolved] - private ScoreManager scoreManager { get; set; } + private ScoreManager scoreManager { get; set; } = null!; public BeatmapClearScoresDialog(BeatmapInfo beatmapInfo, Action onCompletion) { - BodyText = beatmapInfo.GetDisplayTitle(); - Icon = FontAwesome.Solid.Eraser; - HeaderText = @"Clearing all local scores. Are you sure?"; - Buttons = new PopupDialogButton[] + BodyText = $"All local scores on {beatmapInfo.GetDisplayTitle()}"; + DangerousAction = () => { - new PopupDialogOkButton - { - Text = @"Yes. Please.", - Action = () => - { - Task.Run(() => scoreManager.Delete(beatmapInfo)) - .ContinueWith(_ => onCompletion); - } - }, - new PopupDialogCancelButton - { - Text = @"No, I'm still attached.", - }, + Task.Run(() => scoreManager.Delete(beatmapInfo)) + .ContinueWith(_ => onCompletion); }; } } diff --git a/osu.Game/Screens/Select/BeatmapDeleteDialog.cs b/osu.Game/Screens/Select/BeatmapDeleteDialog.cs index b156c2485b..e98af8cca2 100644 --- a/osu.Game/Screens/Select/BeatmapDeleteDialog.cs +++ b/osu.Game/Screens/Select/BeatmapDeleteDialog.cs @@ -2,40 +2,25 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; using osu.Game.Overlays.Dialog; namespace osu.Game.Screens.Select { - public class BeatmapDeleteDialog : PopupDialog + public partial class BeatmapDeleteDialog : DangerousActionDialog { - private BeatmapManager manager; + private readonly BeatmapSetInfo beatmapSet; + + public BeatmapDeleteDialog(BeatmapSetInfo beatmapSet) + { + this.beatmapSet = beatmapSet; + BodyText = $@"{beatmapSet.Metadata.Artist} - {beatmapSet.Metadata.Title}"; + } [BackgroundDependencyLoader] private void load(BeatmapManager beatmapManager) { - manager = beatmapManager; - } - - public BeatmapDeleteDialog(BeatmapSetInfo beatmap) - { - BodyText = $@"{beatmap.Metadata.Artist} - {beatmap.Metadata.Title}"; - - Icon = FontAwesome.Regular.TrashAlt; - HeaderText = @"Confirm deletion of"; - Buttons = new PopupDialogButton[] - { - new PopupDialogDangerousButton - { - Text = @"Yes. Totally. Delete it.", - Action = () => manager?.Delete(beatmap), - }, - new PopupDialogCancelButton - { - Text = @"Firetruck, I didn't mean to!", - }, - }; + DangerousAction = () => beatmapManager.Delete(beatmapSet); } } } diff --git a/osu.Game/Screens/Select/BeatmapDetailArea.cs b/osu.Game/Screens/Select/BeatmapDetailArea.cs index 72c2ba708b..595b86924b 100644 --- a/osu.Game/Screens/Select/BeatmapDetailArea.cs +++ b/osu.Game/Screens/Select/BeatmapDetailArea.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -9,7 +11,7 @@ using osu.Game.Beatmaps; namespace osu.Game.Screens.Select { - public abstract class BeatmapDetailArea : Container + public abstract partial class BeatmapDetailArea : Container { private const float details_padding = 10; @@ -84,7 +86,7 @@ namespace osu.Game.Screens.Select { switch (tab) { - case BeatmapDetailAreaDetailTabItem _: + case BeatmapDetailAreaDetailTabItem: Details.Show(); break; diff --git a/osu.Game/Screens/Select/BeatmapDetailAreaDetailTabItem.cs b/osu.Game/Screens/Select/BeatmapDetailAreaDetailTabItem.cs index 7376cb4708..d6b076f30b 100644 --- a/osu.Game/Screens/Select/BeatmapDetailAreaDetailTabItem.cs +++ b/osu.Game/Screens/Select/BeatmapDetailAreaDetailTabItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Screens.Select { public class BeatmapDetailAreaDetailTabItem : BeatmapDetailAreaTabItem diff --git a/osu.Game/Screens/Select/BeatmapDetailAreaLeaderboardTabItem.cs b/osu.Game/Screens/Select/BeatmapDetailAreaLeaderboardTabItem.cs index 066944e9d2..6efadc77b3 100644 --- a/osu.Game/Screens/Select/BeatmapDetailAreaLeaderboardTabItem.cs +++ b/osu.Game/Screens/Select/BeatmapDetailAreaLeaderboardTabItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; namespace osu.Game.Screens.Select diff --git a/osu.Game/Screens/Select/BeatmapDetailAreaTabControl.cs b/osu.Game/Screens/Select/BeatmapDetailAreaTabControl.cs index 0fd39db97c..f9dab2bb1d 100644 --- a/osu.Game/Screens/Select/BeatmapDetailAreaTabControl.cs +++ b/osu.Game/Screens/Select/BeatmapDetailAreaTabControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using osuTK.Graphics; @@ -15,7 +17,7 @@ using osu.Framework.Graphics.Shapes; namespace osu.Game.Screens.Select { - public class BeatmapDetailAreaTabControl : Container + public partial class BeatmapDetailAreaTabControl : Container { public const float HEIGHT = 24; diff --git a/osu.Game/Screens/Select/BeatmapDetailAreaTabItem.cs b/osu.Game/Screens/Select/BeatmapDetailAreaTabItem.cs index f28e5a7c22..7b7a93d6ee 100644 --- a/osu.Game/Screens/Select/BeatmapDetailAreaTabItem.cs +++ b/osu.Game/Screens/Select/BeatmapDetailAreaTabItem.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; namespace osu.Game.Screens.Select diff --git a/osu.Game/Screens/Select/BeatmapDetails.cs b/osu.Game/Screens/Select/BeatmapDetails.cs index 9ff1574fe4..712b610515 100644 --- a/osu.Game/Screens/Select/BeatmapDetails.cs +++ b/osu.Game/Screens/Select/BeatmapDetails.cs @@ -23,7 +23,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Select { - public class BeatmapDetails : Container + public partial class BeatmapDetails : Container { private const float spacing = 10; private const float transition_duration = 250; @@ -36,15 +36,18 @@ namespace osu.Game.Screens.Select private readonly LoadingLayer loading; [Resolved] - private IAPIProvider api { get; set; } + private IAPIProvider api { get; set; } = null!; - private IBeatmapInfo beatmapInfo; + [Resolved] + private SongSelect? songSelect { get; set; } - private APIFailTimes failTimes; + private IBeatmapInfo? beatmapInfo; - private int[] ratings; + private APIFailTimes? failTimes; - public IBeatmapInfo BeatmapInfo + private int[]? ratings; + + public IBeatmapInfo? BeatmapInfo { get => beatmapInfo; set @@ -54,7 +57,7 @@ namespace osu.Game.Screens.Select beatmapInfo = value; var onlineInfo = beatmapInfo as IBeatmapOnlineInfo; - var onlineSetInfo = beatmapInfo.BeatmapSet as IBeatmapSetOnlineInfo; + var onlineSetInfo = beatmapInfo?.BeatmapSet as IBeatmapSetOnlineInfo; failTimes = onlineInfo?.FailTimes; ratings = onlineSetInfo?.Ratings; @@ -138,9 +141,9 @@ namespace osu.Game.Screens.Select LayoutEasing = Easing.OutQuad, Children = new[] { - description = new MetadataSection(MetadataType.Description), - source = new MetadataSection(MetadataType.Source), - tags = new MetadataSection(MetadataType.Tags), + description = new MetadataSectionDescription(searchOnSongSelect), + source = new MetadataSectionSource(searchOnSongSelect), + tags = new MetadataSectionTags(searchOnSongSelect), }, }, }, @@ -173,14 +176,20 @@ namespace osu.Game.Screens.Select }, loading = new LoadingLayer(true) }; + + void searchOnSongSelect(string text) + { + if (songSelect != null) + songSelect.FilterControl.CurrentTextSearch.Value = text; + } } private void updateStatistics() { advanced.BeatmapInfo = BeatmapInfo; - description.Text = BeatmapInfo?.DifficultyName; - source.Text = BeatmapInfo?.Metadata.Source; - tags.Text = BeatmapInfo?.Metadata.Tags; + description.Metadata = BeatmapInfo?.DifficultyName ?? string.Empty; + source.Metadata = BeatmapInfo?.Metadata.Source ?? string.Empty; + tags.Metadata = BeatmapInfo?.Metadata.Tags ?? string.Empty; // failTimes may have been previously fetched if (ratings != null && failTimes != null) @@ -215,7 +224,7 @@ namespace osu.Game.Screens.Select }); }; - lookup.Failure += e => + lookup.Failure += _ => { Schedule(() => { @@ -264,7 +273,7 @@ namespace osu.Game.Screens.Select loading.Hide(); } - private class DetailBox : Container + private partial class DetailBox : Container { private readonly Container content; protected override Container Content => content; diff --git a/osu.Game/Screens/Select/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs index d98238f518..2102df1022 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs +++ b/osu.Game/Screens/Select/BeatmapInfoWedge.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -31,7 +33,7 @@ using osu.Game.Graphics.Containers; namespace osu.Game.Screens.Select { - public class BeatmapInfoWedge : VisibilityContainer + public partial class BeatmapInfoWedge : VisibilityContainer { public const float BORDER_THICKNESS = 2.5f; private const float shear_width = 36.75f; @@ -146,7 +148,7 @@ namespace osu.Game.Screens.Select } } - public class WedgeInfoText : Container + public partial class WedgeInfoText : Container { public OsuSpriteText VersionLabel { get; private set; } public OsuSpriteText TitleLabel { get; private set; } @@ -454,7 +456,7 @@ namespace osu.Game.Screens.Select cancellationSource?.Cancel(); } - public class InfoLabel : Container, IHasTooltip + public partial class InfoLabel : Container, IHasTooltip { public LocalisableString TooltipText { get; } diff --git a/osu.Game/Screens/Select/BeatmapInfoWedgeBackground.cs b/osu.Game/Screens/Select/BeatmapInfoWedgeBackground.cs index d9b8a20846..d5d258704b 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedgeBackground.cs +++ b/osu.Game/Screens/Select/BeatmapInfoWedgeBackground.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -13,7 +15,7 @@ using osu.Framework.Graphics.Shapes; namespace osu.Game.Screens.Select { - internal class BeatmapInfoWedgeBackground : CompositeDrawable + internal partial class BeatmapInfoWedgeBackground : CompositeDrawable { private readonly IWorkingBeatmap beatmap; diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index fd6a869938..7e48bc5cdd 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -26,6 +26,11 @@ namespace osu.Game.Screens.Select.Carousel { base.Filter(criteria); + Filtered.Value = !checkMatch(criteria); + } + + private bool checkMatch(FilterCriteria criteria) + { bool match = criteria.Ruleset == null || BeatmapInfo.Ruleset.ShortName == criteria.Ruleset.ShortName || @@ -34,8 +39,7 @@ namespace osu.Game.Screens.Select.Carousel if (BeatmapInfo.BeatmapSet?.Equals(criteria.SelectedBeatmapSet) == true) { // only check ruleset equality or convertability for selected beatmap - Filtered.Value = !match; - return; + return match; } match &= !criteria.StarDifficulty.HasFilter || criteria.StarDifficulty.IsInRange(BeatmapInfo.StarRating); @@ -49,18 +53,38 @@ namespace osu.Game.Screens.Select.Carousel match &= !criteria.BeatDivisor.HasFilter || criteria.BeatDivisor.IsInRange(BeatmapInfo.BeatDivisor); match &= !criteria.OnlineStatus.HasFilter || criteria.OnlineStatus.IsInRange(BeatmapInfo.Status); + if (!match) return false; + match &= !criteria.Creator.HasFilter || criteria.Creator.Matches(BeatmapInfo.Metadata.Author.Username); match &= !criteria.Artist.HasFilter || criteria.Artist.Matches(BeatmapInfo.Metadata.Artist) || criteria.Artist.Matches(BeatmapInfo.Metadata.ArtistUnicode); match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(BeatmapInfo.StarRating); - if (match && criteria.SearchTerms.Length > 0) + if (!match) return false; + + if (criteria.SearchTerms.Length > 0) { - string[] terms = BeatmapInfo.GetSearchableTerms(); + var terms = BeatmapInfo.GetSearchableTerms(); foreach (string criteriaTerm in criteria.SearchTerms) - match &= terms.Any(term => term.Contains(criteriaTerm, StringComparison.InvariantCultureIgnoreCase)); + { + bool any = false; + + // ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator + foreach (string term in terms) + { + if (!term.Contains(criteriaTerm, StringComparison.InvariantCultureIgnoreCase)) continue; + + any = true; + break; + } + + if (any) continue; + + match = false; + break; + } // if a match wasn't found via text matching of terms, do a second catch-all check matching against online IDs. // this should be done after text matching so we can prioritise matching numbers in metadata. @@ -71,13 +95,14 @@ namespace osu.Game.Screens.Select.Carousel } } - if (match) - match &= criteria.Collection?.Beatmaps.Contains(BeatmapInfo) ?? true; + if (!match) return false; + + match &= criteria.CollectionBeatmapMD5Hashes?.Contains(BeatmapInfo.MD5Hash) ?? true; if (match && criteria.RulesetCriteria != null) match &= criteria.RulesetCriteria.Matches(BeatmapInfo); - Filtered.Value = !match; + return match; } public override int CompareTo(FilterCriteria criteria, CarouselItem other) diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs index fc4b6c27f3..67822a27ee 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs @@ -19,7 +19,7 @@ namespace osu.Game.Screens.Select.Carousel switch (State.Value) { case CarouselItemState.Selected: - return DrawableCarouselBeatmapSet.HEIGHT + Children.Count(c => c.Visible) * DrawableCarouselBeatmap.HEIGHT; + return DrawableCarouselBeatmapSet.HEIGHT + Items.Count(c => c.Visible) * DrawableCarouselBeatmap.HEIGHT; default: return DrawableCarouselBeatmapSet.HEIGHT; @@ -27,11 +27,11 @@ namespace osu.Game.Screens.Select.Carousel } } - public IEnumerable Beatmaps => InternalChildren.OfType(); + public IEnumerable Beatmaps => Items.OfType(); public BeatmapSetInfo BeatmapSet; - public Func, BeatmapInfo> GetRecommendedBeatmap; + public Func, BeatmapInfo?>? GetRecommendedBeatmap; public CarouselBeatmapSet(BeatmapSetInfo beatmapSet) { @@ -42,15 +42,15 @@ namespace osu.Game.Screens.Select.Carousel .OrderBy(b => b.Ruleset) .ThenBy(b => b.StarRating) .Select(b => new CarouselBeatmap(b)) - .ForEach(AddChild); + .ForEach(AddItem); } - protected override CarouselItem GetNextToSelect() + protected override CarouselItem? GetNextToSelect() { if (LastSelected == null || LastSelected.Filtered.Value) { - if (GetRecommendedBeatmap?.Invoke(Children.OfType().Where(b => !b.Filtered.Value).Select(b => b.BeatmapInfo)) is BeatmapInfo recommended) - return Children.OfType().First(b => b.BeatmapInfo.Equals(recommended)); + if (GetRecommendedBeatmap?.Invoke(Items.OfType().Where(b => !b.Filtered.Value).Select(b => b.BeatmapInfo)) is BeatmapInfo recommended) + return Items.OfType().First(b => b.BeatmapInfo.Equals(recommended)); } return base.GetNextToSelect(); @@ -61,33 +61,67 @@ namespace osu.Game.Screens.Select.Carousel if (!(other is CarouselBeatmapSet otherSet)) return base.CompareTo(criteria, other); + int comparison; + switch (criteria.Sort) { default: case SortMode.Artist: - return string.Compare(BeatmapSet.Metadata.Artist, otherSet.BeatmapSet.Metadata.Artist, StringComparison.OrdinalIgnoreCase); + comparison = string.Compare(BeatmapSet.Metadata.Artist, otherSet.BeatmapSet.Metadata.Artist, StringComparison.OrdinalIgnoreCase); + break; case SortMode.Title: - return string.Compare(BeatmapSet.Metadata.Title, otherSet.BeatmapSet.Metadata.Title, StringComparison.OrdinalIgnoreCase); + comparison = string.Compare(BeatmapSet.Metadata.Title, otherSet.BeatmapSet.Metadata.Title, StringComparison.OrdinalIgnoreCase); + break; case SortMode.Author: - return string.Compare(BeatmapSet.Metadata.Author.Username, otherSet.BeatmapSet.Metadata.Author.Username, StringComparison.OrdinalIgnoreCase); + comparison = string.Compare(BeatmapSet.Metadata.Author.Username, otherSet.BeatmapSet.Metadata.Author.Username, StringComparison.OrdinalIgnoreCase); + break; case SortMode.Source: - return string.Compare(BeatmapSet.Metadata.Source, otherSet.BeatmapSet.Metadata.Source, StringComparison.OrdinalIgnoreCase); + comparison = string.Compare(BeatmapSet.Metadata.Source, otherSet.BeatmapSet.Metadata.Source, StringComparison.OrdinalIgnoreCase); + break; case SortMode.DateAdded: - return otherSet.BeatmapSet.DateAdded.CompareTo(BeatmapSet.DateAdded); + comparison = otherSet.BeatmapSet.DateAdded.CompareTo(BeatmapSet.DateAdded); + break; + + case SortMode.DateRanked: + comparison = Nullable.Compare(otherSet.BeatmapSet.DateRanked, BeatmapSet.DateRanked); + break; + + case SortMode.LastPlayed: + comparison = -compareUsingAggregateMax(otherSet, b => (b.LastPlayed ?? DateTimeOffset.MinValue).ToUnixTimeSeconds()); + break; case SortMode.BPM: - return compareUsingAggregateMax(otherSet, b => b.BPM); + comparison = compareUsingAggregateMax(otherSet, b => b.BPM); + break; case SortMode.Length: - return compareUsingAggregateMax(otherSet, b => b.Length); + comparison = compareUsingAggregateMax(otherSet, b => b.Length); + break; case SortMode.Difficulty: - return compareUsingAggregateMax(otherSet, b => b.StarRating); + comparison = compareUsingAggregateMax(otherSet, b => b.StarRating); + break; + + case SortMode.DateSubmitted: + comparison = Nullable.Compare(otherSet.BeatmapSet.DateSubmitted, BeatmapSet.DateSubmitted); + break; } + + if (comparison != 0) return comparison; + + // If the initial sort could not differentiate, attempt to use DateAdded to order sets in a stable fashion. + // The directionality of this matches the current SortMode.DateAdded, but we may want to reconsider if that becomes a user decision (ie. asc / desc). + comparison = otherSet.BeatmapSet.DateAdded.CompareTo(BeatmapSet.DateAdded); + + if (comparison != 0) return comparison; + + // If DateAdded fails to break the tie, fallback to our internal GUID for stability. + // This basically means it's a stable random sort. + return otherSet.BeatmapSet.ID.CompareTo(BeatmapSet.ID); } /// @@ -110,7 +144,8 @@ namespace osu.Game.Screens.Select.Carousel public override void Filter(FilterCriteria criteria) { base.Filter(criteria); - Filtered.Value = InternalChildren.All(i => i.Filtered.Value); + + Filtered.Value = Items.All(i => i.Filtered.Value); } public override string ToString() => BeatmapSet.ToString(); diff --git a/osu.Game/Screens/Select/Carousel/CarouselGroup.cs b/osu.Game/Screens/Select/Carousel/CarouselGroup.cs index 6ebe314072..9302578038 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselGroup.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselGroup.cs @@ -2,66 +2,64 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using System.Linq; - -#nullable enable namespace osu.Game.Screens.Select.Carousel { /// - /// A group which ensures only one child is selected. + /// A group which ensures only one item is selected. /// public class CarouselGroup : CarouselItem { public override DrawableCarouselItem? CreateDrawableRepresentation() => null; - public IReadOnlyList Children => InternalChildren; + public IReadOnlyList Items => items; - protected List InternalChildren = new List(); + private readonly List items = new List(); /// - /// Used to assign a monotonically increasing ID to children as they are added. This member is - /// incremented whenever a child is added. + /// Used to assign a monotonically increasing ID to items as they are added. This member is + /// incremented whenever an item is added. /// - private ulong currentChildID; + private ulong currentItemID; private Comparer? criteriaComparer; - private FilterCriteria? lastCriteria; - public virtual void RemoveChild(CarouselItem i) + protected int GetIndexOfItem(CarouselItem lastSelected) => items.IndexOf(lastSelected); + + public virtual void RemoveItem(CarouselItem i) { - InternalChildren.Remove(i); + items.Remove(i); // it's important we do the deselection after removing, so any further actions based on // State.ValueChanged make decisions post-removal. i.State.Value = CarouselItemState.Collapsed; } - public virtual void AddChild(CarouselItem i) + public virtual void AddItem(CarouselItem i) { i.State.ValueChanged += state => ChildItemStateChanged(i, state.NewValue); - i.ChildID = ++currentChildID; + i.ItemID = ++currentItemID; if (lastCriteria != null) { i.Filter(lastCriteria); - int index = InternalChildren.BinarySearch(i, criteriaComparer); + int index = items.BinarySearch(i, criteriaComparer); if (index < 0) index = ~index; // BinarySearch hacks multiple return values with 2's complement. - InternalChildren.Insert(index, i); + items.Insert(index, i); } else { // criteria may be null for initial population. the filtering will be applied post-add. - InternalChildren.Add(i); + items.Add(i); } } public CarouselGroup(List? items = null) { - if (items != null) InternalChildren = items; + if (items != null) this.items = items; State.ValueChanged += state => { @@ -69,11 +67,11 @@ namespace osu.Game.Screens.Select.Carousel { case CarouselItemState.Collapsed: case CarouselItemState.NotSelected: - InternalChildren.ForEach(c => c.State.Value = CarouselItemState.Collapsed); + this.items.ForEach(c => c.State.Value = CarouselItemState.Collapsed); break; case CarouselItemState.Selected: - InternalChildren.ForEach(c => + this.items.ForEach(c => { if (c.State.Value == CarouselItemState.Collapsed) c.State.Value = CarouselItemState.NotSelected; }); @@ -86,11 +84,18 @@ namespace osu.Game.Screens.Select.Carousel { base.Filter(criteria); - InternalChildren.ForEach(c => c.Filter(criteria)); + items.ForEach(c => c.Filter(criteria)); - // IEnumerable.OrderBy() is used instead of List.Sort() to ensure sorting stability - criteriaComparer = Comparer.Create((x, y) => x.CompareTo(criteria, y)); - InternalChildren = InternalChildren.OrderBy(c => c, criteriaComparer).ToList(); + criteriaComparer = Comparer.Create((x, y) => + { + int comparison = x.CompareTo(criteria, y); + if (comparison != 0) + return comparison; + + return x.ItemID.CompareTo(y.ItemID); + }); + + items.Sort(criteriaComparer); lastCriteria = criteria; } @@ -100,7 +105,7 @@ namespace osu.Game.Screens.Select.Carousel // ensure we are the only item selected if (value == CarouselItemState.Selected) { - foreach (var b in InternalChildren) + foreach (var b in items) { if (item == b) continue; diff --git a/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs b/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs index aac0e4ed82..7f90e05744 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs @@ -8,7 +8,7 @@ using System.Linq; namespace osu.Game.Screens.Select.Carousel { /// - /// A group which ensures at least one child is selected (if the group itself is selected). + /// A group which ensures at least one item is selected (if the group itself is selected). /// public class CarouselGroupEagerSelect : CarouselGroup { @@ -24,7 +24,7 @@ namespace osu.Game.Screens.Select.Carousel /// /// The last selected item. /// - protected CarouselItem LastSelected { get; private set; } + protected CarouselItem? LastSelected { get; private set; } /// /// We need to keep track of the index for cases where the selection is removed but we want to select a new item based on its old location. @@ -33,46 +33,46 @@ namespace osu.Game.Screens.Select.Carousel /// /// To avoid overhead during filter operations, we don't attempt any selections until after all - /// children have been filtered. This bool will be true during the base + /// items have been filtered. This bool will be true during the base /// operation. /// - private bool filteringChildren; + private bool filteringItems; public override void Filter(FilterCriteria criteria) { - filteringChildren = true; + filteringItems = true; base.Filter(criteria); - filteringChildren = false; + filteringItems = false; attemptSelection(); } - public override void RemoveChild(CarouselItem i) + public override void RemoveItem(CarouselItem i) { - base.RemoveChild(i); + base.RemoveItem(i); if (i != LastSelected) updateSelectedIndex(); } - private bool addingChildren; + private bool addingItems; - public void AddChildren(IEnumerable items) + public void AddItems(IEnumerable items) { - addingChildren = true; + addingItems = true; foreach (var i in items) - AddChild(i); + AddItem(i); - addingChildren = false; + addingItems = false; attemptSelection(); } - public override void AddChild(CarouselItem i) + public override void AddItem(CarouselItem i) { - base.AddChild(i); - if (!addingChildren) + base.AddItem(i); + if (!addingItems) attemptSelection(); } @@ -95,26 +95,51 @@ namespace osu.Game.Screens.Select.Carousel private void attemptSelection() { - if (filteringChildren) return; + if (filteringItems) return; // we only perform eager selection if we are a currently selected group. if (State.Value != CarouselItemState.Selected) return; - // we only perform eager selection if none of our children are in a selected state already. - if (Children.Any(i => i.State.Value == CarouselItemState.Selected)) return; + // we only perform eager selection if none of our items are in a selected state already. + if (Items.Any(i => i.State.Value == CarouselItemState.Selected)) return; PerformSelection(); } - protected virtual CarouselItem GetNextToSelect() + /// + /// Finds the item this group would select next if it attempted selection + /// + /// An unfiltered item nearest to the last selected one or null if all items are filtered + protected virtual CarouselItem? GetNextToSelect() { - return Children.Skip(lastSelectedIndex).FirstOrDefault(i => !i.Filtered.Value) ?? - Children.Reverse().Skip(InternalChildren.Count - lastSelectedIndex).FirstOrDefault(i => !i.Filtered.Value); + if (Items.Count == 0) + return null; + + int forwardsIndex = lastSelectedIndex; + int backwardsIndex = Math.Min(lastSelectedIndex, Items.Count - 1); + + while (true) + { + bool hasBackwards = backwardsIndex >= 0 && backwardsIndex < Items.Count; + bool hasForwards = forwardsIndex < Items.Count; + + if (!hasBackwards && !hasForwards) + return null; + + if (hasForwards && !Items[forwardsIndex].Filtered.Value) + return Items[forwardsIndex]; + + if (hasBackwards && !Items[backwardsIndex].Filtered.Value) + return Items[backwardsIndex]; + + forwardsIndex++; + backwardsIndex--; + } } protected virtual void PerformSelection() { - CarouselItem nextToSelect = GetNextToSelect(); + CarouselItem? nextToSelect = GetNextToSelect(); if (nextToSelect != null) nextToSelect.State.Value = CarouselItemState.Selected; @@ -122,13 +147,13 @@ namespace osu.Game.Screens.Select.Carousel updateSelected(null); } - private void updateSelected(CarouselItem newSelection) + private void updateSelected(CarouselItem? newSelection) { if (newSelection != null) LastSelected = newSelection; updateSelectedIndex(); } - private void updateSelectedIndex() => lastSelectedIndex = LastSelected == null ? 0 : Math.Max(0, InternalChildren.IndexOf(LastSelected)); + private void updateSelectedIndex() => lastSelectedIndex = LastSelected == null ? 0 : Math.Max(0, GetIndexOfItem(LastSelected)); } } diff --git a/osu.Game/Screens/Select/Carousel/CarouselHeader.cs b/osu.Game/Screens/Select/Carousel/CarouselHeader.cs index ed3aea3445..7e668fcd87 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselHeader.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselHeader.cs @@ -19,7 +19,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Select.Carousel { - public class CarouselHeader : Container + public partial class CarouselHeader : Container { public Container BorderContainer; @@ -46,7 +46,8 @@ namespace osu.Game.Screens.Select.Carousel Children = new Drawable[] { Content, - hoverLayer = new HoverLayer() + hoverLayer = new HoverLayer(), + new HeaderSounds(), } }; } @@ -91,11 +92,9 @@ namespace osu.Game.Screens.Select.Carousel } } - public class HoverLayer : HoverSampleDebounceComponent + public partial class HoverLayer : CompositeDrawable { - private Sample sampleHover; - - private Box box; + private Box box = null!; public HoverLayer() { @@ -103,7 +102,7 @@ namespace osu.Game.Screens.Select.Carousel } [BackgroundDependencyLoader] - private void load(AudioManager audio, OsuColour colours) + private void load(OsuColour colours) { InternalChild = box = new Box { @@ -112,8 +111,6 @@ namespace osu.Game.Screens.Select.Carousel Blending = BlendingParameters.Additive, RelativeSizeAxes = Axes.Both, }; - - sampleHover = audio.Samples.Get("UI/default-hover"); } public bool InsetForBorder @@ -147,6 +144,17 @@ namespace osu.Game.Screens.Select.Carousel box.FadeOut(1000, Easing.OutQuint); base.OnHoverLost(e); } + } + + private partial class HeaderSounds : HoverSampleDebounceComponent + { + private Sample? sampleHover; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + sampleHover = audio.Samples.Get("UI/default-hover"); + } public override void PlayHoverSample() { diff --git a/osu.Game/Screens/Select/Carousel/CarouselItem.cs b/osu.Game/Screens/Select/Carousel/CarouselItem.cs index 4bd477412d..5e425a4a1c 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselItem.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselItem.cs @@ -36,20 +36,25 @@ namespace osu.Game.Screens.Select.Carousel /// /// Used as a default sort method for s of differing types. /// - internal ulong ChildID; + internal ulong ItemID; /// /// Create a fresh drawable version of this item. /// - public abstract DrawableCarouselItem CreateDrawableRepresentation(); + public abstract DrawableCarouselItem? CreateDrawableRepresentation(); public virtual void Filter(FilterCriteria criteria) { } - public virtual int CompareTo(FilterCriteria criteria, CarouselItem other) => ChildID.CompareTo(other.ChildID); + public virtual int CompareTo(FilterCriteria criteria, CarouselItem other) => ItemID.CompareTo(other.ItemID); - public int CompareTo(CarouselItem other) => CarouselYPosition.CompareTo(other.CarouselYPosition); + public int CompareTo(CarouselItem? other) + { + if (other == null) return 1; + + return CarouselYPosition.CompareTo(other.CarouselYPosition); + } } public enum CarouselItemState diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs index 98b885eb43..f08d14720b 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs @@ -20,6 +20,7 @@ using osu.Framework.Input.Events; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Collections; +using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Sprites; @@ -31,7 +32,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Select.Carousel { - public class DrawableCarouselBeatmap : DrawableCarouselItem, IHasContextMenu + public partial class DrawableCarouselBeatmap : DrawableCarouselItem, IHasContextMenu { public const float CAROUSEL_BEATMAP_SPACING = 5; @@ -44,29 +45,32 @@ namespace osu.Game.Screens.Select.Carousel private readonly BeatmapInfo beatmapInfo; - private Sprite background; + private Sprite background = null!; - private Action startRequested; - private Action editRequested; - private Action hideRequested; + private MenuItem[]? mainMenuItems; - private Triangles triangles; - private StarCounter starCounter; + private Action? selectRequested; + private Action? hideRequested; - [Resolved(CanBeNull = true)] - private BeatmapSetOverlay beatmapOverlay { get; set; } + private Triangles triangles = null!; + + private StarCounter starCounter = null!; + private DifficultyIcon difficultyIcon = null!; [Resolved] - private BeatmapDifficultyCache difficultyCache { get; set; } + private BeatmapSetOverlay? beatmapOverlay { get; set; } - [Resolved(CanBeNull = true)] - private CollectionManager collectionManager { get; set; } + [Resolved] + private BeatmapDifficultyCache difficultyCache { get; set; } = null!; - [Resolved(CanBeNull = true)] - private ManageCollectionsDialog manageCollectionsDialog { get; set; } + [Resolved] + private ManageCollectionsDialog? manageCollectionsDialog { get; set; } - private IBindable starDifficultyBindable; - private CancellationTokenSource starDifficultyCancellationSource; + [Resolved] + private RealmAccess realm { get; set; } = null!; + + private IBindable starDifficultyBindable = null!; + private CancellationTokenSource? starDifficultyCancellationSource; public DrawableCarouselBeatmap(CarouselBeatmap panel) { @@ -74,16 +78,15 @@ namespace osu.Game.Screens.Select.Carousel Item = panel; } - [BackgroundDependencyLoader(true)] - private void load(BeatmapManager manager, SongSelect songSelect) + [BackgroundDependencyLoader] + private void load(BeatmapManager? manager, SongSelect? songSelect) { Header.Height = height; if (songSelect != null) { - startRequested = b => songSelect.FinaliseSelection(b); - if (songSelect.AllowEditing) - editRequested = songSelect.Edit; + mainMenuItems = songSelect.CreateForwardNavigationMenuItemsForBeatmap(beatmapInfo); + selectRequested = b => songSelect.FinaliseSelection(b); } if (manager != null) @@ -111,8 +114,9 @@ namespace osu.Game.Screens.Select.Carousel Origin = Anchor.CentreLeft, Children = new Drawable[] { - new DifficultyIcon(beatmapInfo, shouldShowTooltip: false) + difficultyIcon = new DifficultyIcon(beatmapInfo) { + ShowTooltip = false, Scale = new Vector2(1.8f), }, new FillFlowContainer @@ -148,17 +152,12 @@ namespace osu.Game.Screens.Select.Carousel { Direction = FillDirection.Horizontal, Spacing = new Vector2(4, 0), + Scale = new Vector2(0.8f), AutoSizeAxes = Axes.Both, Children = new Drawable[] { - new TopLocalRank(beatmapInfo) - { - Scale = new Vector2(0.8f), - }, - starCounter = new StarCounter - { - Scale = new Vector2(0.8f), - } + new TopLocalRank(beatmapInfo), + starCounter = new StarCounter() } } } @@ -193,27 +192,29 @@ namespace osu.Game.Screens.Select.Carousel protected override bool OnClick(ClickEvent e) { - if (Item.State.Value == CarouselItemState.Selected) - startRequested?.Invoke(beatmapInfo); + if (Item?.State.Value == CarouselItemState.Selected) + selectRequested?.Invoke(beatmapInfo); return base.OnClick(e); } protected override void ApplyState() { - if (Item.State.Value != CarouselItemState.Collapsed && Alpha == 0) + if (Item?.State.Value != CarouselItemState.Collapsed && Alpha == 0) starCounter.ReplayAnimation(); starDifficultyCancellationSource?.Cancel(); // Only compute difficulty when the item is visible. - if (Item.State.Value != CarouselItemState.Collapsed) + if (Item?.State.Value != CarouselItemState.Collapsed) { // We've potentially cancelled the computation above so a new bindable is required. starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmapInfo, (starDifficultyCancellationSource = new CancellationTokenSource()).Token); starDifficultyBindable.BindValueChanged(d => { starCounter.Current = (float)(d.NewValue?.Stars ?? 0); + if (d.NewValue != null) + difficultyIcon.Current.Value = d.NewValue.Value; }, true); } @@ -226,23 +227,17 @@ namespace osu.Game.Screens.Select.Carousel { List items = new List(); - if (startRequested != null) - items.Add(new OsuMenuItem("Play", MenuItemType.Highlighted, () => startRequested(beatmapInfo))); - - if (editRequested != null) - items.Add(new OsuMenuItem(CommonStrings.ButtonsEdit, MenuItemType.Standard, () => editRequested(beatmapInfo))); + if (mainMenuItems != null) + items.AddRange(mainMenuItems); if (beatmapInfo.OnlineID > 0 && beatmapOverlay != null) items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => beatmapOverlay.FetchAndShowBeatmap(beatmapInfo.OnlineID))); - if (collectionManager != null) - { - var collectionItems = collectionManager.Collections.Select(createCollectionMenuItem).ToList(); - if (manageCollectionsDialog != null) - collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, manageCollectionsDialog.Show)); + var collectionItems = realm.Realm.All().AsEnumerable().Select(c => new CollectionToggleMenuItem(c.ToLive(realm), beatmapInfo)).Cast().ToList(); + if (manageCollectionsDialog != null) + collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, manageCollectionsDialog.Show)); - items.Add(new OsuMenuItem("Collections") { Items = collectionItems }); - } + items.Add(new OsuMenuItem("Collections") { Items = collectionItems }); if (hideRequested != null) items.Add(new OsuMenuItem(CommonStrings.ButtonsHide.ToSentence(), MenuItemType.Destructive, () => hideRequested(beatmapInfo))); @@ -251,20 +246,6 @@ namespace osu.Game.Screens.Select.Carousel } } - private MenuItem createCollectionMenuItem(BeatmapCollection collection) - { - return new ToggleMenuItem(collection.Name.Value, MenuItemType.Standard, s => - { - if (s) - collection.Beatmaps.Add(beatmapInfo); - else - collection.Beatmaps.Remove(beatmapInfo); - }) - { - State = { Value = collection.Beatmaps.Contains(beatmapInfo) } - }; - } - protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs index 2d70b1aecb..3975bb6bb6 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -15,39 +14,38 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Collections; +using osu.Game.Database; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; namespace osu.Game.Screens.Select.Carousel { - public class DrawableCarouselBeatmapSet : DrawableCarouselItem, IHasContextMenu + public partial class DrawableCarouselBeatmapSet : DrawableCarouselItem, IHasContextMenu { public const float HEIGHT = MAX_HEIGHT; - private Action restoreHiddenRequested; - private Action viewDetails; + private Action restoreHiddenRequested = null!; + private Action? viewDetails; - [Resolved(CanBeNull = true)] - private IDialogOverlay dialogOverlay { get; set; } + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } - [Resolved(CanBeNull = true)] - private CollectionManager collectionManager { get; set; } + [Resolved] + private ManageCollectionsDialog? manageCollectionsDialog { get; set; } - [Resolved(CanBeNull = true)] - private ManageCollectionsDialog manageCollectionsDialog { get; set; } + [Resolved] + private RealmAccess realm { get; set; } = null!; public IEnumerable DrawableBeatmaps => beatmapContainer?.IsLoaded != true ? Enumerable.Empty() : beatmapContainer.AliveChildren; - [CanBeNull] - private Container beatmapContainer; + private Container? beatmapContainer; - private BeatmapSetInfo beatmapSet; + private BeatmapSetInfo beatmapSet = null!; - [CanBeNull] - private Task beatmapsLoadTask; + private Task? beatmapsLoadTask; [Resolved] - private BeatmapManager manager { get; set; } + private BeatmapManager manager { get; set; } = null!; protected override void FreeAfterUse() { @@ -58,8 +56,8 @@ namespace osu.Game.Screens.Select.Carousel ClearTransforms(); } - [BackgroundDependencyLoader(true)] - private void load(BeatmapSetOverlay beatmapOverlay) + [BackgroundDependencyLoader] + private void load(BeatmapSetOverlay? beatmapOverlay) { restoreHiddenRequested = s => { @@ -75,10 +73,11 @@ namespace osu.Game.Screens.Select.Carousel { base.Update(); + Debug.Assert(Item != null); + // position updates should not occur if the item is filtered away. // this avoids panels flying across the screen only to be eventually off-screen or faded out. - if (!Item.Visible) - return; + if (!Item.Visible) return; float targetY = Item.CarouselYPosition; @@ -148,9 +147,11 @@ namespace osu.Game.Screens.Select.Carousel private void updateBeatmapDifficulties() { + Debug.Assert(Item != null); + var carouselBeatmapSet = (CarouselBeatmapSet)Item; - var visibleBeatmaps = carouselBeatmapSet.Children.Where(c => c.Visible).ToArray(); + var visibleBeatmaps = carouselBeatmapSet.Items.Where(c => c.Visible).ToArray(); // if we are already displaying all the correct beatmaps, only run animation updates. // note that the displayed beatmaps may change due to the applied filter. @@ -168,7 +169,7 @@ namespace osu.Game.Screens.Select.Carousel { X = 100, RelativeSizeAxes = Axes.Both, - ChildrenEnumerable = visibleBeatmaps.Select(c => c.CreateDrawableRepresentation()) + ChildrenEnumerable = visibleBeatmaps.Select(c => c.CreateDrawableRepresentation()!) }; beatmapsLoadTask = LoadComponentAsync(beatmapContainer, loaded => @@ -193,10 +194,12 @@ namespace osu.Game.Screens.Select.Carousel float yPos = DrawableCarouselBeatmap.CAROUSEL_BEATMAP_SPACING; - bool isSelected = Item.State.Value == CarouselItemState.Selected; + bool isSelected = Item?.State.Value == CarouselItemState.Selected; foreach (var panel in beatmapContainer.Children) { + Debug.Assert(panel.Item != null); + if (isSelected) { panel.MoveToY(yPos, 800, Easing.OutQuint); @@ -215,20 +218,17 @@ namespace osu.Game.Screens.Select.Carousel List items = new List(); - if (Item.State.Value == CarouselItemState.NotSelected) + if (Item?.State.Value == CarouselItemState.NotSelected) items.Add(new OsuMenuItem("Expand", MenuItemType.Highlighted, () => Item.State.Value = CarouselItemState.Selected)); if (beatmapSet.OnlineID > 0 && viewDetails != null) items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => viewDetails(beatmapSet.OnlineID))); - if (collectionManager != null) - { - var collectionItems = collectionManager.Collections.Select(createCollectionMenuItem).ToList(); - if (manageCollectionsDialog != null) - collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, manageCollectionsDialog.Show)); + var collectionItems = realm.Realm.All().AsEnumerable().Select(createCollectionMenuItem).ToList(); + if (manageCollectionsDialog != null) + collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, manageCollectionsDialog.Show)); - items.Add(new OsuMenuItem("Collections") { Items = collectionItems }); - } + items.Add(new OsuMenuItem("Collections") { Items = collectionItems }); if (beatmapSet.Beatmaps.Any(b => b.Hidden)) items.Add(new OsuMenuItem("Restore all hidden", MenuItemType.Standard, () => restoreHiddenRequested(beatmapSet))); @@ -245,7 +245,7 @@ namespace osu.Game.Screens.Select.Carousel TernaryState state; - int countExisting = beatmapSet.Beatmaps.Count(b => collection.Beatmaps.Contains(b)); + int countExisting = beatmapSet.Beatmaps.Count(b => collection.BeatmapMD5Hashes.Contains(b.MD5Hash)); if (countExisting == beatmapSet.Beatmaps.Count) state = TernaryState.True; @@ -254,24 +254,29 @@ namespace osu.Game.Screens.Select.Carousel else state = TernaryState.False; - return new TernaryStateToggleMenuItem(collection.Name.Value, MenuItemType.Standard, s => + var liveCollection = collection.ToLive(realm); + + return new TernaryStateToggleMenuItem(collection.Name, MenuItemType.Standard, s => { - foreach (var b in beatmapSet.Beatmaps) + liveCollection.PerformWrite(c => { - switch (s) + foreach (var b in beatmapSet.Beatmaps) { - case TernaryState.True: - if (collection.Beatmaps.Contains(b)) - continue; + switch (s) + { + case TernaryState.True: + if (c.BeatmapMD5Hashes.Contains(b.MD5Hash)) + continue; - collection.Beatmaps.Add(b); - break; + c.BeatmapMD5Hashes.Add(b.MD5Hash); + break; - case TernaryState.False: - collection.Beatmaps.Remove(b); - break; + case TernaryState.False: + c.BeatmapMD5Hashes.Remove(b.MD5Hash); + break; + } } - } + }); }) { State = { Value = state } diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs index 75bcdedec4..f065926eb7 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs @@ -11,7 +11,7 @@ using osuTK; namespace osu.Game.Screens.Select.Carousel { - public abstract class DrawableCarouselItem : PoolableDrawable + public abstract partial class DrawableCarouselItem : PoolableDrawable { public const float MAX_HEIGHT = 80; @@ -32,9 +32,9 @@ namespace osu.Game.Screens.Select.Carousel public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Header.ReceivePositionalInputAt(screenSpacePos); - private CarouselItem item; + private CarouselItem? item; - public CarouselItem Item + public CarouselItem? Item { get => item; set @@ -51,7 +51,7 @@ namespace osu.Game.Screens.Select.Carousel if (item is CarouselGroup group) { - foreach (var c in group.Children) + foreach (var c in group.Items) c.Filtered.ValueChanged -= onStateChange; } } @@ -103,7 +103,7 @@ namespace osu.Game.Screens.Select.Carousel protected virtual void UpdateItem() { - if (item == null) + if (Item == null) return; Scheduler.AddOnce(ApplyState); @@ -115,7 +115,7 @@ namespace osu.Game.Screens.Select.Carousel if (Item is CarouselGroup group) { - foreach (var c in group.Children) + foreach (var c in group.Items) c.Filtered.ValueChanged += onStateChange; } } @@ -126,12 +126,12 @@ namespace osu.Game.Screens.Select.Carousel protected virtual void ApplyState() { + Debug.Assert(Item != null); + // Use the fact that we know the precise height of the item from the model to avoid the need for AutoSize overhead. // Additionally, AutoSize doesn't work well due to content starting off-screen and being masked away. Height = Item.TotalHeight; - Debug.Assert(Item != null); - switch (Item.State.Value) { case CarouselItemState.NotSelected: @@ -160,6 +160,8 @@ namespace osu.Game.Screens.Select.Carousel protected override bool OnClick(ClickEvent e) { + Debug.Assert(Item != null); + Item.State.Value = CarouselItemState.Selected; return true; } diff --git a/osu.Game/Screens/Select/Carousel/FilterableDifficultyIcon.cs b/osu.Game/Screens/Select/Carousel/FilterableDifficultyIcon.cs index ce0cec837b..cd8e20ad39 100644 --- a/osu.Game/Screens/Select/Carousel/FilterableDifficultyIcon.cs +++ b/osu.Game/Screens/Select/Carousel/FilterableDifficultyIcon.cs @@ -8,7 +8,7 @@ using osu.Game.Beatmaps.Drawables; namespace osu.Game.Screens.Select.Carousel { - public class FilterableDifficultyIcon : DifficultyIcon + public partial class FilterableDifficultyIcon : DifficultyIcon { private readonly BindableBool filtered = new BindableBool(); @@ -17,7 +17,7 @@ namespace osu.Game.Screens.Select.Carousel public readonly CarouselBeatmap Item; public FilterableDifficultyIcon(CarouselBeatmap item) - : base(item.BeatmapInfo, performBackgroundDifficultyLookup: false) + : base(item.BeatmapInfo) { filtered.BindTo(item.Filtered); filtered.ValueChanged += isFiltered => Schedule(() => this.FadeTo(isFiltered.NewValue ? 0.1f : 1, 100)); diff --git a/osu.Game/Screens/Select/Carousel/FilterableGroupedDifficultyIcon.cs b/osu.Game/Screens/Select/Carousel/GroupedDifficultyIcon.cs similarity index 52% rename from osu.Game/Screens/Select/Carousel/FilterableGroupedDifficultyIcon.cs rename to osu.Game/Screens/Select/Carousel/GroupedDifficultyIcon.cs index acffdd9f64..3de44fa032 100644 --- a/osu.Game/Screens/Select/Carousel/FilterableGroupedDifficultyIcon.cs +++ b/osu.Game/Screens/Select/Carousel/GroupedDifficultyIcon.cs @@ -6,23 +6,42 @@ using System.Linq; using osu.Framework.Graphics; using osu.Framework.Input.Events; using osu.Game.Beatmaps.Drawables; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; using osu.Game.Rulesets; using osuTK.Graphics; namespace osu.Game.Screens.Select.Carousel { - public class FilterableGroupedDifficultyIcon : GroupedDifficultyIcon + /// + /// A difficulty icon that contains a counter on the right-side of it. + /// + /// + /// Used in cases when there are too many difficulty icons to show. + /// + public partial class GroupedDifficultyIcon : DifficultyIcon { public readonly List Items; - public FilterableGroupedDifficultyIcon(List items, RulesetInfo ruleset) - : base(items.Select(i => i.BeatmapInfo).ToList(), ruleset, Color4.White) + public GroupedDifficultyIcon(List items, RulesetInfo ruleset) + : base(items.OrderBy(b => b.BeatmapInfo.StarRating).Last().BeatmapInfo, ruleset) { Items = items; foreach (var item in items) item.Filtered.BindValueChanged(_ => Scheduler.AddOnce(updateFilteredDisplay)); + AddInternal(new OsuSpriteText + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Padding = new MarginPadding { Left = Size.X }, + Margin = new MarginPadding { Left = 2, Right = 5 }, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold), + Text = items.Count.ToString(), + Colour = Color4.White, + }); + updateFilteredDisplay(); } diff --git a/osu.Game/Screens/Select/Carousel/SetPanelBackground.cs b/osu.Game/Screens/Select/Carousel/SetPanelBackground.cs index 2a37969be1..6f13a34bfc 100644 --- a/osu.Game/Screens/Select/Carousel/SetPanelBackground.cs +++ b/osu.Game/Screens/Select/Carousel/SetPanelBackground.cs @@ -12,7 +12,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Select.Carousel { - public class SetPanelBackground : BufferedContainer + public partial class SetPanelBackground : BufferedContainer { public SetPanelBackground(IWorkingBeatmap working) : base(cachedFrameBuffer: true) diff --git a/osu.Game/Screens/Select/Carousel/SetPanelContent.cs b/osu.Game/Screens/Select/Carousel/SetPanelContent.cs index a000cfd5fc..8d6fbbf256 100644 --- a/osu.Game/Screens/Select/Carousel/SetPanelContent.cs +++ b/osu.Game/Screens/Select/Carousel/SetPanelContent.cs @@ -14,13 +14,15 @@ using osuTK; namespace osu.Game.Screens.Select.Carousel { - public class SetPanelContent : CompositeDrawable + public partial class SetPanelContent : CompositeDrawable { // Disallow interacting with difficulty icons on a panel until the panel has been selected. public override bool PropagatePositionalInputSubTree => carouselSet.State.Value == CarouselItemState.Selected; private readonly CarouselBeatmapSet carouselSet; + private FillFlowContainer iconFlow = null!; + public SetPanelContent(CarouselBeatmapSet carouselSet) { this.carouselSet = carouselSet; @@ -59,23 +61,35 @@ namespace osu.Game.Screens.Select.Carousel Direction = FillDirection.Horizontal, AutoSizeAxes = Axes.Both, Margin = new MarginPadding { Top = 5 }, - Children = new Drawable[] + Spacing = new Vector2(5), + Children = new[] { + beatmapSet.AllBeatmapsUpToDate + ? Empty() + : new Container + { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Children = new Drawable[] + { + new UpdateBeatmapSetButton(beatmapSet), + } + }, new BeatmapSetOnlineStatusPill { AutoSizeAxes = Axes.Both, Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, - Margin = new MarginPadding { Right = 5 }, TextSize = 11, TextPadding = new MarginPadding { Horizontal = 8, Vertical = 2 }, Status = beatmapSet.Status }, - new FillFlowContainer + iconFlow = new FillFlowContainer { AutoSizeAxes = Axes.Both, + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, Spacing = new Vector2(3), - ChildrenEnumerable = getDifficultyIcons(), }, } } @@ -83,6 +97,12 @@ namespace osu.Game.Screens.Select.Carousel }; } + protected override void LoadComplete() + { + base.LoadComplete(); + iconFlow.ChildrenEnumerable = getDifficultyIcons(); + } + private const int maximum_difficulty_icons = 18; private IEnumerable getDifficultyIcons() @@ -90,8 +110,8 @@ namespace osu.Game.Screens.Select.Carousel var beatmaps = carouselSet.Beatmaps.ToList(); return beatmaps.Count > maximum_difficulty_icons - ? (IEnumerable)beatmaps.GroupBy(b => b.BeatmapInfo.Ruleset) - .Select(group => new FilterableGroupedDifficultyIcon(group.ToList(), group.Last().BeatmapInfo.Ruleset)) + ? beatmaps.GroupBy(b => b.BeatmapInfo.Ruleset) + .Select(group => new GroupedDifficultyIcon(group.ToList(), group.Last().BeatmapInfo.Ruleset)) : beatmaps.Select(b => new FilterableDifficultyIcon(b)); } } diff --git a/osu.Game/Screens/Select/Carousel/TopLocalRank.cs b/osu.Game/Screens/Select/Carousel/TopLocalRank.cs index e1f9c1b508..a57a8b0f27 100644 --- a/osu.Game/Screens/Select/Carousel/TopLocalRank.cs +++ b/osu.Game/Screens/Select/Carousel/TopLocalRank.cs @@ -6,6 +6,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Models; @@ -18,27 +19,39 @@ using Realms; namespace osu.Game.Screens.Select.Carousel { - public class TopLocalRank : UpdateableRank + public partial class TopLocalRank : CompositeDrawable { private readonly BeatmapInfo beatmapInfo; [Resolved] - private IBindable ruleset { get; set; } + private IBindable ruleset { get; set; } = null!; [Resolved] - private RealmAccess realm { get; set; } + private RealmAccess realm { get; set; } = null!; [Resolved] - private IAPIProvider api { get; set; } + private ScoreManager scoreManager { get; set; } = null!; - private IDisposable scoreSubscription; + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private IDisposable? scoreSubscription; + + private readonly UpdateableRank updateable; + + public ScoreRank? DisplayedRank => updateable.Rank; public TopLocalRank(BeatmapInfo beatmapInfo) - : base(null) { this.beatmapInfo = beatmapInfo; - Size = new Vector2(40, 20); + AutoSizeAxes = Axes.Both; + + InternalChild = updateable = new UpdateableRank + { + Size = new Vector2(40, 20), + Alpha = 0, + }; } protected override void LoadComplete() @@ -52,24 +65,29 @@ namespace osu.Game.Screens.Select.Carousel r.All() .Filter($"{nameof(ScoreInfo.User)}.{nameof(RealmUser.OnlineID)} == $0" + $" && {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} == $1" + + $" && {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.Hash)} == {nameof(ScoreInfo.BeatmapHash)}" + $" && {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $2" - + $" && {nameof(ScoreInfo.DeletePending)} == false", api.LocalUser.Value.Id, beatmapInfo.ID, ruleset.Value.ShortName) - .OrderByDescending(s => s.TotalScore), - (items, changes, ___) => - { - Rank = items.FirstOrDefault()?.Rank; - // Required since presence is changed via IsPresent override - Invalidate(Invalidation.Presence); - }); + + $" && {nameof(ScoreInfo.DeletePending)} == false", api.LocalUser.Value.Id, beatmapInfo.ID, ruleset.Value.ShortName), + localScoresChanged); }, true); - } - public override bool IsPresent => base.IsPresent && Rank != null; + void localScoresChanged(IRealmCollection sender, ChangeSet? changes, Exception _) + { + // This subscription may fire from changes to linked beatmaps, which we don't care about. + // It's currently not possible for a score to be modified after insertion, so we can safely ignore callbacks with only modifications. + if (changes?.HasCollectionChanges() == false) + return; + + ScoreInfo? topScore = scoreManager.OrderByTotalScore(sender.Detach()).FirstOrDefault(); + + updateable.Rank = topScore?.Rank; + updateable.Alpha = topScore != null ? 1 : 0; + } + } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - scoreSubscription?.Dispose(); } } diff --git a/osu.Game/Screens/Select/Carousel/UpdateBeatmapSetButton.cs b/osu.Game/Screens/Select/Carousel/UpdateBeatmapSetButton.cs new file mode 100644 index 0000000000..e45583887a --- /dev/null +++ b/osu.Game/Screens/Select/Carousel/UpdateBeatmapSetButton.cs @@ -0,0 +1,178 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Select.Carousel +{ + public partial class UpdateBeatmapSetButton : OsuAnimatedButton + { + private readonly BeatmapSetInfo beatmapSetInfo; + private SpriteIcon icon = null!; + private Box progressFill = null!; + + [Resolved] + private BeatmapModelDownloader beatmapDownloader { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private LoginOverlay? loginOverlay { get; set; } + + [Resolved] + private IDialogOverlay? dialogOverlay { get; set; } + + public UpdateBeatmapSetButton(BeatmapSetInfo beatmapSetInfo) + { + this.beatmapSetInfo = beatmapSetInfo; + + AutoSizeAxes = Axes.Both; + + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + } + + private Bindable preferNoVideo = null!; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + const float icon_size = 14; + + preferNoVideo = config.GetBindable(OsuSetting.PreferNoVideo); + + Content.Anchor = Anchor.CentreLeft; + Content.Origin = Anchor.CentreLeft; + + Content.AddRange(new Drawable[] + { + progressFill = new Box + { + Colour = Color4.White, + Alpha = 0.2f, + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + Width = 0, + }, + new FillFlowContainer + { + Padding = new MarginPadding { Horizontal = 5, Vertical = 3 }, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4), + Children = new Drawable[] + { + new Container + { + Size = new Vector2(icon_size), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Children = new Drawable[] + { + icon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.SyncAlt, + Size = new Vector2(icon_size), + }, + } + }, + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.Default.With(weight: FontWeight.Bold), + Text = "Update", + } + } + }, + }); + + Action = updateBeatmap; + } + + private bool updateConfirmed; + + private void updateBeatmap() + { + if (!api.IsLoggedIn) + { + loginOverlay?.Show(); + return; + } + + if (dialogOverlay != null && beatmapSetInfo.Status == BeatmapOnlineStatus.LocallyModified && !updateConfirmed) + { + dialogOverlay.Push(new UpdateLocalConfirmationDialog(() => + { + updateConfirmed = true; + updateBeatmap(); + })); + + return; + } + + updateConfirmed = false; + + beatmapDownloader.DownloadAsUpdate(beatmapSetInfo, preferNoVideo.Value); + attachExistingDownload(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + icon.Spin(4000, RotationDirection.Clockwise); + } + + private void attachExistingDownload() + { + var download = beatmapDownloader.GetExistingDownload(beatmapSetInfo); + + if (download != null) + { + Enabled.Value = false; + TooltipText = string.Empty; + + download.DownloadProgressed += progress => progressFill.ResizeWidthTo(progress, 100, Easing.OutQuint); + download.Failure += _ => attachExistingDownload(); + } + else + { + Enabled.Value = true; + TooltipText = "Update beatmap with online changes"; + + progressFill.ResizeWidthTo(0, 100, Easing.OutQuint); + } + } + + protected override bool OnHover(HoverEvent e) + { + icon.Spin(400, RotationDirection.Clockwise); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + icon.Spin(4000, RotationDirection.Clockwise); + base.OnHoverLost(e); + } + } +} diff --git a/osu.Game/Screens/Select/Carousel/UpdateLocalConfirmationDialog.cs b/osu.Game/Screens/Select/Carousel/UpdateLocalConfirmationDialog.cs new file mode 100644 index 0000000000..6157e8f6a5 --- /dev/null +++ b/osu.Game/Screens/Select/Carousel/UpdateLocalConfirmationDialog.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics.Sprites; +using osu.Game.Overlays.Dialog; +using osu.Game.Localisation; + +namespace osu.Game.Screens.Select.Carousel +{ + public partial class UpdateLocalConfirmationDialog : DangerousActionDialog + { + public UpdateLocalConfirmationDialog(Action onConfirm) + { + HeaderText = PopupDialogStrings.UpdateLocallyModifiedText; + BodyText = PopupDialogStrings.UpdateLocallyModifiedDescription; + Icon = FontAwesome.Solid.ExclamationTriangle; + DangerousAction = onConfirm; + } + } +} diff --git a/osu.Game/Screens/Select/Details/AdvancedStats.cs b/osu.Game/Screens/Select/Details/AdvancedStats.cs index a6f2520472..a383298faa 100644 --- a/osu.Game/Screens/Select/Details/AdvancedStats.cs +++ b/osu.Game/Screens/Select/Details/AdvancedStats.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -26,16 +28,18 @@ using osu.Game.Rulesets; namespace osu.Game.Screens.Select.Details { - public class AdvancedStats : Container + public partial class AdvancedStats : Container { + [Resolved] + private BeatmapDifficultyCache difficultyCache { get; set; } + [Resolved] private IBindable> mods { get; set; } [Resolved] - private IBindable ruleset { get; set; } + private OsuGameBase game { get; set; } - [Resolved] - private BeatmapDifficultyCache difficultyCache { get; set; } + private IBindable gameRuleset; protected readonly StatisticRow FirstValue, HpDrain, Accuracy, ApproachRate; private readonly StatisticRow starDifficulty; @@ -82,7 +86,13 @@ namespace osu.Game.Screens.Select.Details { base.LoadComplete(); - ruleset.BindValueChanged(_ => updateStatistics()); + // the cached ruleset bindable might be a decoupled bindable provided by SongSelect, + // which we can't rely on in combination with the game-wide selected mods list, + // since mods could be updated to the new ruleset instances while the decoupled bindable is held behind, + // therefore resulting in performing difficulty calculation with invalid states. + gameRuleset = game.Ruleset.GetBoundCopy(); + gameRuleset.BindValueChanged(_ => updateStatistics()); + mods.BindValueChanged(modsChanged, true); } @@ -94,7 +104,7 @@ namespace osu.Game.Screens.Select.Details modSettingChangeTracker?.Dispose(); modSettingChangeTracker = new ModSettingChangeTracker(mods.NewValue); - modSettingChangeTracker.SettingChanged += m => + modSettingChangeTracker.SettingChanged += _ => { debouncedStatisticsUpdate?.Cancel(); debouncedStatisticsUpdate = Scheduler.AddDelayed(updateStatistics, 100); @@ -140,7 +150,14 @@ namespace osu.Game.Screens.Select.Details private CancellationTokenSource starDifficultyCancellationSource; - private void updateStarDifficulty() + /// + /// Updates the displayed star difficulty statistics with the values provided by the currently-selected beatmap, ruleset, and selected mods. + /// + /// + /// This is scheduled to avoid scenarios wherein a ruleset changes first before selected mods do, + /// potentially resulting in failure during difficulty calculation due to incomplete bindable state updates. + /// + private void updateStarDifficulty() => Scheduler.AddOnce(() => { starDifficultyCancellationSource?.Cancel(); @@ -149,8 +166,8 @@ namespace osu.Game.Screens.Select.Details starDifficultyCancellationSource = new CancellationTokenSource(); - var normalStarDifficultyTask = difficultyCache.GetDifficultyAsync(BeatmapInfo, ruleset.Value, null, starDifficultyCancellationSource.Token); - var moddedStarDifficultyTask = difficultyCache.GetDifficultyAsync(BeatmapInfo, ruleset.Value, mods.Value, starDifficultyCancellationSource.Token); + var normalStarDifficultyTask = difficultyCache.GetDifficultyAsync(BeatmapInfo, gameRuleset.Value, null, starDifficultyCancellationSource.Token); + var moddedStarDifficultyTask = difficultyCache.GetDifficultyAsync(BeatmapInfo, gameRuleset.Value, mods.Value, starDifficultyCancellationSource.Token); Task.WhenAll(normalStarDifficultyTask, moddedStarDifficultyTask).ContinueWith(_ => Schedule(() => { @@ -162,7 +179,7 @@ namespace osu.Game.Screens.Select.Details starDifficulty.Value = ((float)normalDifficulty.Value.Stars, (float)moddedDifficulty.Value.Stars); }), starDifficultyCancellationSource.Token, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Current); - } + }); protected override void Dispose(bool isDisposing) { @@ -171,7 +188,7 @@ namespace osu.Game.Screens.Select.Details starDifficultyCancellationSource?.Cancel(); } - public class StatisticRow : Container, IHasAccentColour + public partial class StatisticRow : Container, IHasAccentColour { private const float value_width = 25; private const float name_width = 70; diff --git a/osu.Game/Screens/Select/Details/FailRetryGraph.cs b/osu.Game/Screens/Select/Details/FailRetryGraph.cs index 312c55b242..9891ef6463 100644 --- a/osu.Game/Screens/Select/Details/FailRetryGraph.cs +++ b/osu.Game/Screens/Select/Details/FailRetryGraph.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osu.Game.Beatmaps; namespace osu.Game.Screens.Select.Details { - public class FailRetryGraph : Container + public partial class FailRetryGraph : Container { private readonly BarGraph retryGraph, failGraph; diff --git a/osu.Game/Screens/Select/Details/UserRatings.cs b/osu.Game/Screens/Select/Details/UserRatings.cs index c2be3528fc..3664a89394 100644 --- a/osu.Game/Screens/Select/Details/UserRatings.cs +++ b/osu.Game/Screens/Select/Details/UserRatings.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.LocalisationExtensions; @@ -13,7 +15,7 @@ using osu.Game.Resources.Localisation.Web; namespace osu.Game.Screens.Select.Details { - public class UserRatings : Container + public partial class UserRatings : Container { private readonly FillFlowContainer header; private readonly Bar ratingsBar; diff --git a/osu.Game/Screens/Select/Filter/GroupMode.cs b/osu.Game/Screens/Select/Filter/GroupMode.cs index d794c215a3..8e2b9271b0 100644 --- a/osu.Game/Screens/Select/Filter/GroupMode.cs +++ b/osu.Game/Screens/Select/Filter/GroupMode.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; namespace osu.Game.Screens.Select.Filter diff --git a/osu.Game/Screens/Select/Filter/Operator.cs b/osu.Game/Screens/Select/Filter/Operator.cs index 706daf631f..a6a53f0c3e 100644 --- a/osu.Game/Screens/Select/Filter/Operator.cs +++ b/osu.Game/Screens/Select/Filter/Operator.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Screens.Select.Filter { /// diff --git a/osu.Game/Screens/Select/Filter/SortMode.cs b/osu.Game/Screens/Select/Filter/SortMode.cs index 3dd3381059..c77bdbfbc6 100644 --- a/osu.Game/Screens/Select/Filter/SortMode.cs +++ b/osu.Game/Screens/Select/Filter/SortMode.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; @@ -18,9 +20,18 @@ namespace osu.Game.Screens.Select.Filter [LocalisableDescription(typeof(SortStrings), nameof(SortStrings.ArtistTracksBpm))] BPM, + [Description("Date Submitted")] + DateSubmitted, + [Description("Date Added")] DateAdded, + [Description("Date Ranked")] + DateRanked, + + [Description("Last Played")] + LastPlayed, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingDifficulty))] Difficulty, diff --git a/osu.Game/Screens/Select/FilterControl.cs b/osu.Game/Screens/Select/FilterControl.cs index 65dde146bb..38520a85b7 100644 --- a/osu.Game/Screens/Select/FilterControl.cs +++ b/osu.Game/Screens/Select/FilterControl.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -8,12 +10,14 @@ 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; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; using osu.Game.Screens.Select.Filter; @@ -22,19 +26,32 @@ using osuTK.Graphics; namespace osu.Game.Screens.Select { - public class FilterControl : Container + 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 FilterControlTextBox searchTextBox; + + private CollectionDropdown collectionDropdown; + public FilterCriteria CreateCriteria() { string query = searchTextBox.Text; @@ -45,7 +62,7 @@ namespace osu.Game.Screens.Select Sort = sortMode.Value, AllowConvertedBeatmaps = showConverted.Value, Ruleset = ruleset.Value, - Collection = collectionDropdown?.Current.Value?.Collection + CollectionBeatmapMD5Hashes = collectionDropdown.Current.Value?.Collection?.PerformRead(c => c.BeatmapMD5Hashes) }; if (!minimumStars.IsDefault) @@ -60,9 +77,6 @@ namespace osu.Game.Screens.Select return criteria; } - private SeekLimitedSearchTextBox searchTextBox; - private CollectionFilterDropdown collectionDropdown; - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) || sortTabs.ReceivePositionalInputAt(screenSpacePos); @@ -93,75 +107,93 @@ 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 FillFlowContainer - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Direction = FillDirection.Horizontal, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(OsuTabControl.HORIZONTAL_SPACING, 0), - Children = new Drawable[] + new OsuSpriteText { - new OsuTabControlCheckbox - { - Text = "Show converted", - Current = config.GetBindable(OsuSetting.ShowConvertedBeatmaps), - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - }, - sortTabs = new OsuTabControl - { - RelativeSizeAxes = Axes.X, - Width = 0.5f, - Height = 24, - AutoSort = true, - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - AccentColour = colours.GreenLight, - Current = { BindTarget = sortMode } - }, - new OsuSpriteText - { - Text = SortStrings.Default, - Font = OsuFont.GetFont(size: 14), - Margin = new MarginPadding(5), - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - }, - } - }, + 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, + }, + } } }, new Container { RelativeSizeAxes = Axes.X, - Height = 20, + Height = 40, Children = new Drawable[] { - collectionDropdown = new CollectionFilterDropdown + new RangeSlider + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Label = "Difficulty range", + LowerBound = config.GetBindable(OsuSetting.DisplayStarsMinimum), + UpperBound = config.GetBindable(OsuSetting.DisplayStarsMaximum), + RelativeSizeAxes = Axes.Both, + Width = 0.48f, + DefaultStringLowerBound = "0", + DefaultStringUpperBound = "∞", + DefaultTooltipUpperBound = UserInterfaceStrings.NoLimit, + TooltipSuffix = "stars" + }, + collectionDropdown = new CollectionDropdown { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, + RequestFilter = updateCriteria, RelativeSizeAxes = Axes.X, - Width = 0.4f, + Y = 4, + Width = 0.5f, } } }, @@ -185,15 +217,6 @@ namespace osu.Game.Screens.Select groupMode.BindValueChanged(_ => updateCriteria()); sortMode.BindValueChanged(_ => updateCriteria()); - collectionDropdown.Current.ValueChanged += val => - { - if (val.NewValue == null) - // may be null briefly while menu is repopulated. - return; - - updateCriteria(); - }; - searchTextBox.Current.ValueChanged += _ => updateCriteria(); updateCriteria(); @@ -224,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/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index 1f6e99f0c6..320bfb1b45 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -66,10 +68,10 @@ namespace osu.Game.Screens.Select } /// - /// The collection to filter beatmaps from. + /// Hashes from the to filter to. /// [CanBeNull] - public BeatmapCollection Collection; + public IEnumerable CollectionBeatmapMD5Hashes { get; set; } [CanBeNull] public IRulesetFilterCriteria RulesetCriteria { get; set; } diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index 94df8addb3..c86554ddbc 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text.RegularExpressions; @@ -22,7 +23,7 @@ namespace osu.Game.Screens.Select { foreach (Match match in query_syntax_regex.Matches(query)) { - string key = match.Groups["key"].Value.ToLower(); + string key = match.Groups["key"].Value.ToLowerInvariant(); var op = parseOperator(match.Groups["op"].Value); string value = match.Groups["value"].Value; @@ -121,10 +122,27 @@ namespace osu.Game.Screens.Select private static bool tryParseEnum(string value, out TEnum result) where TEnum : struct { - if (Enum.TryParse(value, true, out result)) return true; + // First try an exact match. + if (Enum.TryParse(value, true, out result)) + return true; - value = Enum.GetNames(typeof(TEnum)).FirstOrDefault(name => name.StartsWith(value, true, CultureInfo.InvariantCulture)); - return Enum.TryParse(value, true, out result); + // Then try a prefix match. + string? prefixMatch = Enum.GetNames(typeof(TEnum)).FirstOrDefault(name => name.StartsWith(value, true, CultureInfo.InvariantCulture)); + + if (prefixMatch == null) + return false; + + return Enum.TryParse(prefixMatch, true, out result); + } + + private static GroupCollection? tryMatchRegex(string value, string regex) + { + Match matches = Regex.Match(value, regex); + + if (matches.Success) + return matches.Groups; + + return null; } /// @@ -310,11 +328,45 @@ namespace osu.Game.Screens.Select private static bool tryUpdateLengthRange(FilterCriteria criteria, Operator op, string val) { - if (!tryParseDoubleWithPoint(val.TrimEnd('m', 's', 'h'), out double length)) + List parts = new List(); + + GroupCollection? match = null; + + match ??= tryMatchRegex(val, @"^((?\d+):)?(?\d+):(?\d+)$"); + match ??= tryMatchRegex(val, @"^((?\d+(\.\d+)?)h)?((?\d+(\.\d+)?)m)?((?\d+(\.\d+)?)s)?$"); + match ??= tryMatchRegex(val, @"^(?\d+(\.\d+)?)$"); + + if (match == null) return false; - int scale = getLengthScale(val); - return tryUpdateCriteriaRange(ref criteria.Length, op, length * scale, scale / 2.0); + if (match["seconds"].Success) + parts.Add(match["seconds"].Value + "s"); + if (match["minutes"].Success) + parts.Add(match["minutes"].Value + "m"); + if (match["hours"].Success) + parts.Add(match["hours"].Value + "h"); + + double totalLength = 0; + int minScale = 3600000; + + for (int i = 0; i < parts.Count; i++) + { + string part = parts[i]; + string partNoUnit = part.TrimEnd('m', 's', 'h'); + if (!tryParseDoubleWithPoint(partNoUnit, out double length)) + return false; + + if (i != parts.Count - 1 && length >= 60) + return false; + if (i != 0 && partNoUnit.Contains('.')) + return false; + + int scale = getLengthScale(part); + totalLength += length * scale; + minScale = Math.Min(minScale, scale); + } + + return tryUpdateCriteriaRange(ref criteria.Length, op, totalLength, minScale / 2.0); } } } diff --git a/osu.Game/Screens/Select/Footer.cs b/osu.Game/Screens/Select/Footer.cs index ee13ebda44..933df2464a 100644 --- a/osu.Game/Screens/Select/Footer.cs +++ b/osu.Game/Screens/Select/Footer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osuTK; @@ -14,7 +16,7 @@ using osu.Game.Graphics.UserInterface; namespace osu.Game.Screens.Select { - public class Footer : Container + public partial class Footer : Container { private readonly Box modeLight; @@ -55,7 +57,18 @@ namespace osu.Game.Screens.Select } } - private void updateModeLight() => modeLight.FadeColour(buttons.FirstOrDefault(b => b.IsHovered)?.SelectedColour ?? Color4.Transparent, TRANSITION_LENGTH, Easing.OutQuint); + private void updateModeLight() + { + var selectedButton = buttons.FirstOrDefault(b => b.Enabled.Value && b.IsHovered); + + if (selectedButton != null) + { + modeLight.FadeIn(TRANSITION_LENGTH, Easing.OutQuint); + modeLight.FadeColour(selectedButton.SelectedColour, TRANSITION_LENGTH, Easing.OutQuint); + } + else + modeLight.FadeOut(TRANSITION_LENGTH, Easing.OutQuint); + } public Footer() { @@ -76,6 +89,7 @@ namespace osu.Game.Screens.Select RelativeSizeAxes = Axes.X, Height = 3, Position = new Vector2(0, -3), + Colour = Color4.Black, }, new FillFlowContainer { diff --git a/osu.Game/Screens/Select/FooterButton.cs b/osu.Game/Screens/Select/FooterButton.cs index 1587f43258..128e750dca 100644 --- a/osu.Game/Screens/Select/FooterButton.cs +++ b/osu.Game/Screens/Select/FooterButton.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 osuTK; -using osuTK.Graphics; 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; using osu.Framework.Localisation; -using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; using osu.Game.Input.Bindings; -using osu.Framework.Input.Bindings; -using osu.Game.Graphics.UserInterface; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.Select { - public class FooterButton : OsuClickableContainer, IKeyBindingHandler + public partial class FooterButton : OsuClickableContainer, IKeyBindingHandler { public const float SHEAR_WIDTH = 7.5f; @@ -66,7 +67,6 @@ namespace osu.Game.Screens.Select private readonly Box light; public FooterButton() - : base(HoverSampleSet.Toolbar) { AutoSizeAxes = Axes.Both; Shear = SHEAR; @@ -120,10 +120,18 @@ namespace osu.Game.Screens.Select }; } + protected override void LoadComplete() + { + base.LoadComplete(); + Enabled.BindValueChanged(_ => updateDisplay(), true); + } + public Action Hovered; public Action HoverLost; public GlobalAction? Hotkey; + private bool mouseDown; + protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); @@ -140,32 +148,38 @@ namespace osu.Game.Screens.Select protected override bool OnHover(HoverEvent e) { Hovered?.Invoke(); - light.ScaleTo(new Vector2(1, 2), Footer.TRANSITION_LENGTH, Easing.OutQuint); - light.FadeColour(SelectedColour, Footer.TRANSITION_LENGTH, Easing.OutQuint); + updateDisplay(); return true; } protected override void OnHoverLost(HoverLostEvent e) { HoverLost?.Invoke(); - light.ScaleTo(new Vector2(1, 1), Footer.TRANSITION_LENGTH, Easing.OutQuint); - light.FadeColour(DeselectedColour, Footer.TRANSITION_LENGTH, Easing.OutQuint); + updateDisplay(); } protected override bool OnMouseDown(MouseDownEvent e) { - box.FadeTo(0.3f, Footer.TRANSITION_LENGTH * 2, Easing.OutQuint); + if (!Enabled.Value) + return true; + + mouseDown = true; + updateDisplay(); return base.OnMouseDown(e); } protected override void OnMouseUp(MouseUpEvent e) { - box.FadeOut(Footer.TRANSITION_LENGTH, Easing.OutQuint); + mouseDown = false; + updateDisplay(); base.OnMouseUp(e); } protected override bool OnClick(ClickEvent e) { + if (!Enabled.Value) + return true; + box.ClearTransforms(); box.Alpha = 1; box.FadeOut(Footer.TRANSITION_LENGTH * 3, Easing.OutQuint); @@ -184,5 +198,20 @@ namespace osu.Game.Screens.Select } public virtual void OnReleased(KeyBindingReleaseEvent e) { } + + private void updateDisplay() + { + this.FadeTo(Enabled.Value ? 1 : 0.25f, Footer.TRANSITION_LENGTH, Easing.OutQuint); + + light.ScaleTo(Enabled.Value && IsHovered ? new Vector2(1, 2) : new Vector2(1), Footer.TRANSITION_LENGTH, Easing.OutQuint); + light.FadeColour(Enabled.Value && IsHovered ? SelectedColour : DeselectedColour, Footer.TRANSITION_LENGTH, Easing.OutQuint); + + box.FadeTo(Enabled.Value & mouseDown ? 0.3f : 0f, Footer.TRANSITION_LENGTH * 2, Easing.OutQuint); + + if (Enabled.Value && IsHovered) + Hovered?.Invoke(); + else + HoverLost?.Invoke(); + } } } diff --git a/osu.Game/Screens/Select/FooterButtonMods.cs b/osu.Game/Screens/Select/FooterButtonMods.cs index 5bbca5ca1a..9a84f9a0aa 100644 --- a/osu.Game/Screens/Select/FooterButtonMods.cs +++ b/osu.Game/Screens/Select/FooterButtonMods.cs @@ -1,15 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Screens.Play.HUD; using osu.Game.Rulesets.Mods; using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics.UserInterface; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK; @@ -18,7 +22,7 @@ using osu.Game.Input.Bindings; namespace osu.Game.Screens.Select { - public class FooterButtonMods : FooterButton, IHasCurrentValue> + public partial class FooterButtonMods : FooterButton, IHasCurrentValue> { public Bindable> Current { @@ -59,14 +63,28 @@ namespace osu.Game.Screens.Select Hotkey = GlobalAction.ToggleModSelection; } + [CanBeNull] + private ModSettingChangeTracker modSettingChangeTracker; + protected override void LoadComplete() { base.LoadComplete(); - Current.BindValueChanged(_ => updateMultiplierText(), true); + Current.BindValueChanged(mods => + { + modSettingChangeTracker?.Dispose(); + + updateMultiplierText(); + + if (mods.NewValue != null) + { + modSettingChangeTracker = new ModSettingChangeTracker(mods.NewValue); + modSettingChangeTracker.SettingChanged += _ => updateMultiplierText(); + } + }, true); } - private void updateMultiplierText() + private void updateMultiplierText() => Schedule(() => { double multiplier = Current.Value?.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier) ?? 1; @@ -83,6 +101,6 @@ namespace osu.Game.Screens.Select modDisplay.FadeIn(); else modDisplay.FadeOut(); - } + }); } } diff --git a/osu.Game/Screens/Select/FooterButtonOptions.cs b/osu.Game/Screens/Select/FooterButtonOptions.cs index e549656785..e56efcb458 100644 --- a/osu.Game/Screens/Select/FooterButtonOptions.cs +++ b/osu.Game/Screens/Select/FooterButtonOptions.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Game.Graphics; @@ -8,7 +10,7 @@ using osu.Game.Input.Bindings; namespace osu.Game.Screens.Select { - public class FooterButtonOptions : FooterButton + public partial class FooterButtonOptions : FooterButton { [BackgroundDependencyLoader] private void load(OsuColour colours) diff --git a/osu.Game/Screens/Select/FooterButtonRandom.cs b/osu.Game/Screens/Select/FooterButtonRandom.cs index f855b80f75..f413126e87 100644 --- a/osu.Game/Screens/Select/FooterButtonRandom.cs +++ b/osu.Game/Screens/Select/FooterButtonRandom.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -15,7 +17,7 @@ using osuTK.Input; namespace osu.Game.Screens.Select { - public class FooterButtonRandom : FooterButton + public partial class FooterButtonRandom : FooterButton { public Action NextRandom { get; set; } public Action PreviousRandom { get; set; } @@ -117,7 +119,7 @@ namespace osu.Game.Screens.Select protected override void OnMouseUp(MouseUpEvent e) { - if (e.Button == MouseButton.Right) + if (e.Button == MouseButton.Right && IsHovered) { rewindSearch = true; TriggerClick(); @@ -136,7 +138,8 @@ namespace osu.Game.Screens.Select return false; } - TriggerClick(); + if (!e.Repeat) + TriggerClick(); return true; } diff --git a/osu.Game/Screens/Select/FooterV2/FooterButtonModsV2.cs b/osu.Game/Screens/Select/FooterV2/FooterButtonModsV2.cs new file mode 100644 index 0000000000..b8c9f0b34b --- /dev/null +++ b/osu.Game/Screens/Select/FooterV2/FooterButtonModsV2.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.Allocation; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; + +namespace osu.Game.Screens.Select.FooterV2 +{ + public partial class FooterButtonModsV2 : FooterButtonV2 + { + [BackgroundDependencyLoader] + private void load(OsuColour colour) + { + Text = "Mods"; + Icon = FontAwesome.Solid.ExchangeAlt; + AccentColour = colour.Lime1; + } + } +} diff --git a/osu.Game/Screens/Select/FooterV2/FooterButtonOptionsV2.cs b/osu.Game/Screens/Select/FooterV2/FooterButtonOptionsV2.cs new file mode 100644 index 0000000000..87cca0042a --- /dev/null +++ b/osu.Game/Screens/Select/FooterV2/FooterButtonOptionsV2.cs @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Input.Bindings; + +namespace osu.Game.Screens.Select.FooterV2 +{ + public partial class FooterButtonOptionsV2 : FooterButtonV2 + { + [BackgroundDependencyLoader] + private void load(OsuColour colour) + { + Text = "Options"; + Icon = FontAwesome.Solid.Cog; + AccentColour = colour.Purple1; + Hotkey = GlobalAction.ToggleBeatmapOptions; + } + } +} diff --git a/osu.Game/Screens/Select/FooterV2/FooterButtonRandomV2.cs b/osu.Game/Screens/Select/FooterV2/FooterButtonRandomV2.cs new file mode 100644 index 0000000000..70d1c0c19e --- /dev/null +++ b/osu.Game/Screens/Select/FooterV2/FooterButtonRandomV2.cs @@ -0,0 +1,161 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Input.Bindings; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Screens.Select.FooterV2 +{ + public partial class FooterButtonRandomV2 : FooterButtonV2 + { + public Action? NextRandom { get; set; } + public Action? PreviousRandom { get; set; } + + private Container persistentText = null!; + private OsuSpriteText randomSpriteText = null!; + private OsuSpriteText rewindSpriteText = null!; + private bool rewindSearch; + + [BackgroundDependencyLoader] + private void load(OsuColour colour) + { + //TODO: use https://fontawesome.com/icons/shuffle?s=solid&f=classic when local Fontawesome is updated + Icon = FontAwesome.Solid.Random; + AccentColour = colour.Blue1; + TextContainer.Add(persistentText = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AlwaysPresent = true, + AutoSizeAxes = Axes.Both, + Children = new[] + { + randomSpriteText = new OsuSpriteText + { + Font = OsuFont.TorusAlternate.With(size: 19), + AlwaysPresent = true, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = "Random", + }, + rewindSpriteText = new OsuSpriteText + { + Font = OsuFont.TorusAlternate.With(size: 19), + AlwaysPresent = true, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = "Rewind", + Alpha = 0f, + } + } + }); + + Action = () => + { + if (rewindSearch) + { + const double fade_time = 500; + + OsuSpriteText fallingRewind; + + TextContainer.Add(fallingRewind = new OsuSpriteText + { + Alpha = 0, + Text = rewindSpriteText.Text, + AlwaysPresent = true, // make sure the button is sized large enough to always show this + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Font = OsuFont.TorusAlternate.With(size: 19), + }); + + fallingRewind.FadeOutFromOne(fade_time, Easing.In); + fallingRewind.MoveTo(Vector2.Zero).MoveTo(new Vector2(0, 10), fade_time, Easing.In); + fallingRewind.Expire(); + + persistentText.FadeInFromZero(fade_time, Easing.In); + + PreviousRandom?.Invoke(); + } + else + { + NextRandom?.Invoke(); + } + }; + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + updateText(e.ShiftPressed); + return base.OnKeyDown(e); + } + + protected override void OnKeyUp(KeyUpEvent e) + { + updateText(e.ShiftPressed); + base.OnKeyUp(e); + } + + protected override bool OnClick(ClickEvent e) + { + try + { + // this uses OR to handle rewinding when clicks are triggered by other sources (i.e. right button in OnMouseUp). + rewindSearch |= e.ShiftPressed; + return base.OnClick(e); + } + finally + { + rewindSearch = false; + } + } + + protected override void OnMouseUp(MouseUpEvent e) + { + if (e.Button == MouseButton.Right) + { + rewindSearch = true; + TriggerClick(); + return; + } + + base.OnMouseUp(e); + } + + public override bool OnPressed(KeyBindingPressEvent e) + { + rewindSearch = e.Action == GlobalAction.SelectPreviousRandom; + + if (e.Action != GlobalAction.SelectNextRandom && e.Action != GlobalAction.SelectPreviousRandom) + { + return false; + } + + if (!e.Repeat) + TriggerClick(); + return true; + } + + public override void OnReleased(KeyBindingReleaseEvent e) + { + if (e.Action == GlobalAction.SelectPreviousRandom) + { + rewindSearch = false; + } + } + + private void updateText(bool rewind = false) + { + randomSpriteText.Alpha = rewind ? 0 : 1; + rewindSpriteText.Alpha = rewind ? 1 : 0; + } + } +} diff --git a/osu.Game/Screens/Select/FooterV2/FooterButtonV2.cs b/osu.Game/Screens/Select/FooterV2/FooterButtonV2.cs new file mode 100644 index 0000000000..2f5046d2bb --- /dev/null +++ b/osu.Game/Screens/Select/FooterV2/FooterButtonV2.cs @@ -0,0 +1,211 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Input.Bindings; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Select.FooterV2 +{ + public partial class FooterButtonV2 : OsuClickableContainer, IKeyBindingHandler + { + private const int button_height = 90; + private const int button_width = 140; + private const int corner_radius = 10; + private const int transition_length = 500; + + // This should be 12 by design, but an extra allowance is added due to the corner radius specification. + public const float SHEAR_WIDTH = 13.5f; + + public Bindable OverlayState = new Bindable(); + + protected static readonly Vector2 SHEAR = new Vector2(SHEAR_WIDTH / button_height, 0); + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + private Colour4 buttonAccentColour; + + protected Colour4 AccentColour + { + set + { + buttonAccentColour = value; + bar.Colour = buttonAccentColour; + icon.Colour = buttonAccentColour; + } + } + + protected IconUsage Icon + { + set => icon.Icon = value; + } + + protected LocalisableString Text + { + set => text.Text = value; + } + + private readonly SpriteText text; + private readonly SpriteIcon icon; + + protected Container TextContainer; + private readonly Box bar; + private readonly Box backgroundBox; + + public FooterButtonV2() + { + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 4, + // Figma says 50% opacity, but it does not match up visually if taken at face value, and looks bad. + Colour = Colour4.Black.Opacity(0.25f), + Offset = new Vector2(0, 2), + }; + Shear = SHEAR; + Size = new Vector2(button_width, button_height); + Masking = true; + CornerRadius = corner_radius; + Children = new Drawable[] + { + backgroundBox = new Box + { + RelativeSizeAxes = Axes.Both + }, + + // For elements that should not be sheared. + new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Shear = -SHEAR, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + TextContainer = new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Y = 42, + AutoSizeAxes = Axes.Both, + Child = text = new OsuSpriteText + { + // figma design says the size is 16, but due to the issues with font sizes 19 matches better + Font = OsuFont.TorusAlternate.With(size: 19), + AlwaysPresent = true + } + }, + icon = new SpriteIcon + { + Y = 12, + Size = new Vector2(20), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre + }, + } + }, + new Container + { + Shear = -SHEAR, + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + Y = -corner_radius, + Size = new Vector2(120, 6), + Masking = true, + CornerRadius = 3, + Child = bar = new Box + { + RelativeSizeAxes = Axes.Both, + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + OverlayState.BindValueChanged(_ => updateDisplay()); + Enabled.BindValueChanged(_ => updateDisplay(), true); + + FinishTransforms(true); + } + + public GlobalAction? Hotkey; + + private bool handlingMouse; + + protected override bool OnHover(HoverEvent e) + { + updateDisplay(); + return true; + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + handlingMouse = true; + updateDisplay(); + return base.OnMouseDown(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + handlingMouse = false; + updateDisplay(); + base.OnMouseUp(e); + } + + protected override void OnHoverLost(HoverLostEvent e) => updateDisplay(); + + public virtual bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action != Hotkey || e.Repeat) return false; + + TriggerClick(); + return true; + } + + public virtual void OnReleased(KeyBindingReleaseEvent e) { } + + private void updateDisplay() + { + Color4 backgroundColour = colourProvider.Background3; + + if (!Enabled.Value) + { + backgroundColour = colourProvider.Background3.Darken(0.4f); + } + else + { + if (OverlayState.Value == Visibility.Visible) + backgroundColour = buttonAccentColour.Darken(0.5f); + + if (IsHovered) + { + backgroundColour = backgroundColour.Lighten(0.3f); + + if (handlingMouse) + backgroundColour = backgroundColour.Lighten(0.3f); + } + } + + backgroundBox.FadeColour(backgroundColour, transition_length, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Screens/Select/FooterV2/FooterV2.cs b/osu.Game/Screens/Select/FooterV2/FooterV2.cs new file mode 100644 index 0000000000..cd95f3eb6c --- /dev/null +++ b/osu.Game/Screens/Select/FooterV2/FooterV2.cs @@ -0,0 +1,75 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.Select.FooterV2 +{ + public partial class FooterV2 : InputBlockingContainer + { + //Should be 60, setting to 50 for now for the sake of matching the current BackButton height. + private const int height = 50; + private const int padding = 80; + + private readonly List overlays = new List(); + + /// The button to be added. + /// The to be toggled by this button. + public void AddButton(FooterButtonV2 button, OverlayContainer? overlay = null) + { + if (overlay != null) + { + overlays.Add(overlay); + button.Action = () => showOverlay(overlay); + button.OverlayState.BindTo(overlay.State); + } + + buttons.Add(button); + } + + private void showOverlay(OverlayContainer overlay) + { + foreach (var o in overlays) + { + if (o == overlay) + o.ToggleVisibility(); + else + o.Hide(); + } + } + + private FillFlowContainer buttons = null!; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + RelativeSizeAxes = Axes.X; + Height = height; + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5 + }, + buttons = new FillFlowContainer + { + Position = new Vector2(TwoLayerButton.SIZE_EXTENDED.X + padding, 10), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(-FooterButtonV2.SHEAR_WIDTH + 7, 0), + AutoSizeAxes = Axes.Both + } + }; + } + } +} diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs index 8d1654eb1d..2b40b9faf8 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs @@ -3,12 +3,11 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; -using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Extensions; @@ -22,13 +21,13 @@ using Realms; namespace osu.Game.Screens.Select.Leaderboards { - public class BeatmapLeaderboard : Leaderboard + public partial class BeatmapLeaderboard : Leaderboard { - public Action ScoreSelected; + public Action? ScoreSelected; - private BeatmapInfo beatmapInfo; + private BeatmapInfo? beatmapInfo; - public BeatmapInfo BeatmapInfo + public BeatmapInfo? BeatmapInfo { get => beatmapInfo; set @@ -40,6 +39,11 @@ namespace osu.Game.Screens.Select.Leaderboards return; beatmapInfo = value; + + // Refetch is scheduled, which can cause scores to be outdated if the leaderboard is not currently updating. + // As scores are potentially used by other components, clear them eagerly to ensure a more correct state. + SetScores(null); + RefetchScores(); } } @@ -64,24 +68,26 @@ namespace osu.Game.Screens.Select.Leaderboards } [Resolved] - private ScoreManager scoreManager { get; set; } + private ScoreManager scoreManager { get; set; } = null!; [Resolved] - private IBindable ruleset { get; set; } + private IBindable ruleset { get; set; } = null!; [Resolved] - private IBindable> mods { get; set; } + private IBindable> mods { get; set; } = null!; [Resolved] - private IAPIProvider api { get; set; } + private IAPIProvider api { get; set; } = null!; [Resolved] - private RulesetStore rulesets { get; set; } + private RulesetStore rulesets { get; set; } = null!; [Resolved] - private RealmAccess realm { get; set; } + private RealmAccess realm { get; set; } = null!; - private IDisposable scoreSubscription; + private IDisposable? scoreSubscription; + + private GetScoresRequest? scoreRetrievalRequest; [BackgroundDependencyLoader] private void load() @@ -96,10 +102,12 @@ namespace osu.Game.Screens.Select.Leaderboards protected override bool IsOnlineScope => Scope != BeatmapLeaderboardScope.Local; - protected override APIRequest FetchScores(CancellationToken cancellationToken) + protected override APIRequest? FetchScores(CancellationToken cancellationToken) { + scoreRetrievalRequest?.Cancel(); + scoreRetrievalRequest = null; + var fetchBeatmapInfo = BeatmapInfo; - var fetchRuleset = ruleset.Value ?? fetchBeatmapInfo.Ruleset; if (fetchBeatmapInfo == null) { @@ -107,13 +115,15 @@ namespace osu.Game.Screens.Select.Leaderboards return null; } + var fetchRuleset = ruleset.Value ?? fetchBeatmapInfo.Ruleset; + if (Scope == BeatmapLeaderboardScope.Local) { - subscribeToLocalScores(cancellationToken); + subscribeToLocalScores(fetchBeatmapInfo, cancellationToken); return null; } - if (api?.IsLoggedIn != true) + if (!api.IsLoggedIn) { SetErrorState(LeaderboardState.NotLoggedIn); return null; @@ -137,7 +147,7 @@ namespace osu.Game.Screens.Select.Leaderboards return null; } - IReadOnlyList requestMods = null; + IReadOnlyList? requestMods = null; if (filterMods && !mods.Value.Any()) // add nomod for the request @@ -145,21 +155,21 @@ namespace osu.Game.Screens.Select.Leaderboards else if (filterMods) requestMods = mods.Value; - var req = new GetScoresRequest(fetchBeatmapInfo, fetchRuleset, Scope, requestMods); - - req.Success += r => + var newRequest = new GetScoresRequest(fetchBeatmapInfo, fetchRuleset, Scope, requestMods); + newRequest.Success += response => Schedule(() => { - scoreManager.OrderByTotalScoreAsync(r.Scores.Select(s => s.CreateScoreInfo(rulesets, fetchBeatmapInfo)).ToArray(), cancellationToken) - .ContinueWith(task => Schedule(() => - { - if (cancellationToken.IsCancellationRequested) - return; + // Request may have changed since fetch request. + // Can't rely on request cancellation due to Schedule inside SetScores so let's play it safe. + if (!newRequest.Equals(scoreRetrievalRequest)) + return; - SetScores(task.GetResultSafely(), r.UserScore?.CreateScoreInfo(rulesets, fetchBeatmapInfo)); - }), TaskContinuationOptions.OnlyOnRanToCompletion); - }; + SetScores( + scoreManager.OrderByTotalScore(response.Scores.Select(s => s.ToScoreInfo(rulesets, fetchBeatmapInfo))), + response.UserScore?.CreateScoreInfo(rulesets, fetchBeatmapInfo) + ); + }); - return req; + return scoreRetrievalRequest = newRequest; } protected override LeaderboardScore CreateDrawableScore(ScoreInfo model, int index) => new LeaderboardScore(model, index, IsOnlineScope) @@ -172,21 +182,21 @@ namespace osu.Game.Screens.Select.Leaderboards Action = () => ScoreSelected?.Invoke(model) }; - private void subscribeToLocalScores(CancellationToken cancellationToken) + private void subscribeToLocalScores(BeatmapInfo beatmapInfo, CancellationToken cancellationToken) { + Debug.Assert(beatmapInfo != null); + scoreSubscription?.Dispose(); scoreSubscription = null; - if (beatmapInfo == null) - return; - scoreSubscription = realm.RegisterForNotifications(r => r.All().Filter($"{nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} == $0" + + $" AND {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.Hash)} == {nameof(ScoreInfo.BeatmapHash)}" + $" AND {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $1" + $" AND {nameof(ScoreInfo.DeletePending)} == false" , beatmapInfo.ID, ruleset.Value.ShortName), localScoresChanged); - void localScoresChanged(IRealmCollection sender, ChangeSet changes, Exception exception) + void localScoresChanged(IRealmCollection sender, ChangeSet? changes, Exception exception) { if (cancellationToken.IsCancellationRequested) return; @@ -205,29 +215,25 @@ namespace osu.Game.Screens.Select.Leaderboards } else if (filterMods) { - // otherwise find all the scores that have *any* of the currently selected mods (similar to how web applies mod filters) - // we're creating and using a string list representation of selected mods so that it can be translated into the DB query itself - var selectedMods = mods.Value.Select(m => m.Acronym); - scores = scores.Where(s => s.Mods.Any(m => selectedMods.Contains(m.Acronym))); + // otherwise find all the scores that have all of the currently selected mods (similar to how web applies mod filters) + // we're creating and using a string HashSet representation of selected mods so that it can be translated into the DB query itself + var selectedMods = mods.Value.Select(m => m.Acronym).ToHashSet(); + + scores = scores.Where(s => selectedMods.SetEquals(s.Mods.Select(m => m.Acronym))); } - scores = scores.Detach(); + scores = scoreManager.OrderByTotalScore(scores.Detach()); - scoreManager.OrderByTotalScoreAsync(scores.ToArray(), cancellationToken) - .ContinueWith(ordered => Schedule(() => - { - if (cancellationToken.IsCancellationRequested) - return; - - SetScores(ordered.GetResultSafely()); - }), TaskContinuationOptions.OnlyOnRanToCompletion); + SetScores(scores); } } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); + scoreSubscription?.Dispose(); + scoreRetrievalRequest?.Cancel(); } } } diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs index 5bcb4c27a7..b8840b124a 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.ComponentModel; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; diff --git a/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs b/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs index cb96e3f23e..c4add31a4f 100644 --- a/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs +++ b/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs @@ -10,44 +10,25 @@ using osu.Game.Beatmaps; namespace osu.Game.Screens.Select { - public class LocalScoreDeleteDialog : PopupDialog + public partial class LocalScoreDeleteDialog : DangerousActionDialog { private readonly ScoreInfo score; - [Resolved] - private ScoreManager scoreManager { get; set; } - - [Resolved] - private BeatmapManager beatmapManager { get; set; } - public LocalScoreDeleteDialog(ScoreInfo score) { this.score = score; - Debug.Assert(score != null); } [BackgroundDependencyLoader] - private void load() + private void load(BeatmapManager beatmapManager, ScoreManager scoreManager) { - BeatmapInfo beatmapInfo = beatmapManager.QueryBeatmap(b => b.ID == score.BeatmapInfoID); + BeatmapInfo? beatmapInfo = beatmapManager.QueryBeatmap(b => b.ID == score.BeatmapInfoID); Debug.Assert(beatmapInfo != null); BodyText = $"{score.User} ({score.DisplayAccuracy}, {score.Rank})"; Icon = FontAwesome.Regular.TrashAlt; - HeaderText = "Confirm deletion of local score"; - Buttons = new PopupDialogButton[] - { - new PopupDialogDangerousButton - { - Text = "Yes. Please.", - Action = () => scoreManager?.Delete(score) - }, - new PopupDialogCancelButton - { - Text = "No, I'm still attached.", - }, - }; + DangerousAction = () => scoreManager.Delete(score); } } } diff --git a/osu.Game/Screens/Select/NoResultsPlaceholder.cs b/osu.Game/Screens/Select/NoResultsPlaceholder.cs index 28a0541a22..9f870503d3 100644 --- a/osu.Game/Screens/Select/NoResultsPlaceholder.cs +++ b/osu.Game/Screens/Select/NoResultsPlaceholder.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 enable - using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -19,7 +17,7 @@ using osuTK; namespace osu.Game.Screens.Select { - public class NoResultsPlaceholder : VisibilityContainer + public partial class NoResultsPlaceholder : VisibilityContainer { private FilterCriteria? filter; @@ -52,7 +50,7 @@ namespace osu.Game.Screens.Select Masking = true; CornerRadius = 10; - Width = 300; + Width = 400; AutoSizeAxes = Axes.Y; Anchor = Anchor.Centre; @@ -111,7 +109,7 @@ namespace osu.Game.Screens.Select textFlow.AddParagraph("No beatmaps found!"); textFlow.AddParagraph(string.Empty); - textFlow.AddParagraph("Consider using the \""); + textFlow.AddParagraph("- Consider running the \""); textFlow.AddLink(FirstRunSetupOverlayStrings.FirstRunSetupTitle, () => firstRunSetupOverlay?.Show()); textFlow.AddText("\" to download or import some beatmaps!"); } @@ -120,25 +118,37 @@ namespace osu.Game.Screens.Select textFlow.AddParagraph("No beatmaps match your filter criteria!"); textFlow.AddParagraph(string.Empty); - if (string.IsNullOrEmpty(filter?.SearchText)) + if (filter?.UserStarDifficulty.HasFilter == true) { - // TODO: Add realm queries to hint at which ruleset results are available in (and allow clicking to switch). - // TODO: Make this message more certain by ensuring the osu! beatmaps exist before suggesting. - if (filter?.Ruleset.OnlineID > 0 && !filter.AllowConvertedBeatmaps) + textFlow.AddParagraph("- Try "); + textFlow.AddLink("removing", () => { - textFlow.AddParagraph("Beatmaps may be available by "); - textFlow.AddLink("enabling automatic conversion", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); - textFlow.AddText("!"); - } + config.SetValue(OsuSetting.DisplayStarsMinimum, 0.0); + config.SetValue(OsuSetting.DisplayStarsMaximum, 10.1); + }); + + string lowerStar = $"{filter.UserStarDifficulty.Min ?? 0:N1}"; + string upperStar = filter.UserStarDifficulty.Max == null ? "∞" : $"{filter.UserStarDifficulty.Max:N1}"; + + textFlow.AddText($" the {lowerStar} - {upperStar} star difficulty filter."); } - else + + // TODO: Add realm queries to hint at which ruleset results are available in (and allow clicking to switch). + // TODO: Make this message more certain by ensuring the osu! beatmaps exist before suggesting. + if (filter?.Ruleset?.OnlineID != 0 && filter?.AllowConvertedBeatmaps == false) { - textFlow.AddParagraph("You can try "); - textFlow.AddLink("searching online", LinkAction.SearchBeatmapSet, filter.SearchText); - textFlow.AddText(" for this query."); + textFlow.AddParagraph("- Try"); + textFlow.AddLink(" enabling ", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); + textFlow.AddText("automatic conversion!"); } } + if (!string.IsNullOrEmpty(filter?.SearchText)) + { + textFlow.AddParagraph("- Try "); + textFlow.AddLink("searching online", LinkAction.SearchBeatmapSet, filter.SearchText); + textFlow.AddText($" for \"{filter.SearchText}\"."); + } // TODO: add clickable link to reset criteria. } } diff --git a/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs b/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs index df7dd47ef3..0d3e1238f3 100644 --- a/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs +++ b/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -18,7 +20,7 @@ using osu.Game.Graphics.UserInterface; namespace osu.Game.Screens.Select.Options { - public class BeatmapOptionsButton : OsuClickableContainer + public partial class BeatmapOptionsButton : OsuClickableContainer { private const float width = 130; diff --git a/osu.Game/Screens/Select/Options/BeatmapOptionsOverlay.cs b/osu.Game/Screens/Select/Options/BeatmapOptionsOverlay.cs index 1a8b69d859..c92dc2e343 100644 --- a/osu.Game/Screens/Select/Options/BeatmapOptionsOverlay.cs +++ b/osu.Game/Screens/Select/Options/BeatmapOptionsOverlay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -17,7 +19,7 @@ using osu.Framework.Localisation; namespace osu.Game.Screens.Select.Options { - public class BeatmapOptionsOverlay : OsuFocusedOverlayContainer + public partial class BeatmapOptionsOverlay : OsuFocusedOverlayContainer { private const float transition_duration = 500; private const float x_position = 0.2f; diff --git a/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs b/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs index 09f75b7658..8a1b9ef3e1 100644 --- a/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs +++ b/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using osu.Framework.Allocation; @@ -12,7 +14,7 @@ using osu.Game.Screens.Select.Leaderboards; namespace osu.Game.Screens.Select { - public class PlayBeatmapDetailArea : BeatmapDetailArea + public partial class PlayBeatmapDetailArea : BeatmapDetailArea { public readonly BeatmapLeaderboard Leaderboard; @@ -111,7 +113,7 @@ namespace osu.Game.Screens.Select { switch (item) { - case BeatmapDetailAreaDetailTabItem _: + case BeatmapDetailAreaDetailTabItem: return TabType.Details; case BeatmapDetailAreaLeaderboardTabItem leaderboardTab: diff --git a/osu.Game/Screens/Select/PlaySongSelect.cs b/osu.Game/Screens/Select/PlaySongSelect.cs index ec8b2e029a..b99d949b43 100644 --- a/osu.Game/Screens/Select/PlaySongSelect.cs +++ b/osu.Game/Screens/Select/PlaySongSelect.cs @@ -4,10 +4,15 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Screens; +using osu.Game.Beatmaps; using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets.Mods; @@ -20,29 +25,46 @@ using osuTK.Input; namespace osu.Game.Screens.Select { - public class PlaySongSelect : SongSelect + public partial class PlaySongSelect : SongSelect { - private OsuScreen playerLoader; + private OsuScreen? playerLoader; - [Resolved(CanBeNull = true)] - private INotificationOverlay notifications { get; set; } + [Resolved] + private INotificationOverlay? notifications { get; set; } public override bool AllowExternalScreenChange => true; + public override MenuItem[] CreateForwardNavigationMenuItemsForBeatmap(BeatmapInfo beatmap) => new MenuItem[] + { + new OsuMenuItem(ButtonSystemStrings.Play.ToSentence(), MenuItemType.Highlighted, () => FinaliseSelection(beatmap)), + new OsuMenuItem(ButtonSystemStrings.Edit.ToSentence(), MenuItemType.Standard, () => Edit(beatmap)) + }; + protected override UserActivity InitialActivity => new UserActivity.ChoosingBeatmap(); + private PlayBeatmapDetailArea playBeatmapDetailArea = null!; + [BackgroundDependencyLoader] private void load(OsuColour colours) { - BeatmapOptions.AddButton(@"Edit", @"beatmap", FontAwesome.Solid.PencilAlt, colours.Yellow, () => Edit()); - - ((PlayBeatmapDetailArea)BeatmapDetails).Leaderboard.ScoreSelected += PresentScore; + BeatmapOptions.AddButton(ButtonSystemStrings.Edit.ToSentence(), @"beatmap", FontAwesome.Solid.PencilAlt, colours.Yellow, () => Edit()); } protected void PresentScore(ScoreInfo score) => FinaliseSelection(score.BeatmapInfo, score.Ruleset, () => this.Push(new SoloResultsScreen(score, false))); - protected override BeatmapDetailArea CreateBeatmapDetailArea() => new PlayBeatmapDetailArea(); + protected override BeatmapDetailArea CreateBeatmapDetailArea() + { + playBeatmapDetailArea = new PlayBeatmapDetailArea + { + Leaderboard = + { + ScoreSelected = PresentScore + } + }; + + return playBeatmapDetailArea; + } protected override bool OnKeyDown(KeyDownEvent e) { @@ -59,9 +81,9 @@ namespace osu.Game.Screens.Select return base.OnKeyDown(e); } - private IReadOnlyList modsAtGameplayStart; + private IReadOnlyList? modsAtGameplayStart; - private ModAutoplay getAutoplayMod() => Ruleset.Value.CreateInstance().GetAutoplayMod(); + private ModAutoplay? getAutoplayMod() => Ruleset.Value.CreateInstance().GetAutoplayMod(); protected override bool OnStart() { @@ -78,7 +100,7 @@ namespace osu.Game.Screens.Select { notifications?.Post(new SimpleNotification { - Text = "The current ruleset doesn't have an autoplay mod avalaible!" + Text = NotificationsStrings.NoAutoplayMod }); return false; } @@ -98,14 +120,26 @@ namespace osu.Game.Screens.Select Player createPlayer() { + Player player; + var replayGeneratingMod = Mods.Value.OfType().FirstOrDefault(); if (replayGeneratingMod != null) { - return new ReplayPlayer((beatmap, mods) => replayGeneratingMod.CreateScoreFromReplayData(beatmap, mods)); + player = new ReplayPlayer((beatmap, mods) => replayGeneratingMod.CreateScoreFromReplayData(beatmap, mods)) + { + LeaderboardScores = { BindTarget = playBeatmapDetailArea.Leaderboard.Scores } + }; + } + else + { + player = new SoloPlayer + { + LeaderboardScores = { BindTarget = playBeatmapDetailArea.Leaderboard.Scores } + }; } - return new SoloPlayer(); + return player; } } diff --git a/osu.Game/Screens/Select/SkinDeleteDialog.cs b/osu.Game/Screens/Select/SkinDeleteDialog.cs index 4262118658..6612ae837a 100644 --- a/osu.Game/Screens/Select/SkinDeleteDialog.cs +++ b/osu.Game/Screens/Select/SkinDeleteDialog.cs @@ -2,40 +2,28 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Graphics.Sprites; using osu.Game.Skinning; using osu.Game.Overlays.Dialog; namespace osu.Game.Screens.Select { - public class SkinDeleteDialog : PopupDialog + public partial class SkinDeleteDialog : DangerousActionDialog { - [Resolved] - private SkinManager manager { get; set; } + private readonly Skin skin; public SkinDeleteDialog(Skin skin) { + this.skin = skin; BodyText = skin.SkinInfo.Value.Name; - Icon = FontAwesome.Regular.TrashAlt; - HeaderText = @"Confirm deletion of"; - Buttons = new PopupDialogButton[] - { - new PopupDialogDangerousButton - { - Text = @"Yes. Totally. Delete it.", - Action = () => - { - if (manager == null) - return; + } - manager.Delete(skin.SkinInfo.Value); - manager.CurrentSkinInfo.SetDefault(); - }, - }, - new PopupDialogCancelButton - { - Text = @"Firetruck, I didn't mean to!", - }, + [BackgroundDependencyLoader] + private void load(SkinManager manager) + { + DangerousAction = () => + { + manager.Delete(skin.SkinInfo.Value); + manager.CurrentSkinInfo.SetDefault(); }; } } diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 3bfdc845ab..4d6a5398c5 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -1,53 +1,57 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Humanizer; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; +using osu.Framework.Audio.Track; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Framework.Threading; using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Overlays; using osu.Game.Overlays.Mods; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Edit; using osu.Game.Screens.Menu; +using osu.Game.Screens.Play; using osu.Game.Screens.Select.Options; +using osu.Game.Skinning; using osuTK; using osuTK.Graphics; using osuTK.Input; -using System; -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Audio.Track; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Bindings; -using osu.Game.Collections; -using osu.Game.Graphics.UserInterface; -using System.Diagnostics; -using JetBrains.Annotations; -using osu.Game.Screens.Play; -using osu.Game.Skinning; namespace osu.Game.Screens.Select { - public abstract class SongSelect : ScreenWithBeatmapBackground, IKeyBindingHandler + public abstract partial class SongSelect : ScreenWithBeatmapBackground, IKeyBindingHandler { public static readonly float WEDGE_HEIGHT = 245; protected const float BACKGROUND_BLUR = 20; private const float left_area_padding = 20; - public FilterControl FilterControl { get; private set; } + public FilterControl FilterControl { get; private set; } = null!; /// /// Whether this song select instance should take control of the global track, @@ -62,70 +66,92 @@ namespace osu.Game.Screens.Select /// /// Can be null if is false. /// - protected BeatmapOptionsOverlay BeatmapOptions { get; private set; } + protected BeatmapOptionsOverlay BeatmapOptions { get; private set; } = null!; /// /// Can be null if is false. /// - protected Footer Footer { get; private set; } + protected Footer? Footer { get; private set; } /// /// Contains any panel which is triggered by a footer button. /// Helps keep them located beneath the footer itself. /// - protected Container FooterPanels { get; private set; } + protected Container FooterPanels { get; private set; } = null!; /// /// Whether entering editor mode should be allowed. /// public virtual bool AllowEditing => true; - public bool BeatmapSetsLoaded => IsLoaded && Carousel?.BeatmapSetsLoaded == true; + public bool BeatmapSetsLoaded => IsLoaded && Carousel.BeatmapSetsLoaded; + + /// + /// Creates any "action" menu items for the provided beatmap (ie. "Select", "Play", "Edit"). + /// These will always be placed at the top of the context menu, with common items added below them. + /// + /// The beatmap to create items for. + /// The menu items. + public virtual MenuItem[] CreateForwardNavigationMenuItemsForBeatmap(BeatmapInfo beatmap) => new MenuItem[] + { + new OsuMenuItem(@"Select", MenuItemType.Highlighted, () => FinaliseSelection(beatmap)) + }; [Resolved] - private Bindable> selectedMods { get; set; } + private Bindable> selectedMods { get; set; } = null!; - protected BeatmapCarousel Carousel { get; private set; } + protected BeatmapCarousel Carousel { get; private set; } = null!; - protected Container LeftArea { get; private set; } + private ParallaxContainer wedgeBackground = null!; - private BeatmapInfoWedge beatmapInfoWedge; + protected Container LeftArea { get; private set; } = null!; - [Resolved(canBeNull: true)] - private IDialogOverlay dialogOverlay { get; set; } + private BeatmapInfoWedge beatmapInfoWedge = null!; [Resolved] - private BeatmapManager beatmaps { get; set; } + private IDialogOverlay? dialogOverlay { get; set; } - protected ModSelectOverlay ModSelect { get; private set; } + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; - protected Sample SampleConfirm { get; private set; } + protected ModSelectOverlay ModSelect { get; private set; } = null!; - private Sample sampleChangeDifficulty; - private Sample sampleChangeBeatmap; + protected Sample? SampleConfirm { get; private set; } - private Container carouselContainer; + private Sample sampleChangeDifficulty = null!; + private Sample sampleChangeBeatmap = null!; - protected BeatmapDetailArea BeatmapDetails { get; private set; } + private Container carouselContainer = null!; + + protected BeatmapDetailArea BeatmapDetails { get; private set; } = null!; + + private FooterButtonOptions beatmapOptionsButton = null!; private readonly Bindable decoupledRuleset = new Bindable(); private double audioFeedbackLastPlaybackTime; - [CanBeNull] - private IDisposable modSelectOverlayRegistration; + private IDisposable? modSelectOverlayRegistration; [Resolved] - private MusicController music { get; set; } + private MusicController music { get; set; } = null!; - [Resolved(CanBeNull = true)] - internal IOverlayManager OverlayManager { get; private set; } + [Resolved] + internal IOverlayManager? OverlayManager { get; private set; } + + private Bindable configBackgroundBlur { get; set; } = new BindableBool(); [BackgroundDependencyLoader(true)] - private void load(AudioManager audio, OsuColour colours, ManageCollectionsDialog manageCollectionsDialog, DifficultyRecommender recommender) + private void load(AudioManager audio, OsuColour colours, ManageCollectionsDialog? manageCollectionsDialog, DifficultyRecommender? recommender, OsuConfigManager config) { - // initial value transfer is required for FilterControl (it uses our re-cached bindables in its async load for the initial filter). - transferRulesetValue(); + configBackgroundBlur = config.GetBindable(OsuSetting.SongSelectBackgroundBlur); + configBackgroundBlur.BindValueChanged(e => + { + if (!this.IsCurrentScreen()) + return; + + ApplyToBackground(applyBlurToBackground); + }); LoadComponentAsync(Carousel = new BeatmapCarousel { @@ -137,9 +163,13 @@ namespace osu.Game.Screens.Select BleedBottom = Footer.HEIGHT, SelectionChanged = updateSelectedBeatmap, BeatmapSetsChanged = carouselBeatmapsLoaded, + FilterApplied = updateVisibleBeatmapCount, GetRecommendedBeatmap = s => recommender?.GetRecommendedBeatmap(s), }, c => carouselContainer.Child = c); + // initial value transfer is required for FilterControl (it uses our re-cached bindables in its async load for the initial filter). + transferRulesetValue(); + AddRangeInternal(new Drawable[] { new ResetScrollContainer(() => Carousel.ScrollToSelected()) @@ -163,10 +193,13 @@ namespace osu.Game.Screens.Select { new Drawable[] { - new ParallaxContainer + wedgeBackground = new ParallaxContainer { ParallaxAmount = 0.005f, RelativeSizeAxes = Axes.Both, + Alpha = 0, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, Child = new WedgeBackground { RelativeSizeAxes = Axes.Both, @@ -244,7 +277,7 @@ namespace osu.Game.Screens.Select } } }, - new SkinnableTargetContainer(SkinnableTarget.SongSelect) + new SkinComponentsContainer(new SkinComponentsContainerLookup(SkinComponentsContainerLookup.TargetArea.SongSelect)) { RelativeSizeAxes = Axes.Both, }, @@ -265,7 +298,7 @@ namespace osu.Game.Screens.Select BeatmapOptions = new BeatmapOptionsOverlay(), } }, - Footer = new Footer(), + Footer = new Footer() }); } @@ -296,11 +329,21 @@ namespace osu.Game.Screens.Select modSelectOverlayRegistration = OverlayManager?.RegisterBlockingOverlay(ModSelect); } + protected override bool OnScroll(ScrollEvent e) + { + // Match stable behaviour of only alt-scroll adjusting volume. + // Supporting scroll adjust without a modifier key just feels bad, since there are so many scrollable elements on the screen. + if (!e.CurrentState.Keyboard.AltPressed) + return true; + + return base.OnScroll(e); + } + /// /// Creates the buttons to be displayed in the footer. /// /// A set of and an optional which the button opens when pressed. - protected virtual IEnumerable<(FooterButton, OverlayContainer)> CreateFooterButtons() => new (FooterButton, OverlayContainer)[] + protected virtual IEnumerable<(FooterButton, OverlayContainer?)> CreateFooterButtons() => new (FooterButton, OverlayContainer?)[] { (new FooterButtonMods { Current = Mods }, ModSelect), (new FooterButtonRandom @@ -308,10 +351,10 @@ namespace osu.Game.Screens.Select NextRandom = () => Carousel.SelectNextRandom(), PreviousRandom = Carousel.SelectPreviousRandom }, null), - (new FooterButtonOptions(), BeatmapOptions) + (beatmapOptionsButton = new FooterButtonOptions(), BeatmapOptions) }; - protected virtual ModSelectOverlay CreateModSelectOverlay() => new UserModSelectOverlay(); + protected virtual ModSelectOverlay CreateModSelectOverlay() => new SoloModSelectOverlay(); protected virtual void ApplyFilterToCarousel(FilterCriteria criteria) { @@ -321,7 +364,7 @@ namespace osu.Game.Screens.Select Carousel.Filter(criteria, shouldDebounce); } - private DependencyContainer dependencies; + private DependencyContainer dependencies = null!; protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { @@ -339,7 +382,7 @@ namespace osu.Game.Screens.Select /// protected abstract BeatmapDetailArea CreateBeatmapDetailArea(); - public void Edit(BeatmapInfo beatmapInfo = null) + public void Edit(BeatmapInfo? beatmapInfo = null) { if (!AllowEditing) throw new InvalidOperationException($"Attempted to edit when {nameof(AllowEditing)} is disabled"); @@ -354,11 +397,14 @@ namespace osu.Game.Screens.Select /// An optional beatmap to override the current carousel selection. /// An optional ruleset to override the current carousel selection. /// An optional custom action to perform instead of . - public void FinaliseSelection(BeatmapInfo beatmapInfo = null, RulesetInfo ruleset = null, Action customStartAction = null) + public void FinaliseSelection(BeatmapInfo? beatmapInfo = null, RulesetInfo? ruleset = null, Action? customStartAction = null) { // This is very important as we have not yet bound to screen-level bindables before the carousel load is completed. if (!Carousel.BeatmapSetsLoaded) + { + Logger.Log($"{nameof(FinaliseSelection)} aborted as carousel beatmaps are not yet loaded"); return; + } if (ruleset != null) Ruleset.Value = ruleset; @@ -398,22 +444,23 @@ namespace osu.Game.Screens.Select /// If a resultant action occurred that takes the user away from SongSelect. protected abstract bool OnStart(); - private ScheduledDelegate selectionChangedDebounce; + private ScheduledDelegate? selectionChangedDebounce; - private void workingBeatmapChanged(ValueChangedEvent e) + private void updateCarouselSelection(ValueChangedEvent? e = null) { - if (e.NewValue is DummyWorkingBeatmap || !this.IsCurrentScreen()) return; + var beatmap = e?.NewValue ?? Beatmap.Value; + if (beatmap is DummyWorkingBeatmap || !this.IsCurrentScreen()) return; - Logger.Log($"Song select working beatmap updated to {e.NewValue}"); + Logger.Log($"Song select working beatmap updated to {beatmap}"); - if (!Carousel.SelectBeatmap(e.NewValue.BeatmapInfo, false)) + if (!Carousel.SelectBeatmap(beatmap.BeatmapInfo, false)) { // A selection may not have been possible with filters applied. // There was possibly a ruleset mismatch. This is a case we can help things along by updating the game-wide ruleset to match. - if (!e.NewValue.BeatmapInfo.Ruleset.Equals(decoupledRuleset.Value)) + if (!beatmap.BeatmapInfo.Ruleset.Equals(decoupledRuleset.Value)) { - Ruleset.Value = e.NewValue.BeatmapInfo.Ruleset; + Ruleset.Value = beatmap.BeatmapInfo.Ruleset; transferRulesetValue(); } @@ -421,19 +468,19 @@ namespace osu.Game.Screens.Select // we still want to temporarily show the new beatmap, bypassing filters. // This will be undone the next time the user changes the filter. var criteria = FilterControl.CreateCriteria(); - criteria.SelectedBeatmapSet = e.NewValue.BeatmapInfo.BeatmapSet; + criteria.SelectedBeatmapSet = beatmap.BeatmapInfo.BeatmapSet; Carousel.Filter(criteria); - Carousel.SelectBeatmap(e.NewValue.BeatmapInfo); + Carousel.SelectBeatmap(beatmap.BeatmapInfo); } } // We need to keep track of the last selected beatmap ignoring debounce to play the correct selection sounds. - private BeatmapInfo beatmapInfoPrevious; - private BeatmapInfo beatmapInfoNoDebounce; - private RulesetInfo rulesetNoDebounce; + private BeatmapInfo? beatmapInfoPrevious; + private BeatmapInfo? beatmapInfoNoDebounce; + private RulesetInfo? rulesetNoDebounce; - private void updateSelectedBeatmap(BeatmapInfo beatmapInfo) + private void updateSelectedBeatmap(BeatmapInfo? beatmapInfo) { if (beatmapInfo == null && beatmapInfoNoDebounce == null) return; @@ -445,7 +492,7 @@ namespace osu.Game.Screens.Select performUpdateSelected(); } - private void updateSelectedRuleset(RulesetInfo ruleset) + private void updateSelectedRuleset(RulesetInfo? ruleset) { if (ruleset == null && rulesetNoDebounce == null) return; @@ -463,7 +510,7 @@ namespace osu.Game.Screens.Select private void performUpdateSelected() { var beatmap = beatmapInfoNoDebounce; - var ruleset = rulesetNoDebounce; + RulesetInfo? ruleset = rulesetNoDebounce; selectionChangedDebounce?.Cancel(); @@ -492,12 +539,10 @@ namespace osu.Game.Screens.Select // clear pending task immediately to track any potential nested debounce operation. selectionChangedDebounce = null; - Logger.Log($"updating selection with beatmap:{beatmap?.ID.ToString() ?? "null"} ruleset:{ruleset?.ShortName ?? "null"}"); + Logger.Log($"Song select updating selection with beatmap:{beatmap?.ID.ToString() ?? "null"} ruleset:{ruleset?.ShortName ?? "null"}"); if (transferRulesetValue()) { - Mods.Value = Array.Empty(); - // transferRulesetValue() may trigger a re-filter. If the current selection does not match the new ruleset, we want to switch away from it. // The default logic on WorkingBeatmap change is to switch to a matching ruleset (see workingBeatmapChanged()), but we don't want that here. // We perform an early selection attempt and clear out the beatmap selection to avoid a second ruleset change (revert). @@ -517,7 +562,7 @@ namespace osu.Game.Screens.Select // In these cases, the other component has already loaded the beatmap, so we don't need to do so again. if (!EqualityComparer.Default.Equals(beatmap, Beatmap.Value.BeatmapInfo)) { - Logger.Log($"beatmap changed from \"{Beatmap.Value.BeatmapInfo}\" to \"{beatmap}\""); + Logger.Log($"Song select changing beatmap from \"{Beatmap.Value.BeatmapInfo}\" to \"{beatmap?.ToString() ?? "null"}\""); Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmap); } @@ -592,6 +637,8 @@ namespace osu.Game.Screens.Select if (Beatmap != null && !Beatmap.Value.BeatmapSetInfo.DeletePending) { + updateCarouselSelection(); + updateComponentFromBeatmap(Beatmap.Value); if (ControlGlobalMusic) @@ -603,9 +650,15 @@ namespace osu.Game.Screens.Select } } - this.FadeIn(250); + LeftArea.MoveToX(0, 400, Easing.OutQuint); + LeftArea.FadeIn(100, Easing.OutQuint); - this.ScaleTo(1, 250, Easing.OutSine); + FilterControl.MoveToY(0, 400, Easing.OutQuint); + FilterControl.FadeIn(100, Easing.OutQuint); + + this.FadeIn(250, Easing.OutQuint); + + wedgeBackground.ScaleTo(1, 500, Easing.OutQuint); FilterControl.Activate(); } @@ -617,17 +670,8 @@ namespace osu.Game.Screens.Select transferRulesetValue(); ModSelect.SelectedMods.UnbindFrom(selectedMods); - ModSelect.Hide(); - BeatmapOptions.Hide(); - - endLooping(); - - this.ScaleTo(1.1f, 250, Easing.InSine); - - this.FadeOut(250); - - FilterControl.Deactivate(); + playExitingTransition(); base.OnSuspending(e); } @@ -636,16 +680,31 @@ namespace osu.Game.Screens.Select if (base.OnExiting(e)) return true; - beatmapInfoWedge.Hide(); + playExitingTransition(); + return false; + } + + private void playExitingTransition() + { ModSelect.Hide(); - this.FadeOut(100); + BeatmapOptions.Hide(); - FilterControl.Deactivate(); + Carousel.AllowSelection = false; endLooping(); - return false; + FilterControl.MoveToY(-120, 500, Easing.OutQuint); + FilterControl.FadeOut(200, Easing.OutQuint); + + LeftArea.MoveToX(-150, 1800, Easing.OutQuint); + LeftArea.FadeOut(200, Easing.OutQuint); + + wedgeBackground.ScaleTo(2.4f, 400, Easing.OutQuint); + + this.FadeOut(400, Easing.OutQuint); + + FilterControl.Deactivate(); } private bool isHandlingLooping; @@ -660,6 +719,7 @@ namespace osu.Game.Screens.Select isHandlingLooping = true; ensureTrackLooping(Beatmap.Value, TrackChangeDirection.None); + music.TrackChanged += ensureTrackLooping; } @@ -675,7 +735,7 @@ namespace osu.Game.Screens.Select } private void ensureTrackLooping(IWorkingBeatmap beatmap, TrackChangeDirection changeDirection) - => beatmap.PrepareTrackForPreviewLooping(); + => beatmap.PrepareTrackForPreview(true); public override bool OnBackButton() { @@ -694,7 +754,7 @@ namespace osu.Game.Screens.Select decoupledRuleset.UnbindAll(); - if (music != null) + if (music.IsNotNull()) music.TrackChanged -= ensureTrackLooping; modSelectOverlayRegistration?.Dispose(); @@ -707,19 +767,43 @@ namespace osu.Game.Screens.Select /// The working beatmap. private void updateComponentFromBeatmap(WorkingBeatmap beatmap) { - ApplyToBackground(backgroundModeBeatmap => + // If not the current screen, this will be applied in OnResuming. + if (this.IsCurrentScreen()) { - backgroundModeBeatmap.Beatmap = beatmap; - backgroundModeBeatmap.BlurAmount.Value = BACKGROUND_BLUR; - backgroundModeBeatmap.FadeColour(Color4.White, 250); - }); + ApplyToBackground(backgroundModeBeatmap => + { + backgroundModeBeatmap.Beatmap = beatmap; + backgroundModeBeatmap.IgnoreUserSettings.Value = true; + backgroundModeBeatmap.FadeColour(Color4.White, 250); + + applyBlurToBackground(backgroundModeBeatmap); + }); + } beatmapInfoWedge.Beatmap = beatmap; BeatmapDetails.Beatmap = beatmap; + + bool beatmapSelected = beatmap is not DummyWorkingBeatmap; + + if (beatmapSelected) + beatmapOptionsButton.Enabled.Value = true; + else + { + beatmapOptionsButton.Enabled.Value = false; + BeatmapOptions.Hide(); + } } - private readonly WeakReference lastTrack = new WeakReference(null); + private void applyBlurToBackground(BackgroundScreenBeatmap backgroundModeBeatmap) + { + backgroundModeBeatmap.BlurAmount.Value = configBackgroundBlur.Value ? BACKGROUND_BLUR : 0f; + backgroundModeBeatmap.DimWhenUserSettingsIgnored.Value = configBackgroundBlur.Value ? 0 : 0.4f; + + wedgeBackground.FadeTo(configBackgroundBlur.Value ? 0.5f : 0.2f, UserDimContainer.BACKGROUND_FADE_DURATION, Easing.OutQuint); + } + + private readonly WeakReference lastTrack = new WeakReference(null); /// /// Ensures some music is playing for the current track. @@ -735,7 +819,10 @@ namespace osu.Game.Screens.Select bool isNewTrack = !lastTrack.TryGetTarget(out var last) || last != track; if (!track.IsRunning && (music.UserPauseRequested != true || isNewTrack)) + { + Logger.Log($"Song select decided to {nameof(ensurePlayingSelected)}"); music.Play(true); + } lastTrack.SetTarget(track); } @@ -743,6 +830,7 @@ namespace osu.Game.Screens.Select private void carouselBeatmapsLoaded() { bindBindables(); + updateVisibleBeatmapCount(); Carousel.AllowSelection = true; @@ -772,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() @@ -784,10 +879,20 @@ namespace osu.Game.Screens.Select Ruleset.ValueChanged += r => updateSelectedRuleset(r.NewValue); - decoupledRuleset.ValueChanged += r => Ruleset.Value = r.NewValue; + decoupledRuleset.ValueChanged += r => + { + bool wasDisabled = Ruleset.Disabled; + + // a sub-screen may have taken a lease on this decoupled ruleset bindable, + // which would indirectly propagate to the game-global bindable via the `DisabledChanged` callback below. + // to make sure changes sync without crashes, lift the disable for a short while to sync, and then restore the old value. + Ruleset.Disabled = false; + Ruleset.Value = r.NewValue; + Ruleset.Disabled = wasDisabled; + }; decoupledRuleset.DisabledChanged += r => Ruleset.Disabled = r; - Beatmap.BindValueChanged(workingBeatmapChanged); + Beatmap.BindValueChanged(updateCarouselSelection); boundLocalBindables = true; } @@ -807,18 +912,19 @@ namespace osu.Game.Screens.Select // if we have a pending filter operation, we want to run it now. // it could change selection (ie. if the ruleset has been changed). - Carousel?.FlushPendingFilterOperations(); + Carousel.FlushPendingFilterOperations(); + return true; } - private void delete(BeatmapSetInfo beatmap) + private void delete(BeatmapSetInfo? beatmap) { if (beatmap == null) return; dialogOverlay?.Push(new BeatmapDeleteDialog(beatmap)); } - private void clearScores(BeatmapInfo beatmapInfo) + private void clearScores(BeatmapInfo? beatmapInfo) { if (beatmapInfo == null) return; @@ -868,7 +974,7 @@ namespace osu.Game.Screens.Select return base.OnKeyDown(e); } - private class VerticalMaskingContainer : Container + private partial class VerticalMaskingContainer : Container { private const float panel_overflow = 1.2f; @@ -891,9 +997,9 @@ namespace osu.Game.Screens.Select } } - private class ResetScrollContainer : Container + private partial class ResetScrollContainer : Container { - private readonly Action onHoverAction; + private readonly Action? onHoverAction; public ResetScrollContainer(Action onHoverAction) { @@ -906,5 +1012,10 @@ namespace osu.Game.Screens.Select return base.OnHover(e); } } + + internal partial class SoloModSelectOverlay : UserModSelectOverlay + { + protected override bool ShowPresets => true; + } } } diff --git a/osu.Game/Screens/Select/WedgeBackground.cs b/osu.Game/Screens/Select/WedgeBackground.cs index f1337a8119..2e2b43cd70 100644 --- a/osu.Game/Screens/Select/WedgeBackground.cs +++ b/osu.Game/Screens/Select/WedgeBackground.cs @@ -1,16 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osuTK; using osuTK.Graphics; -using osu.Framework.Graphics.Shapes; namespace osu.Game.Screens.Select { - public class WedgeBackground : Container + public partial class WedgeBackground : Container { public WedgeBackground() { @@ -20,7 +19,7 @@ namespace osu.Game.Screens.Select { RelativeSizeAxes = Axes.Both, Size = new Vector2(1, 0.5f), - Colour = Color4.Black.Opacity(0.5f), + Colour = Color4.Black, Shear = new Vector2(0.15f, 0), EdgeSmoothness = new Vector2(2, 0), }, @@ -30,7 +29,7 @@ namespace osu.Game.Screens.Select RelativePositionAxes = Axes.Y, Size = new Vector2(1, -0.5f), Position = new Vector2(0, 1), - Colour = Color4.Black.Opacity(0.5f), + Colour = Color4.Black, Shear = new Vector2(-0.15f, 0), EdgeSmoothness = new Vector2(2, 0), }, diff --git a/osu.Game/Screens/Spectate/SpectatorGameplayState.cs b/osu.Game/Screens/Spectate/SpectatorGameplayState.cs index 6ca1ac9a0a..498363adef 100644 --- a/osu.Game/Screens/Spectate/SpectatorGameplayState.cs +++ b/osu.Game/Screens/Spectate/SpectatorGameplayState.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Scoring; diff --git a/osu.Game/Screens/Spectate/SpectatorScreen.cs b/osu.Game/Screens/Spectate/SpectatorScreen.cs index 9eb374f0f7..2b56767bd0 100644 --- a/osu.Game/Screens/Spectate/SpectatorScreen.cs +++ b/osu.Game/Screens/Spectate/SpectatorScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; @@ -24,7 +26,7 @@ namespace osu.Game.Screens.Spectate /// /// A which spectates one or more users. /// - public abstract class SpectatorScreen : OsuScreen + public abstract partial class SpectatorScreen : OsuScreen { protected IReadOnlyList Users => users; @@ -113,14 +115,9 @@ namespace osu.Game.Screens.Spectate { case NotifyDictionaryChangedAction.Add: case NotifyDictionaryChangedAction.Replace: - foreach ((int userId, var state) in e.NewItems.AsNonNull()) + foreach ((int userId, SpectatorState state) in e.NewItems.AsNonNull()) onUserStateChanged(userId, state); break; - - case NotifyDictionaryChangedAction.Remove: - foreach ((int userId, SpectatorState state) in e.OldItems.AsNonNull()) - onUserStateRemoved(userId, state); - break; } } @@ -134,33 +131,21 @@ namespace osu.Game.Screens.Spectate switch (newState.State) { - case SpectatedUserState.Passed: - // Make sure that gameplay completes to the end. - if (gameplayStates.TryGetValue(userId, out var gameplayState)) - gameplayState.Score.Replay.HasReceivedAllFrames = true; - break; - case SpectatedUserState.Playing: Schedule(() => OnNewPlayingUserState(userId, newState)); startGameplay(userId); break; + + case SpectatedUserState.Passed: + markReceivedAllFrames(userId); + break; + + case SpectatedUserState.Quit: + quitGameplay(userId); + break; } } - private void onUserStateRemoved(int userId, SpectatorState state) - { - if (!userMap.ContainsKey(userId)) - return; - - if (!gameplayStates.TryGetValue(userId, out var gameplayState)) - return; - - gameplayState.Score.Replay.HasReceivedAllFrames = true; - - gameplayStates.Remove(userId); - Schedule(() => EndGameplay(userId, state)); - } - private void startGameplay(int userId) { Debug.Assert(userMap.ContainsKey(userId)); @@ -194,6 +179,29 @@ namespace osu.Game.Screens.Spectate Schedule(() => StartGameplay(userId, gameplayState)); } + /// + /// Marks an existing gameplay session as received all frames. + /// + private void markReceivedAllFrames(int userId) + { + if (gameplayStates.TryGetValue(userId, out var gameplayState)) + gameplayState.Score.Replay.HasReceivedAllFrames = true; + } + + private void quitGameplay(int userId) + { + if (!userMap.ContainsKey(userId)) + return; + + if (!gameplayStates.ContainsKey(userId)) + return; + + markReceivedAllFrames(userId); + + gameplayStates.Remove(userId); + Schedule(() => QuitGameplay(userId)); + } + /// /// Invoked when a spectated user's state has changed to a new state indicating the player is currently playing. /// @@ -209,11 +217,10 @@ namespace osu.Game.Screens.Spectate protected abstract void StartGameplay(int userId, [NotNull] SpectatorGameplayState spectatorGameplayState); /// - /// Ends gameplay for a user. + /// Quits gameplay for a user. /// - /// The user to end gameplay for. - /// The final user state. - protected abstract void EndGameplay(int userId, SpectatorState state); + /// The user to quit gameplay for. + protected abstract void QuitGameplay(int userId); /// /// Stops spectating a user. @@ -221,10 +228,10 @@ namespace osu.Game.Screens.Spectate /// The user to stop spectating. protected void RemoveUser(int userId) { - if (!userStates.TryGetValue(userId, out var state)) + if (!userStates.ContainsKey(userId)) return; - onUserStateRemoved(userId, state); + quitGameplay(userId); users.Remove(userId); userMap.Remove(userId); diff --git a/osu.Game/Screens/StartupScreen.cs b/osu.Game/Screens/StartupScreen.cs index be217d6b1f..84ef3eac78 100644 --- a/osu.Game/Screens/StartupScreen.cs +++ b/osu.Game/Screens/StartupScreen.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Overlays; namespace osu.Game.Screens @@ -8,7 +10,7 @@ namespace osu.Game.Screens /// /// A screen which is shown once as part of the startup procedure. /// - public abstract class StartupScreen : OsuScreen + public abstract partial class StartupScreen : OsuScreen { public override bool AllowBackButton => false; diff --git a/osu.Game/Screens/Utility/ButtonWithKeyBind.cs b/osu.Game/Screens/Utility/ButtonWithKeyBind.cs new file mode 100644 index 0000000000..7c78836b12 --- /dev/null +++ b/osu.Game/Screens/Utility/ButtonWithKeyBind.cs @@ -0,0 +1,54 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Overlays; +using osu.Game.Overlays.Settings; +using osuTK.Input; + +namespace osu.Game.Screens.Utility +{ + public partial class ButtonWithKeyBind : SettingsButton + { + private readonly Key key; + + public ButtonWithKeyBind(Key key) + { + this.key = key; + } + + public new LocalisableString Text + { + get => base.Text; + set => base.Text = $"{value} (Press {key.ToString().Replace("Number", string.Empty)})"; + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (!e.Repeat && e.Key == key) + { + TriggerClick(); + return true; + } + + return base.OnKeyDown(e); + } + + [Resolved] + private OverlayColourProvider overlayColourProvider { get; set; } = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + Height = 100; + SpriteText.Colour = overlayColourProvider.Background6; + SpriteText.Font = OsuFont.TorusAlternate.With(size: 34); + + Triangles?.Hide(); + } + } +} diff --git a/osu.Game/Screens/Utility/CircleGameplay.cs b/osu.Game/Screens/Utility/CircleGameplay.cs new file mode 100644 index 0000000000..d97812acb4 --- /dev/null +++ b/osu.Game/Screens/Utility/CircleGameplay.cs @@ -0,0 +1,238 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Input.States; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Utility.SampleComponents; +using osuTK; + +namespace osu.Game.Screens.Utility +{ + public partial class CircleGameplay : LatencySampleComponent + { + private int nextLocation; + + private readonly List hitEvents = new List(); + + private double? lastGeneratedBeatTime; + + private Container circles = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + InternalChildren = new Drawable[] + { + circles = new Container + { + RelativeSizeAxes = Axes.Both, + }, + }; + + SampleBPM.BindValueChanged(_ => + { + circles.Clear(); + lastGeneratedBeatTime = null; + }); + } + + protected override void UpdateAtLimitedRate(InputState inputState) + { + double beatLength = 60000 / SampleBPM.Value; + + int nextBeat = (int)(Clock.CurrentTime / beatLength) + 1; + + // We want to generate a few hit objects ahead of the current time (to allow them to animate). + double generateUpTo = (nextBeat + 2) * beatLength; + + while (lastGeneratedBeatTime == null || lastGeneratedBeatTime < generateUpTo) + { + double time = ++nextBeat * beatLength; + + if (time <= lastGeneratedBeatTime) + continue; + + newBeat(time); + lastGeneratedBeatTime = time; + } + } + + private void newBeat(double time) + { + nextLocation++; + + Vector2 location; + + float adjust = SampleVisualSpacing.Value * 0.25f; + + float spacingLow = 0.5f - adjust; + float spacingHigh = 0.5f + adjust; + + switch (nextLocation % 4) + { + default: + location = new Vector2(spacingLow, spacingLow); + break; + + case 1: + location = new Vector2(spacingHigh, spacingHigh); + break; + + case 2: + location = new Vector2(spacingHigh, spacingLow); + break; + + case 3: + location = new Vector2(spacingLow, spacingHigh); + break; + } + + circles.Add(new SampleHitCircle(time) + { + RelativePositionAxes = Axes.Both, + Position = location, + Hit = hit, + }); + } + + private void hit(HitEvent h) + { + hitEvents.Add(h); + } + + public partial class SampleHitCircle : LatencySampleComponent + { + public HitEvent? HitEvent; + + public Action? Hit { get; set; } + + public readonly double HitTime; + + private CircularContainer approach = null!; + private Circle circle = null!; + + private const float size = 100; + private const float duration = 200; + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + => circle.ReceivePositionalInputAt(screenSpacePos); + + public SampleHitCircle(double hitTime) + { + HitTime = hitTime; + + Origin = Anchor.Centre; + AutoSizeAxes = Axes.Both; + AlwaysPresent = true; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + InternalChildren = new Drawable[] + { + circle = new Circle + { + Colour = OverlayColourProvider.Content1, + Size = new Vector2(size), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + approach = new CircularContainer + { + BorderColour = colours.Blue, + Size = new Vector2(size), + Masking = true, + BorderThickness = 4, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new Box + { + Alpha = 0, + AlwaysPresent = true, + RelativeSizeAxes = Axes.Both, + }, + } + }, + }; + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (HitEvent != null) + return false; + + if (Math.Abs(Clock.CurrentTime - HitTime) > duration) + return false; + + attemptHit(); + return true; + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (!IsActive.Value) + return false; + + if (Math.Abs(Clock.CurrentTime - HitTime) > duration) + return false; + + if (IsHovered) + attemptHit(); + return base.OnKeyDown(e); + } + + protected override void UpdateAtLimitedRate(InputState inputState) + { + if (HitEvent == null) + { + double preempt = (float)IBeatmapDifficultyInfo.DifficultyRange(SampleApproachRate.Value, 1800, 1200, 450); + + approach.Scale = new Vector2(1 + 4 * (float)MathHelper.Clamp((HitTime - Clock.CurrentTime) / preempt, 0, 100)); + Alpha = (float)MathHelper.Clamp((Clock.CurrentTime - HitTime + 600) / 400, 0, 1); + + if (Clock.CurrentTime > HitTime + duration) + Expire(); + } + } + + private void attemptHit() => Schedule(() => + { + if (HitEvent != null) + return; + + // in case it was hit outside of display range, show immediately + // so the user isn't confused. + this.FadeIn(); + + approach.Expire(); + + circle + .FadeOut(duration) + .ScaleTo(1.5f, duration); + + HitEvent = new HitEvent(Clock.CurrentTime - HitTime, HitResult.Good, new HitObject + { + HitWindows = new HitWindows(), + }, null, null); + + Hit?.Invoke(HitEvent.Value); + + this.Delay(duration).Expire(); + }); + } + } +} diff --git a/osu.Game/Screens/Utility/LatencyArea.cs b/osu.Game/Screens/Utility/LatencyArea.cs new file mode 100644 index 0000000000..b225171f5d --- /dev/null +++ b/osu.Game/Screens/Utility/LatencyArea.cs @@ -0,0 +1,154 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using 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.Events; +using osu.Game.Graphics.Cursor; +using osu.Game.Overlays; +using osu.Game.Screens.Utility.SampleComponents; +using osuTK.Input; + +namespace osu.Game.Screens.Utility +{ + [Cached] + public partial class LatencyArea : CompositeDrawable, IProvideCursor + { + [Resolved] + private OverlayColourProvider overlayColourProvider { get; set; } = null!; + + public Action? ReportUserBest { get; set; } + + private Drawable? background; + + private readonly Key key; + + private Container visualContent = null!; + + public readonly int? TargetFrameRate; + + public readonly BindableBool IsActiveArea = new BindableBool(); + + public readonly Bindable VisualMode = new Bindable(); + + public CursorContainer? Cursor { get; private set; } + + public bool ProvidingUserCursor => IsActiveArea.Value; + + public LatencyArea(Key key, int? targetFrameRate) + { + this.key = key; + TargetFrameRate = targetFrameRate; + + RelativeSizeAxes = Axes.Both; + Masking = true; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + InternalChildren = new[] + { + background = new Box + { + Colour = overlayColourProvider.Background6, + RelativeSizeAxes = Axes.Both, + }, + new ButtonWithKeyBind(key) + { + Text = "Feels better", + Y = 20, + Width = 0.8f, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Action = () => ReportUserBest?.Invoke(), + }, + visualContent = new Container + { + RelativeSizeAxes = Axes.Both, + }, + }; + + IsActiveArea.BindValueChanged(active => + { + background.FadeColour(active.NewValue ? overlayColourProvider.Background4 : overlayColourProvider.Background6, 200, Easing.OutQuint); + }, true); + + VisualMode.BindValueChanged(mode => + { + switch (mode.NewValue) + { + case LatencyVisualMode.Simple: + visualContent.Children = new Drawable[] + { + new LatencyMovableBox + { + RelativeSizeAxes = Axes.Both, + }, + Cursor = new LatencyCursorContainer + { + RelativeSizeAxes = Axes.Both, + }, + }; + break; + + case LatencyVisualMode.CircleGameplay: + visualContent.Children = new Drawable[] + { + new CircleGameplay + { + RelativeSizeAxes = Axes.Both, + }, + Cursor = new LatencyCursorContainer + { + RelativeSizeAxes = Axes.Both, + }, + }; + break; + + case LatencyVisualMode.ScrollingGameplay: + visualContent.Children = new Drawable[] + { + new ScrollingGameplay + { + RelativeSizeAxes = Axes.Both, + }, + Cursor = new LatencyCursorContainer + { + RelativeSizeAxes = Axes.Both, + }, + }; + break; + + default: + throw new ArgumentOutOfRangeException(); + } + }, true); + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + IsActiveArea.Value = true; + return base.OnMouseMove(e); + } + + private double lastFrameTime; + + public override bool UpdateSubTree() + { + double elapsed = Clock.CurrentTime - lastFrameTime; + if (TargetFrameRate.HasValue && elapsed < 1000.0 / TargetFrameRate) + return false; + + lastFrameTime = Clock.CurrentTime; + + return base.UpdateSubTree(); + } + } +} diff --git a/osu.Game/Screens/Utility/LatencyCertifierScreen.cs b/osu.Game/Screens/Utility/LatencyCertifierScreen.cs new file mode 100644 index 0000000000..5c8e448931 --- /dev/null +++ b/osu.Game/Screens/Utility/LatencyCertifierScreen.cs @@ -0,0 +1,525 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Configuration; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Framework.Platform; +using osu.Framework.Platform.Windows; +using osu.Framework.Screens; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Overlays.Settings; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Screens.Utility +{ + [Cached] + public partial class LatencyCertifierScreen : OsuScreen + { + private FrameSync previousFrameSyncMode; + private double previousActiveHz; + + private readonly OsuTextFlowContainer statusText; + + public override bool HideOverlaysOnEnter => true; + + public override float BackgroundParallaxAmount => 0; + + private readonly LinkFlowContainer explanatoryText; + + private readonly Container mainArea; + + private readonly Container resultsArea; + + public readonly BindableDouble SampleBPM = new BindableDouble(120) { MinValue = 60, MaxValue = 300, Precision = 1 }; + public readonly BindableDouble SampleApproachRate = new BindableDouble(9) { MinValue = 5, MaxValue = 12, Precision = 0.1 }; + public readonly BindableFloat SampleVisualSpacing = new BindableFloat(0.5f) { MinValue = 0f, MaxValue = 1, Precision = 0.1f }; + + /// + /// The rate at which the game host should attempt to run. + /// + private const int target_host_update_frames = 4000; + + [Cached] + private readonly OverlayColourProvider overlayColourProvider = new OverlayColourProvider(OverlayColourScheme.Orange); + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private FrameworkConfigManager config { get; set; } = null!; + + public readonly Bindable VisualMode = new Bindable(); + + private const int rounds_to_complete = 5; + + private const int rounds_to_complete_certified = 20; + + /// + /// Whether we are now in certification mode and decreasing difficulty. + /// + private bool isCertifying; + + private int totalRoundForNextResultsScreen => isCertifying ? rounds_to_complete_certified : rounds_to_complete; + + private int attemptsAtCurrentDifficulty; + private int correctAtCurrentDifficulty; + + public int DifficultyLevel { get; private set; } = 1; + + private double lastPoll; + private int pollingMax; + + private readonly FillFlowContainer settings; + + [Resolved] + private GameHost host { get; set; } = null!; + + [Resolved] + private MusicController musicController { get; set; } = null!; + + public LatencyCertifierScreen() + { + InternalChildren = new Drawable[] + { + new Box + { + Colour = overlayColourProvider.Background6, + RelativeSizeAxes = Axes.Both, + }, + mainArea = new Container + { + RelativeSizeAxes = Axes.Both, + }, + // Make sure the edge between the two comparisons can't be used to ascertain latency. + new Box + { + Name = "separator", + Colour = ColourInfo.GradientHorizontal(overlayColourProvider.Background6, overlayColourProvider.Background6.Opacity(0)), + Width = 100, + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopLeft, + }, + new Box + { + Name = "separator", + Colour = ColourInfo.GradientHorizontal(overlayColourProvider.Background6.Opacity(0), overlayColourProvider.Background6), + Width = 100, + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopRight, + }, + settings = new FillFlowContainer + { + Name = "Settings", + AutoSizeAxes = Axes.Y, + Width = 800, + Padding = new MarginPadding(10), + Spacing = new Vector2(2), + Direction = FillDirection.Vertical, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Children = new Drawable[] + { + explanatoryText = new LinkFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 20)) + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + TextAnchor = Anchor.TopCentre, + }, + new SettingsSlider + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.None, + Width = 400, + LabelText = "bpm", + Current = SampleBPM + }, + new SettingsSlider + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.None, + Width = 400, + LabelText = "visual spacing", + Current = SampleVisualSpacing + }, + new SettingsSlider + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.None, + Width = 400, + LabelText = "approach rate", + Current = SampleApproachRate + }, + }, + }, + resultsArea = new Container + { + RelativeSizeAxes = Axes.Both, + }, + statusText = new OsuTextFlowContainer(cp => cp.Font = OsuFont.Default.With(size: 40)) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + TextAnchor = Anchor.TopCentre, + Y = 150, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + }; + + explanatoryText.AddParagraph(@"Welcome to the latency certifier!"); + explanatoryText.AddParagraph(@"Do whatever you need to try and perceive the difference in latency, then choose your best side. Read more about the methodology "); + explanatoryText.AddLink("here", "https://github.com/ppy/osu/wiki/Latency-and-unlimited-frame-rates#methodology"); + explanatoryText.AddParagraph(@"Use the arrow keys or Z/X/F/J to control the display."); + explanatoryText.AddParagraph(@"Tab key to change focus. Space to change display mode"); + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + if (lastPoll > 0 && Clock.CurrentTime != lastPoll) + pollingMax = (int)Math.Max(pollingMax, 1000 / (Clock.CurrentTime - lastPoll)); + lastPoll = Clock.CurrentTime; + return base.OnMouseMove(e); + } + + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + + previousFrameSyncMode = config.Get(FrameworkSetting.FrameSync); + previousActiveHz = host.UpdateThread.ActiveHz; + config.SetValue(FrameworkSetting.FrameSync, FrameSync.Unlimited); + host.UpdateThread.ActiveHz = target_host_update_frames; + host.AllowBenchmarkUnlimitedFrames = true; + + musicController.Stop(); + } + + public override bool OnExiting(ScreenExitEvent e) + { + host.AllowBenchmarkUnlimitedFrames = false; + config.SetValue(FrameworkSetting.FrameSync, previousFrameSyncMode); + host.UpdateThread.ActiveHz = previousActiveHz; + return base.OnExiting(e); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + loadNextRound(); + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + switch (e.Key) + { + case Key.Space: + int availableModes = Enum.GetValues().Length; + VisualMode.Value = (LatencyVisualMode)(((int)VisualMode.Value + 1) % availableModes); + return true; + + case Key.Tab: + var firstArea = mainArea.FirstOrDefault(a => !a.IsActiveArea.Value); + if (firstArea != null) + firstArea.IsActiveArea.Value = true; + return true; + } + + return base.OnKeyDown(e); + } + + private void showResults() + { + mainArea.Clear(); + resultsArea.Clear(); + settings.Hide(); + + var displayMode = host.Window?.CurrentDisplayMode.Value; + + string exclusive = (host.Renderer as IWindowsRenderer)?.FullscreenCapability.ToString() ?? "unknown"; + + statusText.Clear(); + + float successRate = (float)correctAtCurrentDifficulty / attemptsAtCurrentDifficulty; + bool isPass = successRate == 1; + + statusText.AddParagraph($"You scored {correctAtCurrentDifficulty} out of {attemptsAtCurrentDifficulty} ({successRate:0%})!", cp => cp.Colour = isPass ? colours.Green : colours.Red); + statusText.AddParagraph($"Level {DifficultyLevel} ({mapDifficultyToTargetFrameRate(DifficultyLevel):N0} Hz)", + cp => cp.Font = OsuFont.Default.With(size: 24)); + + statusText.AddParagraph(string.Empty); + statusText.AddParagraph(string.Empty); + statusText.AddIcon(isPass ? FontAwesome.Regular.CheckCircle : FontAwesome.Regular.TimesCircle, cp => cp.Colour = isPass ? colours.Green : colours.Red); + statusText.AddParagraph(string.Empty); + + if (!isPass && DifficultyLevel > 1) + { + statusText.AddParagraph("To complete certification, the difficulty level will now decrease until you can get 20 rounds correct in a row!", + cp => cp.Font = OsuFont.Default.With(size: 24, weight: FontWeight.SemiBold)); + statusText.AddParagraph(string.Empty); + } + + statusText.AddParagraph($"Polling: {pollingMax} Hz Monitor: {displayMode?.RefreshRate ?? 0:N0} Hz Exclusive: {exclusive}", + cp => cp.Font = OsuFont.Default.With(size: 15, weight: FontWeight.SemiBold)); + + statusText.AddParagraph($"Input: {host.InputThread.Clock.FramesPerSecond} Hz " + + $"Update: {host.UpdateThread.Clock.FramesPerSecond} Hz " + + $"Draw: {host.DrawThread.Clock.FramesPerSecond} Hz" + , cp => cp.Font = OsuFont.Default.With(size: 15, weight: FontWeight.SemiBold)); + + if (isCertifying && isPass) + { + showCertifiedScreen(); + return; + } + + string cannotIncreaseReason = string.Empty; + + if (mapDifficultyToTargetFrameRate(DifficultyLevel + 1) > target_host_update_frames) + cannotIncreaseReason = "You've reached the maximum level."; + else if (mapDifficultyToTargetFrameRate(DifficultyLevel + 1) > Clock.FramesPerSecond) + cannotIncreaseReason = "Game is not running fast enough to test this level"; + + FillFlowContainer buttonFlow; + + resultsArea.Add(buttonFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Spacing = new Vector2(20), + Padding = new MarginPadding(20), + }); + + if (isPass) + { + buttonFlow.Add(new ButtonWithKeyBind(Key.Enter) + { + Text = "Continue to next level", + BackgroundColour = colours.Green, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Action = () => changeDifficulty(DifficultyLevel + 1), + Enabled = { Value = string.IsNullOrEmpty(cannotIncreaseReason) }, + TooltipText = cannotIncreaseReason + }); + } + else + { + if (DifficultyLevel == 1) + { + buttonFlow.Add(new ButtonWithKeyBind(Key.Enter) + { + Text = "Retry", + TooltipText = "Are you even trying..?", + BackgroundColour = colours.Pink2, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Action = () => + { + isCertifying = false; + changeDifficulty(1); + }, + }); + } + else + { + buttonFlow.Add(new ButtonWithKeyBind(Key.Enter) + { + Text = "Begin certification at last level", + BackgroundColour = colours.Yellow, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Action = () => + { + isCertifying = true; + changeDifficulty(DifficultyLevel - 1); + }, + TooltipText = isPass + ? $"Chain {rounds_to_complete_certified} rounds to confirm your perception!" + : "You've reached your limits. Go to the previous level to complete certification!", + }); + } + } + } + + private void showCertifiedScreen() + { + Drawable background; + Drawable certifiedText; + + resultsArea.AddRange(new[] + { + background = new Box + { + Colour = overlayColourProvider.Background4, + RelativeSizeAxes = Axes.Both, + }, + (certifiedText = new OsuSpriteText + { + Alpha = 0, + Font = OsuFont.TorusAlternate.With(size: 80, weight: FontWeight.Bold), + Text = "Certified!", + Blending = BlendingParameters.Additive, + }).WithEffect(new GlowEffect + { + Colour = overlayColourProvider.Colour1, + PadExtent = true + }).With(e => + { + e.Anchor = Anchor.Centre; + e.Origin = Anchor.Centre; + }), + new OsuSpriteText + { + Text = $"You should use a frame limiter with update rate of {mapDifficultyToTargetFrameRate(DifficultyLevel + 1)} Hz (or fps) for best results!", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Torus.With(size: 24, weight: FontWeight.SemiBold), + Y = 80, + } + }); + + background.FadeInFromZero(1000, Easing.OutQuint); + + certifiedText.FadeInFromZero(500, Easing.InQuint); + + certifiedText + .ScaleTo(10) + .ScaleTo(1, 600, Easing.InQuad) + .Then() + .ScaleTo(1.05f, 10000, Easing.OutQuint); + } + + private void changeDifficulty(int difficulty) + { + Debug.Assert(difficulty > 0); + + resultsArea.Clear(); + + correctAtCurrentDifficulty = 0; + attemptsAtCurrentDifficulty = 0; + + pollingMax = 0; + lastPoll = 0; + + DifficultyLevel = difficulty; + + loadNextRound(); + } + + private void loadNextRound() + { + settings.Show(); + + attemptsAtCurrentDifficulty++; + statusText.Text = $"Level {DifficultyLevel}\nRound {attemptsAtCurrentDifficulty} of {totalRoundForNextResultsScreen}"; + + mainArea.Clear(); + + int betterSide = RNG.Next(0, 2); + + mainArea.AddRange(new[] + { + new LatencyArea(Key.Number1, betterSide == 1 ? mapDifficultyToTargetFrameRate(DifficultyLevel) : null) + { + Width = 0.5f, + VisualMode = { BindTarget = VisualMode }, + IsActiveArea = { Value = true }, + ReportUserBest = () => recordResult(betterSide == 0), + }, + new LatencyArea(Key.Number2, betterSide == 0 ? mapDifficultyToTargetFrameRate(DifficultyLevel) : null) + { + Width = 0.5f, + VisualMode = { BindTarget = VisualMode }, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + ReportUserBest = () => recordResult(betterSide == 1) + } + }); + + foreach (var area in mainArea) + { + area.IsActiveArea.BindValueChanged(active => + { + if (active.NewValue) + mainArea.Children.First(a => a != area).IsActiveArea.Value = false; + }); + } + } + + private void recordResult(bool correct) + { + // Fading this out will improve the frame rate after the first round due to less text on screen. + explanatoryText.FadeOut(500, Easing.OutQuint); + + if (correct) + correctAtCurrentDifficulty++; + + if (attemptsAtCurrentDifficulty < totalRoundForNextResultsScreen) + loadNextRound(); + else + showResults(); + } + + private static int mapDifficultyToTargetFrameRate(int difficulty) + { + switch (difficulty) + { + case 1: + return 15; + + case 2: + return 30; + + case 3: + return 45; + + case 4: + return 60; + + case 5: + return 120; + + case 6: + return 240; + + case 7: + return 480; + + case 8: + return 720; + + case 9: + return 960; + + default: + return 1000 + ((difficulty - 10) * 500); + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ColumnType.cs b/osu.Game/Screens/Utility/LatencyVisualMode.cs similarity index 54% rename from osu.Game.Rulesets.Mania/Beatmaps/ColumnType.cs rename to osu.Game/Screens/Utility/LatencyVisualMode.cs index 8f904530bc..c4022d09b2 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/ColumnType.cs +++ b/osu.Game/Screens/Utility/LatencyVisualMode.cs @@ -1,12 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -namespace osu.Game.Rulesets.Mania.Beatmaps +namespace osu.Game.Screens.Utility { - public enum ColumnType + public enum LatencyVisualMode { - Even, - Odd, - Special + CircleGameplay, + ScrollingGameplay, + Simple, } } diff --git a/osu.Game/Screens/Utility/SampleComponents/LatencyCursorContainer.cs b/osu.Game/Screens/Utility/SampleComponents/LatencyCursorContainer.cs new file mode 100644 index 0000000000..251d283242 --- /dev/null +++ b/osu.Game/Screens/Utility/SampleComponents/LatencyCursorContainer.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Input.States; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Screens.Utility.SampleComponents +{ + public partial class LatencyCursorContainer : CursorContainer + { + protected override Drawable CreateCursor() => new LatencyCursor(); + + public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; + + public LatencyCursorContainer() + { + State.Value = Visibility.Hidden; + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + // Scheduling is required to ensure updating of cursor position happens in limited rate. + // We can alternatively solve this by a PassThroughInputManager layer inside LatencyArea, + // but that would mean including input lag to this test, which may not be desired. + Schedule(() => base.OnMouseMove(e)); + return false; + } + + private partial class LatencyCursor : LatencySampleComponent + { + public LatencyCursor() + { + AutoSizeAxes = Axes.Both; + Origin = Anchor.Centre; + + InternalChild = new Circle { Size = new Vector2(40) }; + } + + protected override void UpdateAtLimitedRate(InputState inputState) + { + Colour = inputState.Mouse.IsPressed(MouseButton.Left) ? OverlayColourProvider.Content1 : OverlayColourProvider.Colour2; + } + } + } +} diff --git a/osu.Game/Screens/Utility/SampleComponents/LatencyMovableBox.cs b/osu.Game/Screens/Utility/SampleComponents/LatencyMovableBox.cs new file mode 100644 index 0000000000..dcfcf602bf --- /dev/null +++ b/osu.Game/Screens/Utility/SampleComponents/LatencyMovableBox.cs @@ -0,0 +1,82 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Input.States; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Screens.Utility.SampleComponents +{ + public partial class LatencyMovableBox : LatencySampleComponent + { + private Box box = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + InternalChild = box = new Box + { + Size = new Vector2(40), + RelativePositionAxes = Axes.Both, + Position = new Vector2(0.5f), + Origin = Anchor.Centre, + Colour = OverlayColourProvider.Colour1, + }; + } + + protected override bool OnHover(HoverEvent e) => false; + + private double? lastFrameTime; + + protected override void UpdateAtLimitedRate(InputState inputState) + { + if (!IsActive.Value) + { + lastFrameTime = null; + box.Colour = OverlayColourProvider.Colour1; + return; + } + + if (lastFrameTime != null) + { + float movementAmount = (float)(Clock.CurrentTime - lastFrameTime) / 400; + + var buttons = inputState.Keyboard.Keys; + + box.Colour = buttons.HasAnyButtonPressed ? OverlayColourProvider.Content1 : OverlayColourProvider.Colour1; + + foreach (var key in buttons) + { + switch (key) + { + case Key.F: + case Key.Up: + box.Y = MathHelper.Clamp(box.Y - movementAmount, 0.1f, 0.9f); + break; + + case Key.J: + case Key.Down: + box.Y = MathHelper.Clamp(box.Y + movementAmount, 0.1f, 0.9f); + break; + + case Key.Z: + case Key.Left: + box.X = MathHelper.Clamp(box.X - movementAmount, 0.1f, 0.9f); + break; + + case Key.X: + case Key.Right: + box.X = MathHelper.Clamp(box.X + movementAmount, 0.1f, 0.9f); + break; + } + } + } + + lastFrameTime = Clock.CurrentTime; + } + } +} diff --git a/osu.Game/Screens/Utility/SampleComponents/LatencySampleComponent.cs b/osu.Game/Screens/Utility/SampleComponents/LatencySampleComponent.cs new file mode 100644 index 0000000000..690376cf52 --- /dev/null +++ b/osu.Game/Screens/Utility/SampleComponents/LatencySampleComponent.cs @@ -0,0 +1,53 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input; +using osu.Framework.Input.States; +using osu.Game.Overlays; + +namespace osu.Game.Screens.Utility.SampleComponents +{ + public abstract partial class LatencySampleComponent : CompositeDrawable + { + protected readonly BindableDouble SampleBPM = new BindableDouble(); + protected readonly BindableDouble SampleApproachRate = new BindableDouble(); + protected readonly BindableFloat SampleVisualSpacing = new BindableFloat(); + + protected readonly BindableBool IsActive = new BindableBool(); + + private InputManager inputManager = null!; + + [Resolved] + private LatencyArea latencyArea { get; set; } = null!; + + [Resolved] + protected OverlayColourProvider OverlayColourProvider { get; private set; } = null!; + + [BackgroundDependencyLoader] + private void load(LatencyCertifierScreen latencyCertifierScreen) + { + SampleBPM.BindTo(latencyCertifierScreen.SampleBPM); + SampleApproachRate.BindTo(latencyCertifierScreen.SampleApproachRate); + SampleVisualSpacing.BindTo(latencyCertifierScreen.SampleVisualSpacing); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + inputManager = GetContainingInputManager(); + IsActive.BindTo(latencyArea.IsActiveArea); + } + + protected sealed override void Update() + { + base.Update(); + UpdateAtLimitedRate(inputManager.CurrentState); + } + + protected abstract void UpdateAtLimitedRate(InputState inputState); + } +} diff --git a/osu.Game/Screens/Utility/ScrollingGameplay.cs b/osu.Game/Screens/Utility/ScrollingGameplay.cs new file mode 100644 index 0000000000..f1331d8fb2 --- /dev/null +++ b/osu.Game/Screens/Utility/ScrollingGameplay.cs @@ -0,0 +1,200 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Input.States; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Utility.SampleComponents; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Screens.Utility +{ + public partial class ScrollingGameplay : LatencySampleComponent + { + private const float judgement_position = 0.8f; + private const float bar_height = 20; + + private int nextLocation; + + private readonly List hitEvents = new List(); + + private double? lastGeneratedBeatTime; + + private Container circles = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + InternalChildren = new Drawable[] + { + new Box + { + Name = "judgement bar", + Colour = OverlayColourProvider.Content2, + RelativeSizeAxes = Axes.X, + RelativePositionAxes = Axes.Y, + Y = judgement_position, + Height = bar_height, + }, + circles = new Container + { + RelativeSizeAxes = Axes.Both, + }, + }; + + SampleBPM.BindValueChanged(_ => + { + circles.Clear(); + lastGeneratedBeatTime = null; + }); + } + + protected override void UpdateAtLimitedRate(InputState inputState) + { + double beatLength = 60000 / SampleBPM.Value; + + int nextBeat = (int)(Clock.CurrentTime / beatLength) + 1; + + // We want to generate a few hit objects ahead of the current time (to allow them to animate). + double generateUpTo = (nextBeat + 2) * beatLength; + + while (lastGeneratedBeatTime == null || lastGeneratedBeatTime < generateUpTo) + { + double time = ++nextBeat * beatLength; + + if (time <= lastGeneratedBeatTime) + continue; + + newBeat(time); + lastGeneratedBeatTime = time; + } + } + + private void newBeat(double time) + { + const float columns = 4; + + float adjustedXPos = ((1f + nextLocation++ % columns) - columns / 2) / columns; + + circles.Add(new SampleNote(time) + { + RelativePositionAxes = Axes.Both, + X = 0.5f + SampleVisualSpacing.Value * (adjustedXPos * 0.5f), + Scale = new Vector2(0.4f + (0.8f * SampleVisualSpacing.Value), 1), + Hit = hit, + }); + } + + private void hit(HitEvent h) + { + hitEvents.Add(h); + } + + public partial class SampleNote : LatencySampleComponent + { + public HitEvent? HitEvent; + + public Action? Hit { get; set; } + + public readonly double HitTime; + + private Box box = null!; + + private const float size = 100; + private const float duration = 200; + + public SampleNote(double hitTime) + { + HitTime = hitTime; + + Origin = Anchor.Centre; + AutoSizeAxes = Axes.Both; + AlwaysPresent = true; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + box = new Box + { + Colour = OverlayColourProvider.Content1, + Size = new Vector2(size, bar_height), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + }; + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (!IsActive.Value) + return false; + + if (Math.Abs(Clock.CurrentTime - HitTime) > duration) + return false; + + // Allow using any key that isn't used by the latency certifier itself. + switch (e.Key) + { + case Key.Space: + case Key.Number1: + case Key.Number2: + case Key.Tab: + return false; + } + + attemptHit(); + return true; + } + + protected override void UpdateAtLimitedRate(InputState inputState) + { + if (HitEvent == null) + { + double preempt = (float)IBeatmapDifficultyInfo.DifficultyRange(SampleApproachRate.Value, 1800, 1200, 450); + + Alpha = (float)MathHelper.Clamp((Clock.CurrentTime - HitTime + 600) / 400, 0, 1); + Y = judgement_position - (float)((HitTime - Clock.CurrentTime) / preempt); + + if (Clock.CurrentTime > HitTime + duration) + Expire(); + } + } + + private void attemptHit() => Schedule(() => + { + if (HitEvent != null) + return; + + // in case it was hit outside of display range, show immediately + // so the user isn't confused. + this.FadeIn(); + + box + .FadeOut(duration / 2) + .ScaleTo(1.5f, duration / 2); + + HitEvent = new HitEvent(Clock.CurrentTime - HitTime, HitResult.Good, new HitObject + { + HitWindows = new HitWindows(), + }, null, null); + + Hit?.Invoke(HitEvent.Value); + + this.Delay(duration).Expire(); + }); + } + } +} diff --git a/osu.Game/Skinning/ArgonProSkin.cs b/osu.Game/Skinning/ArgonProSkin.cs new file mode 100644 index 0000000000..b753dd8fbe --- /dev/null +++ b/osu.Game/Skinning/ArgonProSkin.cs @@ -0,0 +1,48 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; +using osu.Framework.Audio.Sample; +using osu.Game.Audio; +using osu.Game.Extensions; +using osu.Game.IO; + +namespace osu.Game.Skinning +{ + public class ArgonProSkin : ArgonSkin + { + public new static SkinInfo CreateInfo() => new SkinInfo + { + ID = Skinning.SkinInfo.ARGON_PRO_SKIN, + Name = "osu! \"argon\" pro (2022)", + Creator = "team osu!", + Protected = true, + InstantiationInfo = typeof(ArgonProSkin).GetInvariantInstantiationInfo() + }; + + public override ISample? GetSample(ISampleInfo sampleInfo) + { + foreach (string lookup in sampleInfo.LookupNames) + { + string remappedLookup = lookup.Replace(@"Gameplay/", @"Gameplay/ArgonPro/"); + + var sample = Samples?.Get(remappedLookup) ?? Resources.AudioManager?.Samples.Get(remappedLookup); + if (sample != null) + return sample; + } + + return null; + } + + public ArgonProSkin(IStorageResourceProvider resources) + : this(CreateInfo(), resources) + { + } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)] + public ArgonProSkin(SkinInfo skin, IStorageResourceProvider resources) + : base(skin, resources) + { + } + } +} diff --git a/osu.Game/Skinning/ArgonSkin.cs b/osu.Game/Skinning/ArgonSkin.cs new file mode 100644 index 0000000000..a9b26f13e8 --- /dev/null +++ b/osu.Game/Skinning/ArgonSkin.cs @@ -0,0 +1,222 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; +using osu.Game.Audio; +using osu.Game.Beatmaps.Formats; +using osu.Game.Extensions; +using osu.Game.IO; +using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Play.HUD.HitErrorMeters; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Skinning +{ + public class ArgonSkin : Skin + { + public static SkinInfo CreateInfo() => new SkinInfo + { + ID = Skinning.SkinInfo.ARGON_SKIN, + Name = "osu! \"argon\" (2022)", + Creator = "team osu!", + Protected = true, + InstantiationInfo = typeof(ArgonSkin).GetInvariantInstantiationInfo() + }; + + protected readonly IStorageResourceProvider Resources; + + public ArgonSkin(IStorageResourceProvider resources) + : this(CreateInfo(), resources) + { + } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)] + public ArgonSkin(SkinInfo skin, IStorageResourceProvider resources) + : base(skin, resources) + { + Resources = resources; + + Configuration.CustomComboColours = new List + { + // Standard combo progression order is green - blue - red - yellow. + // But for whatever reason, this starts from index 1, not 0. + // + // We've added two new combo colours in argon, so to ensure the initial rotation matches, + // this same progression is in slots 1 - 4. + + // Orange + new Color4(241, 116, 0, 255), + // Green + new Color4(0, 241, 53, 255), + // Blue + new Color4(0, 82, 241, 255), + // Red + new Color4(241, 0, 0, 255), + // Yellow + new Color4(232, 235, 0, 255), + // Purple + new Color4(92, 0, 241, 255), + }; + } + + public override Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => Textures?.Get(componentName, wrapModeS, wrapModeT); + + public override ISample? GetSample(ISampleInfo sampleInfo) + { + foreach (string lookup in sampleInfo.LookupNames) + { + var sample = Samples?.Get(lookup) ?? Resources.AudioManager?.Samples.Get(lookup); + if (sample != null) + return sample; + } + + return null; + } + + public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) + { + // Temporary until default skin has a valid hit lighting. + if ((lookup as SkinnableSprite.SpriteComponentLookup)?.LookupName == @"lighting") return Drawable.Empty(); + + if (base.GetDrawableComponent(lookup) is Drawable c) + return c; + + switch (lookup) + { + case SkinComponentsContainerLookup containerLookup: + // Only handle global level defaults for now. + if (containerLookup.Ruleset != null) + return null; + + switch (containerLookup.Target) + { + case SkinComponentsContainerLookup.TargetArea.SongSelect: + var songSelectComponents = new DefaultSkinComponentsContainer(_ => + { + // do stuff when we need to. + }); + + return songSelectComponents; + + case SkinComponentsContainerLookup.TargetArea.MainHUDComponents: + var skinnableTargetWrapper = new DefaultSkinComponentsContainer(container => + { + var score = container.OfType().FirstOrDefault(); + var accuracy = container.OfType().FirstOrDefault(); + var combo = container.OfType().FirstOrDefault(); + var ppCounter = container.OfType().FirstOrDefault(); + var songProgress = container.OfType().FirstOrDefault(); + + if (score != null) + { + score.Anchor = Anchor.TopCentre; + score.Origin = Anchor.TopCentre; + + // elements default to beneath the health bar + const float vertical_offset = 30; + + const float horizontal_padding = 20; + + score.Position = new Vector2(0, vertical_offset); + + if (ppCounter != null) + { + ppCounter.Y = score.Position.Y + ppCounter.ScreenSpaceDeltaToParentSpace(score.ScreenSpaceDrawQuad.Size).Y - 4; + ppCounter.Origin = Anchor.TopCentre; + ppCounter.Anchor = Anchor.TopCentre; + } + + if (accuracy != null) + { + accuracy.Position = new Vector2(-accuracy.ScreenSpaceDeltaToParentSpace(score.ScreenSpaceDrawQuad.Size).X / 2 - horizontal_padding, vertical_offset + 5); + accuracy.Origin = Anchor.TopRight; + accuracy.Anchor = Anchor.TopCentre; + + if (combo != null) + { + combo.Position = new Vector2(accuracy.ScreenSpaceDeltaToParentSpace(score.ScreenSpaceDrawQuad.Size).X / 2 + horizontal_padding, vertical_offset + 5); + combo.Anchor = Anchor.TopCentre; + } + } + + var hitError = container.OfType().FirstOrDefault(); + + if (hitError != null) + { + hitError.Anchor = Anchor.CentreLeft; + hitError.Origin = Anchor.CentreLeft; + } + + var hitError2 = container.OfType().LastOrDefault(); + + if (hitError2 != null) + { + hitError2.Anchor = Anchor.CentreRight; + hitError2.Scale = new Vector2(-1, 1); + // origin flipped to match scale above. + hitError2.Origin = Anchor.CentreLeft; + } + + if (songProgress != null) + { + songProgress.Position = new Vector2(0, -10); + songProgress.Scale = new Vector2(0.9f, 1); + } + } + }) + { + Children = new Drawable[] + { + new DefaultComboCounter(), + new DefaultScoreCounter(), + new DefaultAccuracyCounter(), + new DefaultHealthDisplay(), + new ArgonSongProgress(), + new BarHitErrorMeter(), + new BarHitErrorMeter(), + new PerformancePointsCounter() + } + }; + + return skinnableTargetWrapper; + } + + return null; + } + + return null; + } + + public override IBindable? GetConfig(TLookup lookup) + { + // todo: this code is pulled from LegacySkin and should not exist. + // will likely change based on how databased storage of skin configuration goes. + switch (lookup) + { + case GlobalSkinColours global: + switch (global) + { + case GlobalSkinColours.ComboColours: + return SkinUtils.As(new Bindable?>(Configuration.ComboColours)); + } + + break; + + case SkinComboColourLookup comboColour: + return SkinUtils.As(new Bindable(getComboColour(Configuration, comboColour.ColourIndex))); + } + + return null; + } + + private static Color4 getComboColour(IHasComboColours source, int colourIndex) + => source.ComboColours[colourIndex % source.ComboColours.Count]; + } +} diff --git a/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs b/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs index 2ba301deed..4486c8a9f0 100644 --- a/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs +++ b/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -13,7 +15,7 @@ namespace osu.Game.Skinning /// /// A container which overrides existing skin options with beatmap-local values. /// - public class BeatmapSkinProvidingContainer : SkinProvidingContainer + public partial class BeatmapSkinProvidingContainer : SkinProvidingContainer { private Bindable beatmapSkins; private Bindable beatmapColours; @@ -41,7 +43,7 @@ namespace osu.Game.Skinning } } - protected override bool AllowDrawableLookup(ISkinComponent component) + protected override bool AllowDrawableLookup(ISkinComponentLookup lookup) { if (beatmapSkins == null) throw new InvalidOperationException($"{nameof(BeatmapSkinProvidingContainer)} needs to be loaded before being consumed."); @@ -65,9 +67,12 @@ namespace osu.Game.Skinning return sampleInfo is StoryboardSampleInfo || beatmapHitsounds.Value; } + private readonly ISkin skin; + public BeatmapSkinProvidingContainer(ISkin skin) : base(skin) { + this.skin = skin; } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) @@ -82,11 +87,21 @@ namespace osu.Game.Skinning } [BackgroundDependencyLoader] - private void load() + private void load(SkinManager skins) { beatmapSkins.BindValueChanged(_ => TriggerSourceChanged()); beatmapColours.BindValueChanged(_ => TriggerSourceChanged()); beatmapHitsounds.BindValueChanged(_ => TriggerSourceChanged()); + + // If the beatmap skin looks to have skinnable resources, add the default classic skin as a fallback opportunity. + if (skin is LegacySkinTransformer legacySkin && legacySkin.IsProvidingLegacyResources) + { + SetSources(new[] + { + skin, + skins.DefaultClassicSkin + }); + } } } } diff --git a/osu.Game/Skinning/Components/BeatmapAttributeText.cs b/osu.Game/Skinning/Components/BeatmapAttributeText.cs new file mode 100644 index 0000000000..2c16a67cac --- /dev/null +++ b/osu.Game/Skinning/Components/BeatmapAttributeText.cs @@ -0,0 +1,141 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Extensions; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; +using osu.Game.Localisation.SkinComponents; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Skinning.Components +{ + [UsedImplicitly] + public partial class BeatmapAttributeText : FontAdjustableSkinComponent + { + [SettingSource(typeof(BeatmapAttributeTextStrings), nameof(BeatmapAttributeTextStrings.Attribute), nameof(BeatmapAttributeTextStrings.AttributeDescription))] + public Bindable Attribute { get; } = new Bindable(BeatmapAttribute.StarRating); + + [SettingSource(typeof(BeatmapAttributeTextStrings), nameof(BeatmapAttributeTextStrings.Template), nameof(BeatmapAttributeTextStrings.TemplateDescription))] + public Bindable Template { get; set; } = new Bindable("{Label}: {Value}"); + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + private readonly Dictionary valueDictionary = new Dictionary(); + + private static readonly ImmutableDictionary label_dictionary = new Dictionary + { + [BeatmapAttribute.CircleSize] = BeatmapsetsStrings.ShowStatsCs, + [BeatmapAttribute.Accuracy] = BeatmapsetsStrings.ShowStatsAccuracy, + [BeatmapAttribute.HPDrain] = BeatmapsetsStrings.ShowStatsDrain, + [BeatmapAttribute.ApproachRate] = BeatmapsetsStrings.ShowStatsAr, + [BeatmapAttribute.StarRating] = BeatmapsetsStrings.ShowStatsStars, + [BeatmapAttribute.Title] = EditorSetupStrings.Title, + [BeatmapAttribute.Artist] = EditorSetupStrings.Artist, + [BeatmapAttribute.DifficultyName] = EditorSetupStrings.DifficultyHeader, + [BeatmapAttribute.Creator] = EditorSetupStrings.Creator, + [BeatmapAttribute.Length] = ArtistStrings.TracklistLength.ToTitle(), + [BeatmapAttribute.RankedStatus] = BeatmapDiscussionsStrings.IndexFormBeatmapsetStatusDefault, + [BeatmapAttribute.BPM] = BeatmapsetsStrings.ShowStatsBpm, + }.ToImmutableDictionary(); + + private readonly OsuSpriteText text; + + public BeatmapAttributeText() + { + AutoSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + text = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Attribute.BindValueChanged(_ => updateLabel()); + Template.BindValueChanged(_ => updateLabel()); + beatmap.BindValueChanged(b => + { + updateBeatmapContent(b.NewValue); + updateLabel(); + }, true); + } + + private void updateBeatmapContent(WorkingBeatmap workingBeatmap) + { + valueDictionary[BeatmapAttribute.Title] = workingBeatmap.BeatmapInfo.Metadata.Title; + valueDictionary[BeatmapAttribute.Artist] = 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(); + valueDictionary[BeatmapAttribute.RankedStatus] = workingBeatmap.BeatmapInfo.Status.GetLocalisableDescription(); + valueDictionary[BeatmapAttribute.BPM] = workingBeatmap.BeatmapInfo.BPM.ToLocalisableString(@"F2"); + valueDictionary[BeatmapAttribute.CircleSize] = ((double)workingBeatmap.BeatmapInfo.Difficulty.CircleSize).ToLocalisableString(@"F2"); + valueDictionary[BeatmapAttribute.HPDrain] = ((double)workingBeatmap.BeatmapInfo.Difficulty.DrainRate).ToLocalisableString(@"F2"); + valueDictionary[BeatmapAttribute.Accuracy] = ((double)workingBeatmap.BeatmapInfo.Difficulty.OverallDifficulty).ToLocalisableString(@"F2"); + valueDictionary[BeatmapAttribute.ApproachRate] = ((double)workingBeatmap.BeatmapInfo.Difficulty.ApproachRate).ToLocalisableString(@"F2"); + valueDictionary[BeatmapAttribute.StarRating] = workingBeatmap.BeatmapInfo.StarRating.ToLocalisableString(@"F2"); + } + + private void updateLabel() + { + string numberedTemplate = Template.Value + .Replace("{", "{{") + .Replace("}", "}}") + .Replace(@"{{Label}}", "{0}") + .Replace(@"{{Value}}", $"{{{1 + (int)Attribute.Value}}}"); + + object?[] args = valueDictionary.OrderBy(pair => pair.Key) + .Select(pair => pair.Value) + .Prepend(label_dictionary[Attribute.Value]) + .Cast() + .ToArray(); + + foreach (var type in Enum.GetValues()) + { + numberedTemplate = numberedTemplate.Replace($"{{{{{type}}}}}", $"{{{1 + (int)type}}}"); + } + + text.Text = LocalisableString.Format(numberedTemplate, args); + } + + protected override void SetFont(FontUsage font) => text.Font = font.With(size: 40); + } + + public enum BeatmapAttribute + { + CircleSize, + HPDrain, + Accuracy, + ApproachRate, + StarRating, + Title, + Artist, + DifficultyName, + Creator, + Length, + RankedStatus, + BPM, + } +} diff --git a/osu.Game/Skinning/Components/BigBlackBox.cs b/osu.Game/Skinning/Components/BigBlackBox.cs index 373e6467e8..3c63dae8d8 100644 --- a/osu.Game/Skinning/Components/BigBlackBox.cs +++ b/osu.Game/Skinning/Components/BigBlackBox.cs @@ -19,7 +19,7 @@ namespace osu.Game.Skinning.Components /// Intended to be a test bed for skinning. May be removed at some point in the future. /// [UsedImplicitly] - public class BigBlackBox : CompositeDrawable, ISkinnableDrawable + public partial class BigBlackBox : CompositeDrawable, ISerialisableDrawable { public bool UsesFixedAnchor { get; set; } diff --git a/osu.Game/Skinning/Components/TextElement.cs b/osu.Game/Skinning/Components/TextElement.cs new file mode 100644 index 0000000000..936f6a529b --- /dev/null +++ b/osu.Game/Skinning/Components/TextElement.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 JetBrains.Annotations; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation.SkinComponents; + +namespace osu.Game.Skinning.Components +{ + [UsedImplicitly] + public partial class TextElement : FontAdjustableSkinComponent + { + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextElementText), nameof(SkinnableComponentStrings.TextElementTextDescription))] + public Bindable Text { get; } = new Bindable("Circles!"); + + private readonly OsuSpriteText text; + + public TextElement() + { + AutoSizeAxes = Axes.Both; + InternalChildren = new Drawable[] + { + text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Default.With(size: 40) + } + }; + text.Current.BindTo(Text); + } + + protected override void SetFont(FontUsage font) => text.Font = font.With(size: 40); + } +} diff --git a/osu.Game/Skinning/DefaultLegacySkin.cs b/osu.Game/Skinning/DefaultLegacySkin.cs index f7b415e886..fd9653e3e5 100644 --- a/osu.Game/Skinning/DefaultLegacySkin.cs +++ b/osu.Game/Skinning/DefaultLegacySkin.cs @@ -15,7 +15,7 @@ namespace osu.Game.Skinning public static SkinInfo CreateInfo() => new SkinInfo { ID = Skinning.SkinInfo.CLASSIC_SKIN, // this is temporary until database storage is decided upon. - Name = "osu!classic", + Name = "osu! \"classic\" (2013)", Creator = "team osu!", Protected = true, InstantiationInfo = typeof(DefaultLegacySkin).GetInvariantInstantiationInfo() @@ -44,6 +44,8 @@ namespace osu.Game.Skinning new Color4(242, 24, 57, 255) }; + Configuration.ConfigDictionary[nameof(SkinConfiguration.LegacySetting.AllowSliderBallTint)] = @"true"; + Configuration.LegacyVersion = 2.7m; } } diff --git a/osu.Game/Skinning/SkinnableTargetComponentsContainer.cs b/osu.Game/Skinning/DefaultSkinComponentsContainer.cs similarity index 58% rename from osu.Game/Skinning/SkinnableTargetComponentsContainer.cs rename to osu.Game/Skinning/DefaultSkinComponentsContainer.cs index 67114de948..0d6e6b6ce9 100644 --- a/osu.Game/Skinning/SkinnableTargetComponentsContainer.cs +++ b/osu.Game/Skinning/DefaultSkinComponentsContainer.cs @@ -2,39 +2,28 @@ // See the LICENCE file in the repository root for full licence text. using System; -using Newtonsoft.Json; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; namespace osu.Game.Skinning { /// - /// A container which groups the components of a into a single object. - /// Optionally also applies a default layout to the components. + /// A container which can be used to specify default skin components layouts. + /// Handles applying a default layout to the components. /// - [Serializable] - public class SkinnableTargetComponentsContainer : Container, ISkinnableDrawable + public partial class DefaultSkinComponentsContainer : Container { - public bool IsEditable => false; - - public bool UsesFixedAnchor { get; set; } - - private readonly Action applyDefaults; + private readonly Action? applyDefaults; /// /// Construct a wrapper with defaults that should be applied once. /// /// A function to apply the default layout. - public SkinnableTargetComponentsContainer(Action applyDefaults) - : this() - { - this.applyDefaults = applyDefaults; - } - - [JsonConstructor] - public SkinnableTargetComponentsContainer() + public DefaultSkinComponentsContainer(Action applyDefaults) { RelativeSizeAxes = Axes.Both; + + this.applyDefaults = applyDefaults; } protected override void LoadComplete() diff --git a/osu.Game/Skinning/EFSkinInfo.cs b/osu.Game/Skinning/EFSkinInfo.cs deleted file mode 100644 index 50588563be..0000000000 --- a/osu.Game/Skinning/EFSkinInfo.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations.Schema; -using osu.Framework.Extensions.ObjectExtensions; -using osu.Game.Database; -using osu.Game.Extensions; -using osu.Game.IO; - -namespace osu.Game.Skinning -{ - [Table(@"SkinInfo")] - public class EFSkinInfo : IHasFiles, IEquatable, IHasPrimaryKey, ISoftDelete - { - internal const int DEFAULT_SKIN = 0; - internal const int CLASSIC_SKIN = -1; - internal const int RANDOM_SKIN = -2; - - public int ID { get; set; } - - public string Name { get; set; } = string.Empty; - - public string Creator { get; set; } = string.Empty; - - public string Hash { get; set; } - - public string InstantiationInfo { get; set; } - - public virtual Skin CreateInstance(IStorageResourceProvider resources) - { - var type = string.IsNullOrEmpty(InstantiationInfo) - // handle the case of skins imported before InstantiationInfo was added. - ? typeof(LegacySkin) - : Type.GetType(InstantiationInfo).AsNonNull(); - - return (Skin)Activator.CreateInstance(type, this, resources); - } - - public List Files { get; set; } = new List(); - - public bool DeletePending { get; set; } - - public static EFSkinInfo Default { get; } = new EFSkinInfo - { - ID = DEFAULT_SKIN, - Name = "osu! (triangles)", - Creator = "team osu!", - InstantiationInfo = typeof(DefaultSkin).GetInvariantInstantiationInfo() - }; - - public bool Equals(EFSkinInfo other) => other != null && ID == other.ID; - - public override string ToString() - { - string author = Creator == null ? string.Empty : $"({Creator})"; - return $"{Name} {author}".Trim(); - } - - public bool IsManaged => ID > 0; - } -} diff --git a/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs b/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs deleted file mode 100644 index ebf3c9c319..0000000000 --- a/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using System.Collections.Specialized; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Screens; -using osu.Framework.Testing; -using osu.Game.Rulesets.Edit; -using osu.Game.Screens; -using osu.Game.Screens.Edit.Compose.Components; - -namespace osu.Game.Skinning.Editor -{ - public class SkinBlueprintContainer : BlueprintContainer - { - private readonly Drawable target; - - private readonly List> targetComponents = new List>(); - - [Resolved] - private SkinEditor editor { get; set; } - - public SkinBlueprintContainer(Drawable target) - { - this.target = target; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - SelectedItems.BindTo(editor.SelectedComponents); - - // track each target container on the current screen. - var targetContainers = target.ChildrenOfType().ToArray(); - - if (targetContainers.Length == 0) - { - string targetScreen = target.ChildrenOfType().LastOrDefault()?.GetType().Name ?? "this screen"; - - AddInternal(new ScreenWhiteBox.UnderConstructionMessage(targetScreen, "doesn't support skin customisation just yet.")); - return; - } - - foreach (var targetContainer in targetContainers) - { - var bindableList = new BindableList { BindTarget = targetContainer.Components }; - bindableList.BindCollectionChanged(componentsChanged, true); - - targetComponents.Add(bindableList); - } - } - - private void componentsChanged(object sender, NotifyCollectionChangedEventArgs e) => Schedule(() => - { - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - foreach (var item in e.NewItems.Cast()) - AddBlueprintFor(item); - break; - - case NotifyCollectionChangedAction.Remove: - case NotifyCollectionChangedAction.Reset: - foreach (var item in e.OldItems.Cast()) - RemoveBlueprintFor(item); - break; - - case NotifyCollectionChangedAction.Replace: - foreach (var item in e.OldItems.Cast()) - RemoveBlueprintFor(item); - - foreach (var item in e.NewItems.Cast()) - AddBlueprintFor(item); - break; - } - }); - - protected override void AddBlueprintFor(ISkinnableDrawable item) - { - if (!item.IsEditable) - return; - - base.AddBlueprintFor(item); - } - - protected override SelectionHandler CreateSelectionHandler() => new SkinSelectionHandler(); - - protected override SelectionBlueprint CreateBlueprintFor(ISkinnableDrawable component) - => new SkinBlueprint(component); - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - foreach (var list in targetComponents) - list.UnbindAll(); - } - } -} diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs deleted file mode 100644 index 095763de18..0000000000 --- a/osu.Game/Skinning/Editor/SkinEditor.cs +++ /dev/null @@ -1,391 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Events; -using osu.Framework.Testing; -using osu.Game.Database; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Cursor; -using osu.Game.Graphics.UserInterface; -using osu.Game.Overlays; -using osu.Game.Screens.Edit.Components; -using osu.Game.Screens.Edit.Components.Menus; - -namespace osu.Game.Skinning.Editor -{ - [Cached(typeof(SkinEditor))] - public class SkinEditor : VisibilityContainer, ICanAcceptFiles - { - public const double TRANSITION_DURATION = 500; - - public const float MENU_HEIGHT = 40; - - public readonly BindableList SelectedComponents = new BindableList(); - - protected override bool StartHidden => true; - - private Drawable targetScreen; - - private OsuTextFlowContainer headerText; - - private Bindable currentSkin; - - [Resolved(canBeNull: true)] - private OsuGame game { get; set; } - - [Resolved] - private SkinManager skins { get; set; } - - [Resolved] - private OsuColour colours { get; set; } - - [Resolved] - private RealmAccess realm { get; set; } - - [Resolved(canBeNull: true)] - private SkinEditorOverlay skinEditorOverlay { get; set; } - - [Cached] - private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); - - private bool hasBegunMutating; - - private Container content; - - private EditorSidebar componentsSidebar; - private EditorSidebar settingsSidebar; - - public SkinEditor() - { - } - - public SkinEditor(Drawable targetScreen) - { - UpdateTargetScreen(targetScreen); - } - - [BackgroundDependencyLoader] - private void load() - { - RelativeSizeAxes = Axes.Both; - - InternalChild = new OsuContextMenuContainer - { - RelativeSizeAxes = Axes.Both, - Child = new GridContainer - { - RelativeSizeAxes = Axes.Both, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - }, - - Content = new[] - { - new Drawable[] - { - new Container - { - Name = "Menu container", - RelativeSizeAxes = Axes.X, - Depth = float.MinValue, - Height = MENU_HEIGHT, - Children = new Drawable[] - { - new EditorMenuBar - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - RelativeSizeAxes = Axes.Both, - Items = new[] - { - new MenuItem("File") - { - Items = new[] - { - new EditorMenuItem("Save", MenuItemType.Standard, Save), - new EditorMenuItem("Revert to default", MenuItemType.Destructive, revert), - new EditorMenuItemSpacer(), - new EditorMenuItem("Exit", MenuItemType.Standard, () => skinEditorOverlay?.Hide()), - }, - }, - } - }, - headerText = new OsuTextFlowContainer - { - TextAnchor = Anchor.TopRight, - Padding = new MarginPadding(5), - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - }, - }, - }, - }, - new Drawable[] - { - new SkinEditorSceneLibrary - { - RelativeSizeAxes = Axes.X, - }, - }, - new Drawable[] - { - new GridContainer - { - RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(), - new Dimension(GridSizeMode.AutoSize), - }, - Content = new[] - { - new Drawable[] - { - componentsSidebar = new EditorSidebar(), - content = new Container - { - Depth = float.MaxValue, - RelativeSizeAxes = Axes.Both, - }, - settingsSidebar = new EditorSidebar(), - } - } - } - }, - } - } - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - Show(); - - game?.RegisterImportHandler(this); - - // as long as the skin editor is loaded, let's make sure we can modify the current skin. - currentSkin = skins.CurrentSkin.GetBoundCopy(); - - // schedule ensures this only happens when the skin editor is visible. - // also avoid some weird endless recursion / bindable feedback loop (something to do with tracking skins across three different bindable types). - // probably something which will be factored out in a future database refactor so not too concerning for now. - currentSkin.BindValueChanged(skin => - { - hasBegunMutating = false; - Scheduler.AddOnce(skinChanged); - }, true); - - SelectedComponents.BindCollectionChanged((_, __) => Scheduler.AddOnce(populateSettings), true); - } - - public void UpdateTargetScreen(Drawable targetScreen) - { - this.targetScreen = targetScreen; - - SelectedComponents.Clear(); - - // Immediately clear the previous blueprint container to ensure it doesn't try to interact with the old target. - content?.Clear(); - - Scheduler.AddOnce(loadBlueprintContainer); - Scheduler.AddOnce(populateSettings); - - void loadBlueprintContainer() - { - content.Child = new SkinBlueprintContainer(targetScreen); - - componentsSidebar.Child = new SkinComponentToolbox(getFirstTarget() as CompositeDrawable) - { - RequestPlacement = placeComponent - }; - } - } - - private void skinChanged() - { - headerText.Clear(); - - headerText.AddParagraph("Skin editor", cp => cp.Font = OsuFont.Default.With(size: 16)); - headerText.NewParagraph(); - headerText.AddText("Currently editing ", cp => - { - cp.Font = OsuFont.Default.With(size: 12); - cp.Colour = colours.Yellow; - }); - - headerText.AddText($"{currentSkin.Value.SkinInfo}", cp => - { - cp.Font = OsuFont.Default.With(size: 12, weight: FontWeight.Bold); - cp.Colour = colours.Yellow; - }); - - skins.EnsureMutableSkin(); - hasBegunMutating = true; - } - - private void placeComponent(Type type) - { - if (!(Activator.CreateInstance(type) is ISkinnableDrawable component)) - throw new InvalidOperationException($"Attempted to instantiate a component for placement which was not an {typeof(ISkinnableDrawable)}."); - - placeComponent(component); - } - - private void placeComponent(ISkinnableDrawable component, bool applyDefaults = true) - { - var targetContainer = getFirstTarget(); - - if (targetContainer == null) - return; - - var drawableComponent = (Drawable)component; - - if (applyDefaults) - { - // give newly added components a sane starting location. - drawableComponent.Origin = Anchor.TopCentre; - drawableComponent.Anchor = Anchor.TopCentre; - drawableComponent.Y = targetContainer.DrawSize.Y / 2; - } - - targetContainer.Add(component); - - SelectedComponents.Clear(); - SelectedComponents.Add(component); - } - - private void populateSettings() - { - settingsSidebar.Clear(); - - foreach (var component in SelectedComponents.OfType()) - settingsSidebar.Add(new SkinSettingsToolbox(component)); - } - - private IEnumerable availableTargets => targetScreen.ChildrenOfType(); - - private ISkinnableTarget getFirstTarget() => availableTargets.FirstOrDefault(); - - private ISkinnableTarget getTarget(SkinnableTarget target) - { - return availableTargets.FirstOrDefault(c => c.Target == target); - } - - private void revert() - { - ISkinnableTarget[] targetContainers = availableTargets.ToArray(); - - foreach (var t in targetContainers) - { - currentSkin.Value.ResetDrawableTarget(t); - - // add back default components - getTarget(t.Target).Reload(); - } - } - - public void Save() - { - if (!hasBegunMutating) - return; - - ISkinnableTarget[] targetContainers = availableTargets.ToArray(); - - foreach (var t in targetContainers) - currentSkin.Value.UpdateDrawableTarget(t); - - skins.Save(skins.CurrentSkin.Value); - } - - protected override bool OnHover(HoverEvent e) => true; - - protected override bool OnMouseDown(MouseDownEvent e) => true; - - protected override void PopIn() - { - this - // align animation to happen after the majority of the ScalingContainer animation completes. - .Delay(ScalingContainer.TRANSITION_DURATION * 0.3f) - .FadeIn(TRANSITION_DURATION, Easing.OutQuint); - } - - protected override void PopOut() - { - this.FadeOut(TRANSITION_DURATION, Easing.OutQuint); - } - - public void DeleteItems(ISkinnableDrawable[] items) - { - foreach (var item in items) - availableTargets.FirstOrDefault(t => t.Components.Contains(item))?.Remove(item); - } - - #region Drag & drop import handling - - public Task Import(params string[] paths) - { - Schedule(() => - { - var file = new FileInfo(paths.First()); - - // import to skin - currentSkin.Value.SkinInfo.PerformWrite(skinInfo => - { - using (var contents = file.OpenRead()) - skins.AddFile(skinInfo, contents, file.Name); - }); - - // Even though we are 100% on an update thread, we need to wait for realm callbacks to fire (to correctly invalidate caches in RealmBackedResourceStore). - // See https://github.com/realm/realm-dotnet/discussions/2634#discussioncomment-2483573 for further discussion. - // This is the best we can do for now. - realm.Run(r => r.Refresh()); - - // place component - var sprite = new SkinnableSprite - { - SpriteName = { Value = file.Name }, - Origin = Anchor.Centre, - Position = getFirstTarget().ToLocalSpace(GetContainingInputManager().CurrentState.Mouse.Position), - }; - - placeComponent(sprite, false); - - SkinSelectionHandler.ApplyClosestAnchor(sprite); - }); - - return Task.CompletedTask; - } - - public Task Import(params ImportTask[] tasks) => throw new NotImplementedException(); - - public IEnumerable HandledExtensions => new[] { ".jpg", ".jpeg", ".png" }; - - #endregion - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - game?.UnregisterImportHandler(this); - } - } -} diff --git a/osu.Game/Skinning/Editor/SkinSettingsToolbox.cs b/osu.Game/Skinning/Editor/SkinSettingsToolbox.cs deleted file mode 100644 index d2823ed0e4..0000000000 --- a/osu.Game/Skinning/Editor/SkinSettingsToolbox.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Linq; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Configuration; -using osu.Game.Screens.Edit.Components; -using osuTK; - -namespace osu.Game.Skinning.Editor -{ - internal class SkinSettingsToolbox : EditorSidebarSection - { - protected override Container Content { get; } - - public SkinSettingsToolbox(Drawable component) - : base($"Settings ({component.GetType().Name})") - { - base.Content.Add(Content = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(10), - Children = component.CreateSettingsControls().ToArray() - }); - } - } -} diff --git a/osu.Game/Skinning/FontAdjustableSkinComponent.cs b/osu.Game/Skinning/FontAdjustableSkinComponent.cs new file mode 100644 index 0000000000..8f3a1d41c6 --- /dev/null +++ b/osu.Game/Skinning/FontAdjustableSkinComponent.cs @@ -0,0 +1,42 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Localisation.SkinComponents; + +namespace osu.Game.Skinning +{ + /// + /// A skin component that contains text and allows the user to choose its font. + /// + public abstract partial class FontAdjustableSkinComponent : Container, ISerialisableDrawable + { + public bool UsesFixedAnchor { get; set; } + + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Font), nameof(SkinnableComponentStrings.FontDescription))] + public Bindable Font { get; } = new Bindable(Typeface.Torus); + + /// + /// Implement to apply the user font selection to one or more components. + /// + protected abstract void SetFont(FontUsage font); + + protected override void LoadComplete() + { + base.LoadComplete(); + + Font.BindValueChanged(e => + { + // We only have bold weight for venera, so let's force that. + FontWeight fontWeight = e.NewValue == Typeface.Venera ? FontWeight.Bold : FontWeight.Regular; + + FontUsage f = OsuFont.GetFont(e.NewValue, weight: fontWeight); + SetFont(f); + }, true); + } + } +} diff --git a/osu.Game/Skinning/GameplaySkinComponent.cs b/osu.Game/Skinning/GameplaySkinComponent.cs deleted file mode 100644 index 80f6efc07a..0000000000 --- a/osu.Game/Skinning/GameplaySkinComponent.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Linq; - -namespace osu.Game.Skinning -{ - public class GameplaySkinComponent : ISkinComponent - { - public readonly T Component; - - public GameplaySkinComponent(T component) - { - Component = component; - } - - protected virtual string RulesetPrefix => string.Empty; - protected virtual string ComponentName => Component.ToString(); - - public string LookupName => - string.Join('/', new[] { "Gameplay", RulesetPrefix, ComponentName }.Where(s => !string.IsNullOrEmpty(s))); - } -} diff --git a/osu.Game/Skinning/GameplaySkinComponentLookup.cs b/osu.Game/Skinning/GameplaySkinComponentLookup.cs new file mode 100644 index 0000000000..a44bf3a43d --- /dev/null +++ b/osu.Game/Skinning/GameplaySkinComponentLookup.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Skinning +{ + /// + /// A lookup type intended for use for skinnable gameplay components (not HUD level components). + /// + /// + /// The most common usage of this class is for ruleset-specific skinning implementations, but it can also be used directly + /// (see 's usage for ) where ruleset-agnostic elements are required. + /// + /// An enum lookup type. + public class GameplaySkinComponentLookup : ISkinComponentLookup + where T : Enum + { + public readonly T Component; + + public GameplaySkinComponentLookup(T component) + { + Component = component; + } + + protected virtual string RulesetPrefix => string.Empty; + protected virtual string ComponentName => Component.ToString(); + + public string LookupName => + string.Join('/', new[] { "Gameplay", RulesetPrefix, ComponentName }.Where(s => !string.IsNullOrEmpty(s))); + } +} diff --git a/osu.Game/Skinning/HUDSkinComponents.cs b/osu.Game/Skinning/HUDSkinComponents.cs deleted file mode 100644 index ea39c98635..0000000000 --- a/osu.Game/Skinning/HUDSkinComponents.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. - -namespace osu.Game.Skinning -{ - public enum HUDSkinComponents - { - ComboCounter, - ScoreCounter, - AccuracyCounter, - HealthDisplay, - SongProgress, - BarHitErrorMeter, - ColourHitErrorMeter, - } -} diff --git a/osu.Game/Skinning/IAnimationTimeReference.cs b/osu.Game/Skinning/IAnimationTimeReference.cs index f627379a57..b6a944ddf8 100644 --- a/osu.Game/Skinning/IAnimationTimeReference.cs +++ b/osu.Game/Skinning/IAnimationTimeReference.cs @@ -3,7 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Textures; using osu.Framework.Timing; namespace osu.Game.Skinning diff --git a/osu.Game/Skinning/IPooledSampleProvider.cs b/osu.Game/Skinning/IPooledSampleProvider.cs index 40193d1a1a..3ea299f5e2 100644 --- a/osu.Game/Skinning/IPooledSampleProvider.cs +++ b/osu.Game/Skinning/IPooledSampleProvider.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 JetBrains.Annotations; using osu.Game.Audio; namespace osu.Game.Skinning @@ -16,7 +15,6 @@ namespace osu.Game.Skinning /// /// The describing the sample to retrieve. /// The . - [CanBeNull] - PoolableSkinnableSample GetPooledSample(ISampleInfo sampleInfo); + PoolableSkinnableSample? GetPooledSample(ISampleInfo sampleInfo); } } diff --git a/osu.Game/Skinning/ISkinnableDrawable.cs b/osu.Game/Skinning/ISerialisableDrawable.cs similarity index 56% rename from osu.Game/Skinning/ISkinnableDrawable.cs rename to osu.Game/Skinning/ISerialisableDrawable.cs index 3fc6a2fdd8..503b44c2dd 100644 --- a/osu.Game/Skinning/ISkinnableDrawable.cs +++ b/osu.Game/Skinning/ISerialisableDrawable.cs @@ -5,13 +5,21 @@ using System; using osu.Framework.Bindables; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; +using osu.Game.Configuration; namespace osu.Game.Skinning { /// - /// Denotes a drawable which, as a drawable, can be adjusted via skinning specifications. + /// A drawable which is intended to be serialised to . /// - public interface ISkinnableDrawable : IDrawable + /// + /// This is currently used exclusively for serialisation to a skin, and leaned on heavily to allow placement and customisation in the skin layout editor. + /// That said, it is intended to be flexible enough to potentially be used in other places we want to serialise drawables in the future. + /// + /// Attaching this interface to any will make it serialisable via . + /// Adding annotated bindables will also allow serialising settings automatically. + /// + public interface ISerialisableDrawable : IDrawable { /// /// Whether this component should be editable by an end user. @@ -19,8 +27,8 @@ namespace osu.Game.Skinning bool IsEditable => true; /// - /// In the context of the skin layout editor, whether this has a permanent anchor defined. - /// If , this 's is automatically determined by proximity, + /// In the context of the skin layout editor, whether this has a permanent anchor defined. + /// If , this 's is automatically determined by proximity, /// If , a fixed anchor point has been defined. /// bool UsesFixedAnchor { get; set; } diff --git a/osu.Game/Skinning/ISkinnableTarget.cs b/osu.Game/Skinning/ISerialisableDrawableContainer.cs similarity index 54% rename from osu.Game/Skinning/ISkinnableTarget.cs rename to osu.Game/Skinning/ISerialisableDrawableContainer.cs index 8d4f4dd0c3..a19c8c5162 100644 --- a/osu.Game/Skinning/ISkinnableTarget.cs +++ b/osu.Game/Skinning/ISerialisableDrawableContainer.cs @@ -5,47 +5,47 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Game.Extensions; -using osu.Game.Screens.Play.HUD; namespace osu.Game.Skinning { /// - /// Denotes a container which can house s. + /// A container which can house s. + /// Contains functionality for new drawables to be added, removed, and reloaded from provided . /// - public interface ISkinnableTarget : IDrawable + public interface ISerialisableDrawableContainer : IDrawable { - /// - /// The definition of this target. - /// - SkinnableTarget Target { get; } - /// /// A bindable list of components which are being tracked by this skinnable target. /// - IBindableList Components { get; } + IBindableList Components { get; } /// - /// Serialise all children as . + /// Serialise all children as . /// /// The serialised content. - IEnumerable CreateSkinnableInfo() => Components.Select(d => ((Drawable)d).CreateSkinnableInfo()); + IEnumerable CreateSerialisedInfo() => Components.Select(d => ((Drawable)d).CreateSerialisedInfo()); /// /// Reload this target from the current skin. /// void Reload(); + /// + /// Reload this target from the provided skinnable information. + /// + void Reload(SerialisedDrawableInfo[] skinnableInfo); + /// /// Add a new skinnable component to this target. /// /// The component to add. - void Add(ISkinnableDrawable drawable); + void Add(ISerialisableDrawable drawable); /// /// Remove an existing skinnable component from this target. /// /// The component to remove. - public void Remove(ISkinnableDrawable component); + /// Whether removed items should be immediately disposed. + void Remove(ISerialisableDrawable component, bool disposeImmediately); } } diff --git a/osu.Game/Skinning/ISkin.cs b/osu.Game/Skinning/ISkin.cs index 414a316dec..fa04dda202 100644 --- a/osu.Game/Skinning/ISkin.cs +++ b/osu.Game/Skinning/ISkin.cs @@ -1,28 +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 enable - using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Game.Audio; namespace osu.Game.Skinning { /// - /// Provides access to skinnable elements. + /// Provides access to various elements contained by a skin. /// public interface ISkin { /// /// Retrieve a component implementation. /// - /// The requested component. + /// The requested component. /// A drawable representation for the requested component, or null if unavailable. - Drawable? GetDrawableComponent(ISkinComponent component); + Drawable? GetDrawableComponent(ISkinComponentLookup lookup); /// /// Retrieve a . diff --git a/osu.Game/Skinning/ISkinComponentLookup.cs b/osu.Game/Skinning/ISkinComponentLookup.cs new file mode 100644 index 0000000000..25ee086707 --- /dev/null +++ b/osu.Game/Skinning/ISkinComponentLookup.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Skinning +{ + /// + /// The base lookup type to be used with . + /// Should be implemented as necessary to add further criteria to lookups, which are usually consumed by ruleset transformers or legacy lookup cases. + /// + /// + /// Implementations of should match on types implementing this interface + /// to scope particular lookup variations. Using this, a ruleset or skin implementation could make its own lookup + /// type to scope away from more global contexts. + /// + /// More commonly, a ruleset could make use of to do a simple lookup based on + /// a provided enum. + /// + public interface ISkinComponentLookup + { + } +} diff --git a/osu.Game/Skinning/ISkinSource.cs b/osu.Game/Skinning/ISkinSource.cs index a5ed0fc990..0dfc99e3bc 100644 --- a/osu.Game/Skinning/ISkinSource.cs +++ b/osu.Game/Skinning/ISkinSource.cs @@ -3,13 +3,20 @@ using System; using System.Collections.Generic; -using JetBrains.Annotations; namespace osu.Game.Skinning { /// - /// Provides access to skinnable elements. + /// An abstract skin implementation, whose primary purpose is to properly handle component fallback across multiple layers of skins (e.g.: beatmap skin, user skin, default skin). /// + /// + /// Common usage is to do an initial lookup via , and use the returned + /// to do further lookups for related components. + /// + /// The initial lookup is used to lock consecutive lookups to the same underlying skin source (as to not get some elements + /// from one skin and others from another, which would be the case if using methods like + /// directly). + /// public interface ISkinSource : ISkin { /// @@ -22,8 +29,7 @@ namespace osu.Game.Skinning /// This should be used for cases where subsequent lookups (for related components) need to occur on the same skin. /// /// The skin to be used for subsequent lookups, or null if none is available. - [CanBeNull] - ISkin FindProvider(Func lookupFunction); + ISkin? FindProvider(Func lookupFunction); /// /// Retrieve all sources available for lookup, with highest priority source first. diff --git a/osu.Game/Skinning/ISkinTransformer.cs b/osu.Game/Skinning/ISkinTransformer.cs new file mode 100644 index 0000000000..f985b8afcd --- /dev/null +++ b/osu.Game/Skinning/ISkinTransformer.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.Skinning +{ + /// + /// A skin transformer takes in an and applies transformations to it. + /// The most common use case is allowing individual rulesets to add skinnable components without directly coupling to underlying skins. + /// + public interface ISkinTransformer : ISkin + { + /// + /// The original skin that is being transformed. + /// + ISkin Skin { get; } + } +} diff --git a/osu.Game/Skinning/LegacyAccuracyCounter.cs b/osu.Game/Skinning/LegacyAccuracyCounter.cs index bdcb85456a..c99cdba91c 100644 --- a/osu.Game/Skinning/LegacyAccuracyCounter.cs +++ b/osu.Game/Skinning/LegacyAccuracyCounter.cs @@ -8,7 +8,7 @@ using osuTK; namespace osu.Game.Skinning { - public class LegacyAccuracyCounter : GameplayAccuracyCounter, ISkinnableDrawable + public partial class LegacyAccuracyCounter : GameplayAccuracyCounter, ISerialisableDrawable { public bool UsesFixedAnchor { get; set; } diff --git a/osu.Game/Skinning/LegacyBeatmapSkin.cs b/osu.Game/Skinning/LegacyBeatmapSkin.cs index 70f5b35d00..1cbfda16cf 100644 --- a/osu.Game/Skinning/LegacyBeatmapSkin.cs +++ b/osu.Game/Skinning/LegacyBeatmapSkin.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 enable - using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; @@ -45,13 +43,13 @@ namespace osu.Game.Skinning return new RealmBackedResourceStore(beatmapInfo.BeatmapSet.ToLive(resources.RealmAccess), resources.Files, resources.RealmAccess); } - public override Drawable? GetDrawableComponent(ISkinComponent component) + public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) { - if (component is SkinnableTargetComponent targetComponent) + if (lookup is SkinComponentsContainerLookup containerLookup) { - switch (targetComponent.Target) + switch (containerLookup.Target) { - case SkinnableTarget.MainHUDComponents: + case SkinComponentsContainerLookup.TargetArea.MainHUDComponents: // this should exist in LegacySkin instead, but there isn't a fallback skin for LegacySkins yet. // therefore keep the check here until fallback default legacy skin is supported. if (!this.HasFont(LegacyFont.Score)) @@ -61,7 +59,7 @@ namespace osu.Game.Skinning } } - return base.GetDrawableComponent(component); + return base.GetDrawableComponent(lookup); } public override IBindable? GetConfig(TLookup lookup) @@ -98,7 +96,7 @@ namespace osu.Game.Skinning new SkinInfo { Name = beatmapInfo.ToString(), - Creator = beatmapInfo.Metadata.Author.Username ?? string.Empty + Creator = beatmapInfo.Metadata.Author.Username }; } } diff --git a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs b/osu.Game/Skinning/LegacyComboCounter.cs similarity index 98% rename from osu.Game/Screens/Play/HUD/LegacyComboCounter.cs rename to osu.Game/Skinning/LegacyComboCounter.cs index eaca623e39..cd72055fce 100644 --- a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs +++ b/osu.Game/Skinning/LegacyComboCounter.cs @@ -7,15 +7,14 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Game.Rulesets.Scoring; -using osu.Game.Skinning; using osuTK; -namespace osu.Game.Screens.Play.HUD +namespace osu.Game.Skinning { /// /// Uses the 'x' symbol and has a pop-out effect while rolling over. /// - public class LegacyComboCounter : CompositeDrawable, ISkinnableDrawable + public partial class LegacyComboCounter : CompositeDrawable, ISerialisableDrawable { public Bindable Current { get; } = new BindableInt { MinValue = 0 }; @@ -45,7 +44,7 @@ namespace osu.Game.Screens.Play.HUD private readonly Container counterContainer; /// - /// Hides the combo counter internally without affecting its . + /// Hides the combo counter internally without affecting its . /// /// /// This is used for rulesets that provide their own combo counter and don't want this HUD one to be visible, diff --git a/osu.Game/Skinning/LegacyHealthDisplay.cs b/osu.Game/Skinning/LegacyHealthDisplay.cs index b1cd1f86c0..f785022f84 100644 --- a/osu.Game/Skinning/LegacyHealthDisplay.cs +++ b/osu.Game/Skinning/LegacyHealthDisplay.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -17,7 +19,7 @@ using osuTK.Graphics; namespace osu.Game.Skinning { - public class LegacyHealthDisplay : HealthDisplay, ISkinnableDrawable + public partial class LegacyHealthDisplay : HealthDisplay, ISerialisableDrawable { private const double epic_cutoff = 0.5; @@ -92,7 +94,7 @@ namespace osu.Game.Skinning return Color4.White; } - public class LegacyOldStyleMarker : LegacyMarker + public partial class LegacyOldStyleMarker : LegacyMarker { private readonly Texture normalTexture; private readonly Texture dangerTexture; @@ -127,7 +129,7 @@ namespace osu.Game.Skinning } } - public class LegacyNewStyleMarker : LegacyMarker + public partial class LegacyNewStyleMarker : LegacyMarker { private readonly ISkin skin; @@ -151,7 +153,7 @@ namespace osu.Game.Skinning } } - internal abstract class LegacyFill : LegacyHealthPiece + internal abstract partial class LegacyFill : LegacyHealthPiece { protected LegacyFill(ISkin skin) { @@ -173,7 +175,7 @@ namespace osu.Game.Skinning } } - internal class LegacyOldStyleFill : LegacyFill + internal partial class LegacyOldStyleFill : LegacyFill { public LegacyOldStyleFill(ISkin skin) : base(skin) @@ -182,7 +184,7 @@ namespace osu.Game.Skinning } } - internal class LegacyNewStyleFill : LegacyFill + internal partial class LegacyNewStyleFill : LegacyFill { public LegacyNewStyleFill(ISkin skin) : base(skin) @@ -197,7 +199,7 @@ namespace osu.Game.Skinning } } - public abstract class LegacyMarker : LegacyHealthPiece + public abstract partial class LegacyMarker : LegacyHealthPiece { protected Sprite Main; @@ -250,7 +252,7 @@ namespace osu.Game.Skinning Main.ScaleTo(1.4f).Then().ScaleTo(1, 200, Easing.Out); } - public class LegacyHealthPiece : CompositeDrawable + public partial class LegacyHealthPiece : CompositeDrawable { public Bindable Current { get; } = new Bindable(); diff --git a/osu.Game/Skinning/LegacyJudgementPieceNew.cs b/osu.Game/Skinning/LegacyJudgementPieceNew.cs index e76f251ce5..9b1ff9b22f 100644 --- a/osu.Game/Skinning/LegacyJudgementPieceNew.cs +++ b/osu.Game/Skinning/LegacyJudgementPieceNew.cs @@ -6,7 +6,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Textures; -using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; @@ -14,17 +13,17 @@ using osuTK; namespace osu.Game.Skinning { - public class LegacyJudgementPieceNew : CompositeDrawable, IAnimatableJudgement + public partial class LegacyJudgementPieceNew : CompositeDrawable, IAnimatableJudgement { private readonly HitResult result; - private readonly LegacyJudgementPieceOld temporaryOldStyle; + private readonly LegacyJudgementPieceOld? temporaryOldStyle; private readonly Drawable mainPiece; - private readonly ParticleExplosion particles; + private readonly ParticleExplosion? particles; - public LegacyJudgementPieceNew(HitResult result, Func createMainDrawable, Texture particleTexture) + public LegacyJudgementPieceNew(HitResult result, Func createMainDrawable, Texture? particleTexture) { this.result = result; @@ -54,7 +53,7 @@ namespace osu.Game.Skinning if (result != HitResult.Miss) { //new judgement shows old as a temporary effect - AddInternal(temporaryOldStyle = new LegacyJudgementPieceOld(result, createMainDrawable, 1.05f) + AddInternal(temporaryOldStyle = new LegacyJudgementPieceOld(result, createMainDrawable, 1.05f, true) { Blending = BlendingParameters.Additive, Anchor = Anchor.Centre, @@ -100,21 +99,6 @@ namespace osu.Game.Skinning switch (result) { - case HitResult.Miss: - this.ScaleTo(1.6f); - this.ScaleTo(1, 100, Easing.In); - - //todo: this only applies to osu! ruleset apparently. - this.MoveTo(new Vector2(0, -2)); - this.MoveToOffset(new Vector2(0, 20), fade_out_delay + fade_out_length, Easing.In); - - float rotation = RNG.NextSingle(-8.6f, 8.6f); - - this.RotateTo(0); - this.RotateTo(rotation, fade_in_length) - .Then().RotateTo(rotation * 2, fade_out_delay + fade_out_length - fade_in_length, Easing.In); - break; - default: mainPiece.ScaleTo(0.9f); mainPiece.ScaleTo(1.05f, fade_out_delay + fade_out_length); @@ -122,6 +106,6 @@ namespace osu.Game.Skinning } } - public Drawable GetAboveHitObjectsProxiedContent() => temporaryOldStyle?.CreateProxy(); // for new style judgements, only the old style temporary display is in front of objects. + public Drawable? GetAboveHitObjectsProxiedContent() => temporaryOldStyle?.CreateProxy(); // for new style judgements, only the old style temporary display is in front of objects. } } diff --git a/osu.Game/Skinning/LegacyJudgementPieceOld.cs b/osu.Game/Skinning/LegacyJudgementPieceOld.cs index 5d74ab9ae3..082d0e4a67 100644 --- a/osu.Game/Skinning/LegacyJudgementPieceOld.cs +++ b/osu.Game/Skinning/LegacyJudgementPieceOld.cs @@ -2,25 +2,32 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Containers; using osu.Framework.Utils; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; +using osuTK; namespace osu.Game.Skinning { - public class LegacyJudgementPieceOld : CompositeDrawable, IAnimatableJudgement + public partial class LegacyJudgementPieceOld : CompositeDrawable, IAnimatableJudgement { private readonly HitResult result; private readonly float finalScale; + private readonly bool forceTransforms; - public LegacyJudgementPieceOld(HitResult result, Func createMainDrawable, float finalScale = 1f) + [Resolved] + private ISkinSource skin { get; set; } = null!; + + public LegacyJudgementPieceOld(HitResult result, Func createMainDrawable, float finalScale = 1f, bool forceTransforms = false) { this.result = result; this.finalScale = finalScale; + this.forceTransforms = forceTransforms; AutoSizeAxes = Axes.Both; Origin = Anchor.Centre; @@ -41,8 +48,8 @@ namespace osu.Game.Skinning this.FadeInFromZero(fade_in_length); this.Delay(fade_out_delay).FadeOut(fade_out_length); - // legacy judgements don't play any transforms if they are an animation. - if (animation?.FrameCount > 1) + // legacy judgements don't play any transforms if they are an animation.... UNLESS they are the temporary displayed judgement from new piece. + if (animation?.FrameCount > 1 && !forceTransforms) return; switch (result) @@ -51,6 +58,14 @@ namespace osu.Game.Skinning this.ScaleTo(1.6f); this.ScaleTo(1, 100, Easing.In); + decimal? legacyVersion = skin.GetConfig(SkinConfiguration.LegacySetting.Version)?.Value; + + if (legacyVersion >= 2.0m) + { + this.MoveTo(new Vector2(0, -5)); + this.MoveToOffset(new Vector2(0, 80), fade_out_delay + fade_out_length, Easing.In); + } + float rotation = RNG.NextSingle(-8.6f, 8.6f); this.RotateTo(0); @@ -61,11 +76,14 @@ namespace osu.Game.Skinning default: this.ScaleTo(0.6f).Then() - .ScaleTo(1.1f, fade_in_length * 0.8f).Then() - // this is actually correct to match stable; there were overlapping transforms. - .ScaleTo(0.9f).Delay(fade_in_length * 0.2f) - .ScaleTo(1.1f).ScaleTo(0.9f, fade_in_length * 0.2f).Then() - .ScaleTo(0.95f).ScaleTo(finalScale, fade_in_length * 0.2f); + .ScaleTo(1.1f, fade_in_length * 0.8f).Then() // t = 0.8 + .Delay(fade_in_length * 0.2f) // t = 1.0 + .ScaleTo(0.9f, fade_in_length * 0.2f).Then() // t = 1.2 + + // stable dictates scale of 0.9->1 over time 1.0 to 1.4, but we are already at 1.2. + // so we need to force the current value to be correct at 1.2 (0.95) then complete the + // second half of the transform. + .ScaleTo(0.95f).ScaleTo(finalScale, fade_in_length * 0.2f); // t = 1.4 break; } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/KiaiFlashingDrawable.cs b/osu.Game/Skinning/LegacyKiaiFlashingDrawable.cs similarity index 75% rename from osu.Game.Rulesets.Osu/Skinning/Legacy/KiaiFlashingDrawable.cs rename to osu.Game/Skinning/LegacyKiaiFlashingDrawable.cs index 4ee28d05b5..2f79512ed8 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/KiaiFlashingDrawable.cs +++ b/osu.Game/Skinning/LegacyKiaiFlashingDrawable.cs @@ -6,18 +6,23 @@ using osu.Framework.Audio.Track; using osu.Framework.Graphics; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.Containers; +using osuTK.Graphics; -#nullable enable - -namespace osu.Game.Rulesets.Osu.Skinning.Legacy +namespace osu.Game.Skinning { - internal class KiaiFlashingDrawable : BeatSyncedContainer + public partial class LegacyKiaiFlashingDrawable : BeatSyncedContainer { + public Color4 KiaiGlowColour + { + get => flashingDrawable.Colour; + set => flashingDrawable.Colour = value; + } + private readonly Drawable flashingDrawable; private const float flash_opacity = 0.3f; - public KiaiFlashingDrawable(Func creationFunc) + public LegacyKiaiFlashingDrawable(Func creationFunc) { AutoSizeAxes = Axes.Both; @@ -46,7 +51,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy flashingDrawable .FadeTo(flash_opacity) .Then() - .FadeOut(timingPoint.BeatLength * 0.75f); + .FadeOut(Math.Max(80, timingPoint.BeatLength - 80), Easing.OutSine); } } } diff --git a/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs b/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs index 77d390875b..f460a3d31a 100644 --- a/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs +++ b/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -27,6 +29,8 @@ namespace osu.Game.Skinning public Dictionary ImageLookups = new Dictionary(); + public float WidthForNoteHeightScale; + public readonly float[] ColumnLineWidth; public readonly float[] ColumnSpacing; public readonly float[] ColumnWidth; @@ -39,6 +43,8 @@ namespace osu.Game.Skinning public bool ShowJudgementLine = true; public bool KeysUnderNotes; + public LegacyNoteBodyStyle? NoteBodyStyle; + public LegacyManiaSkinConfiguration(int keys) { Keys = keys; @@ -53,12 +59,6 @@ namespace osu.Game.Skinning ColumnWidth.AsSpan().Fill(DEFAULT_COLUMN_SIZE); } - private float? minimumColumnWidth; - - public float MinimumColumnWidth - { - get => minimumColumnWidth ?? ColumnWidth.Min(); - set => minimumColumnWidth = value; - } + public float MinimumColumnWidth => ColumnWidth.Min(); } } diff --git a/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs b/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs index 9db6c8bf66..a2408a92bb 100644 --- a/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs +++ b/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs @@ -3,17 +3,32 @@ namespace osu.Game.Skinning { + /// + /// This class exists for the explicit purpose of ferrying information from ManiaBeatmap in a way LegacySkin can use it. + /// This is because half of the mania legacy skin implementation is in LegacySkin (osu.Game project) which doesn't have visibility + /// over ManiaBeatmap / StageDefinition. + /// public class LegacyManiaSkinConfigurationLookup { - public readonly int Keys; - public readonly LegacyManiaSkinConfigurationLookups Lookup; - public readonly int? TargetColumn; + /// + /// Total columns across all stages. + /// + public readonly int TotalColumns; - public LegacyManiaSkinConfigurationLookup(int keys, LegacyManiaSkinConfigurationLookups lookup, int? targetColumn = null) + /// + /// The column which is being looked up. + /// May be null if the configuration does not apply to a specific column. + /// Note that this is the absolute index across all stages. + /// + public readonly int? ColumnIndex; + + public readonly LegacyManiaSkinConfigurationLookups Lookup; + + public LegacyManiaSkinConfigurationLookup(int totalColumns, LegacyManiaSkinConfigurationLookups lookup, int? columnIndex = null) { - Keys = keys; + TotalColumns = totalColumns; Lookup = lookup; - TargetColumn = targetColumn; + ColumnIndex = columnIndex; } } @@ -27,6 +42,8 @@ namespace osu.Game.Skinning HitPosition, ScorePosition, LightPosition, + StagePaddingTop, + StagePaddingBottom, HitTargetImage, ShowJudgementLine, KeyImage, @@ -37,6 +54,7 @@ namespace osu.Game.Skinning HoldNoteBodyImage, HoldNoteLightImage, HoldNoteLightScale, + WidthForNoteHeightScale, ExplosionImage, ExplosionScale, ColumnLineColour, @@ -54,5 +72,6 @@ namespace osu.Game.Skinning Hit50, Hit0, KeysUnderNotes, + NoteBodyStyle } } diff --git a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs index 7214c847a7..e880e3c1ed 100644 --- a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs +++ b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; @@ -112,22 +114,25 @@ namespace osu.Game.Skinning parseArrayValue(pair.Value, currentConfig.HoldNoteLightWidth); break; - case "WidthForNoteHeightScale": - float minWidth = float.Parse(pair.Value, CultureInfo.InvariantCulture) * LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR; - if (minWidth > 0) - currentConfig.MinimumColumnWidth = minWidth; + case "NoteBodyStyle": + if (Enum.TryParse(pair.Value, out var style)) + currentConfig.NoteBodyStyle = style; break; - case string _ when pair.Key.StartsWith("Colour", StringComparison.Ordinal): - HandleColours(currentConfig, line); + case "WidthForNoteHeightScale": + currentConfig.WidthForNoteHeightScale = (float.Parse(pair.Value, CultureInfo.InvariantCulture)) * LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR; + break; + + case string when pair.Key.StartsWith("Colour", StringComparison.Ordinal): + HandleColours(currentConfig, line, true); break; // Custom sprite paths - case string _ when pair.Key.StartsWith("NoteImage", StringComparison.Ordinal): - case string _ when pair.Key.StartsWith("KeyImage", StringComparison.Ordinal): - case string _ when pair.Key.StartsWith("Hit", StringComparison.Ordinal): - case string _ when pair.Key.StartsWith("Stage", StringComparison.Ordinal): - case string _ when pair.Key.StartsWith("Lighting", StringComparison.Ordinal): + case string when pair.Key.StartsWith("NoteImage", StringComparison.Ordinal): + case string when pair.Key.StartsWith("KeyImage", StringComparison.Ordinal): + case string when pair.Key.StartsWith("Hit", StringComparison.Ordinal): + case string when pair.Key.StartsWith("Stage", StringComparison.Ordinal): + case string when pair.Key.StartsWith("Lighting", StringComparison.Ordinal): currentConfig.ImageLookups[pair.Key] = pair.Value; break; } diff --git a/osu.Game/Skinning/LegacyNoteBodyStyle.cs b/osu.Game/Skinning/LegacyNoteBodyStyle.cs new file mode 100644 index 0000000000..3c1108dcef --- /dev/null +++ b/osu.Game/Skinning/LegacyNoteBodyStyle.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.Skinning +{ + public enum LegacyNoteBodyStyle + { + Stretch = 0, + + // listed as the default on https://osu.ppy.sh/wiki/en/Skinning/skin.ini, but is seemingly not according to the source. + // Repeat = 1, + + RepeatTop = 2, + RepeatBottom = 3, + RepeatTopAndBottom = 4, + } +} diff --git a/osu.Game/Skinning/LegacyRollingCounter.cs b/osu.Game/Skinning/LegacyRollingCounter.cs index b531ae1e6f..8282b2f88b 100644 --- a/osu.Game/Skinning/LegacyRollingCounter.cs +++ b/osu.Game/Skinning/LegacyRollingCounter.cs @@ -10,7 +10,7 @@ namespace osu.Game.Skinning /// /// An integer that uses number sprites from a legacy skin. /// - public class LegacyRollingCounter : RollingCounter + public partial class LegacyRollingCounter : RollingCounter { private readonly LegacyFont font; diff --git a/osu.Game/Skinning/LegacyScoreCounter.cs b/osu.Game/Skinning/LegacyScoreCounter.cs index 0c9a82074f..d8ee6b21de 100644 --- a/osu.Game/Skinning/LegacyScoreCounter.cs +++ b/osu.Game/Skinning/LegacyScoreCounter.cs @@ -8,7 +8,7 @@ using osuTK; namespace osu.Game.Skinning { - public class LegacyScoreCounter : GameplayScoreCounter, ISkinnableDrawable + public partial class LegacyScoreCounter : GameplayScoreCounter, ISerialisableDrawable { protected override double RollingDuration => 1000; protected override Easing RollingEasing => Easing.Out; diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index 9524d3f615..e46eaf90c1 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.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 enable - using System; using System.Collections.Generic; using System.Diagnostics; @@ -11,17 +9,17 @@ using System.Linq; using JetBrains.Annotations; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Game.Audio; using osu.Game.Beatmaps.Formats; using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.IO; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; -using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD.HitErrorMeters; using osuTK.Graphics; @@ -107,7 +105,7 @@ namespace osu.Game.Skinning return SkinUtils.As(GetComboColour(Configuration, comboColour.ColourIndex, comboColour.Combo)); case SkinCustomColourLookup customColour: - return SkinUtils.As(getCustomColour(Configuration, customColour.Lookup.ToString())); + return SkinUtils.As(getCustomColour(Configuration, customColour.Lookup.ToString() ?? string.Empty)); case LegacyManiaSkinConfigurationLookup maniaLookup: if (!AllowManiaSkin) @@ -131,18 +129,22 @@ namespace osu.Game.Skinning private IBindable? lookupForMania(LegacyManiaSkinConfigurationLookup maniaLookup) { - if (!maniaConfigurations.TryGetValue(maniaLookup.Keys, out var existing)) - maniaConfigurations[maniaLookup.Keys] = existing = new LegacyManiaSkinConfiguration(maniaLookup.Keys); + if (!maniaConfigurations.TryGetValue(maniaLookup.TotalColumns, out var existing)) + maniaConfigurations[maniaLookup.TotalColumns] = existing = new LegacyManiaSkinConfiguration(maniaLookup.TotalColumns); switch (maniaLookup.Lookup) { case LegacyManiaSkinConfigurationLookups.ColumnWidth: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.TargetColumn.Value])); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.ColumnIndex.Value])); + + case LegacyManiaSkinConfigurationLookups.WidthForNoteHeightScale: + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(new Bindable(existing.WidthForNoteHeightScale)); case LegacyManiaSkinConfigurationLookups.ColumnSpacing: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(new Bindable(existing.ColumnSpacing[maniaLookup.TargetColumn.Value])); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(new Bindable(existing.ColumnSpacing[maniaLookup.ColumnIndex.Value])); case LegacyManiaSkinConfigurationLookups.HitPosition: return SkinUtils.As(new Bindable(existing.HitPosition)); @@ -160,15 +162,15 @@ namespace osu.Game.Skinning return SkinUtils.As(getManiaImage(existing, "LightingN")); case LegacyManiaSkinConfigurationLookups.ExplosionScale: - Debug.Assert(maniaLookup.TargetColumn != null); + Debug.Assert(maniaLookup.ColumnIndex != null); if (GetConfig(SkinConfiguration.LegacySetting.Version)?.Value < 2.5m) return SkinUtils.As(new Bindable(1)); - if (existing.ExplosionWidth[maniaLookup.TargetColumn.Value] != 0) - return SkinUtils.As(new Bindable(existing.ExplosionWidth[maniaLookup.TargetColumn.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); + if (existing.ExplosionWidth[maniaLookup.ColumnIndex.Value] != 0) + return SkinUtils.As(new Bindable(existing.ExplosionWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); - return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.TargetColumn.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); + return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); case LegacyManiaSkinConfigurationLookups.ColumnLineColour: return SkinUtils.As(getCustomColour(existing, "ColourColumnLine")); @@ -177,53 +179,63 @@ namespace osu.Game.Skinning return SkinUtils.As(getCustomColour(existing, "ColourJudgementLine")); case LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getCustomColour(existing, $"Colour{maniaLookup.TargetColumn + 1}")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getCustomColour(existing, $"Colour{maniaLookup.ColumnIndex + 1}")); case LegacyManiaSkinConfigurationLookups.ColumnLightColour: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getCustomColour(existing, $"ColourLight{maniaLookup.TargetColumn + 1}")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getCustomColour(existing, $"ColourLight{maniaLookup.ColumnIndex + 1}")); case LegacyManiaSkinConfigurationLookups.MinimumColumnWidth: return SkinUtils.As(new Bindable(existing.MinimumColumnWidth)); + case LegacyManiaSkinConfigurationLookups.NoteBodyStyle: + + if (existing.NoteBodyStyle != null) + return SkinUtils.As(new Bindable(existing.NoteBodyStyle.Value)); + + if (GetConfig(SkinConfiguration.LegacySetting.Version)?.Value < 2.5m) + return SkinUtils.As(new Bindable(LegacyNoteBodyStyle.Stretch)); + + return SkinUtils.As(new Bindable(LegacyNoteBodyStyle.RepeatBottom)); + case LegacyManiaSkinConfigurationLookups.NoteImage: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.ColumnIndex}")); case LegacyManiaSkinConfigurationLookups.HoldNoteHeadImage: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}H")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.ColumnIndex}H")); case LegacyManiaSkinConfigurationLookups.HoldNoteTailImage: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}T")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.ColumnIndex}T")); case LegacyManiaSkinConfigurationLookups.HoldNoteBodyImage: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}L")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getManiaImage(existing, $"NoteImage{maniaLookup.ColumnIndex}L")); case LegacyManiaSkinConfigurationLookups.HoldNoteLightImage: return SkinUtils.As(getManiaImage(existing, "LightingL")); case LegacyManiaSkinConfigurationLookups.HoldNoteLightScale: - Debug.Assert(maniaLookup.TargetColumn != null); + Debug.Assert(maniaLookup.ColumnIndex != null); if (GetConfig(SkinConfiguration.LegacySetting.Version)?.Value < 2.5m) return SkinUtils.As(new Bindable(1)); - if (existing.HoldNoteLightWidth[maniaLookup.TargetColumn.Value] != 0) - return SkinUtils.As(new Bindable(existing.HoldNoteLightWidth[maniaLookup.TargetColumn.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); + if (existing.HoldNoteLightWidth[maniaLookup.ColumnIndex.Value] != 0) + return SkinUtils.As(new Bindable(existing.HoldNoteLightWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); - return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.TargetColumn.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); + return SkinUtils.As(new Bindable(existing.ColumnWidth[maniaLookup.ColumnIndex.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); case LegacyManiaSkinConfigurationLookups.KeyImage: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getManiaImage(existing, $"KeyImage{maniaLookup.TargetColumn}")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getManiaImage(existing, $"KeyImage{maniaLookup.ColumnIndex}")); case LegacyManiaSkinConfigurationLookups.KeyImageDown: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(getManiaImage(existing, $"KeyImage{maniaLookup.TargetColumn}D")); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(getManiaImage(existing, $"KeyImage{maniaLookup.ColumnIndex}D")); case LegacyManiaSkinConfigurationLookups.LeftStageImage: return SkinUtils.As(getManiaImage(existing, "StageLeft")); @@ -241,12 +253,12 @@ namespace osu.Game.Skinning return SkinUtils.As(getManiaImage(existing, "StageHint")); case LegacyManiaSkinConfigurationLookups.LeftLineWidth: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.TargetColumn.Value])); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.ColumnIndex.Value])); case LegacyManiaSkinConfigurationLookups.RightLineWidth: - Debug.Assert(maniaLookup.TargetColumn != null); - return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.TargetColumn.Value + 1])); + Debug.Assert(maniaLookup.ColumnIndex != null); + return SkinUtils.As(new Bindable(existing.ColumnLineWidth[maniaLookup.ColumnIndex.Value + 1])); case LegacyManiaSkinConfigurationLookups.Hit0: case LegacyManiaSkinConfigurationLookups.Hit50: @@ -279,7 +291,7 @@ namespace osu.Game.Skinning => source.CustomColours.TryGetValue(lookup, out var col) ? new Bindable(col) : null; private IBindable? getManiaImage(LegacyManiaSkinConfiguration source, string lookup) - => source.ImageLookups.TryGetValue(lookup, out string image) ? new Bindable(image) : null; + => source.ImageLookups.TryGetValue(lookup, out string? image) ? new Bindable(image) : null; private IBindable? legacySettingLookup(SkinConfiguration.LegacySetting legacySetting) where TValue : notnull @@ -300,7 +312,7 @@ namespace osu.Game.Skinning { try { - if (Configuration.ConfigDictionary.TryGetValue(lookup.ToString(), out string val)) + if (Configuration.ConfigDictionary.TryGetValue(lookup.ToString() ?? string.Empty, out string? val)) { // special case for handling skins which use 1 or 0 to signify a boolean state. // ..or in some cases 2 (https://github.com/ppy/osu/issues/18579). @@ -324,29 +336,40 @@ namespace osu.Game.Skinning return null; } - public override Drawable? GetDrawableComponent(ISkinComponent component) + public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) { - if (base.GetDrawableComponent(component) is Drawable c) + if (base.GetDrawableComponent(lookup) is Drawable c) return c; - switch (component) + switch (lookup) { - case SkinnableTargetComponent target: - switch (target.Target) + case SkinComponentsContainerLookup containerLookup: + // Only handle global level defaults for now. + if (containerLookup.Ruleset != null) + return null; + + switch (containerLookup.Target) { - case SkinnableTarget.MainHUDComponents: - var skinnableTargetWrapper = new SkinnableTargetComponentsContainer(container => + case SkinComponentsContainerLookup.TargetArea.MainHUDComponents: + return new DefaultSkinComponentsContainer(container => { var score = container.OfType().FirstOrDefault(); var accuracy = container.OfType().FirstOrDefault(); - var combo = container.OfType().FirstOrDefault(); if (score != null && accuracy != null) { accuracy.Y = container.ToLocalSpace(score.ScreenSpaceDrawQuad.BottomRight).Y; } - var songProgress = container.OfType().FirstOrDefault(); + var songProgress = container.OfType().FirstOrDefault(); + + if (songProgress != null && accuracy != null) + { + songProgress.Anchor = Anchor.TopRight; + songProgress.Origin = Anchor.CentreRight; + songProgress.X = -accuracy.ScreenSpaceDeltaToParentSpace(accuracy.ScreenSpaceDrawQuad.Size).X - 10; + songProgress.Y = container.ToLocalSpace(accuracy.ScreenSpaceDrawQuad.TopLeft).Y + (accuracy.ScreenSpaceDeltaToParentSpace(accuracy.ScreenSpaceDrawQuad.Size).Y / 2); + } var hitError = container.OfType().FirstOrDefault(); @@ -356,12 +379,6 @@ namespace osu.Game.Skinning hitError.Origin = Anchor.CentreLeft; hitError.Rotation = -90; } - - if (songProgress != null) - { - if (hitError != null) hitError.Y -= SongProgress.MAX_HEIGHT; - if (combo != null) combo.Y -= SongProgress.MAX_HEIGHT; - } }) { Children = new Drawable[] @@ -370,39 +387,34 @@ namespace osu.Game.Skinning new LegacyScoreCounter(), new LegacyAccuracyCounter(), new LegacyHealthDisplay(), - new SongProgress(), + new LegacySongProgress(), new BarHitErrorMeter(), } }; - - return skinnableTargetWrapper; } return null; - case GameplaySkinComponent resultComponent: - // TODO: this should be inside the judgement pieces. - Func createDrawable = () => getJudgementAnimation(resultComponent.Component); + case GameplaySkinComponentLookup resultComponent: // kind of wasteful that we throw this away, but should do for now. - if (createDrawable() != null) + if (getJudgementAnimation(resultComponent.Component) != null) { + // TODO: this should be inside the judgement pieces. + Func createDrawable = () => getJudgementAnimation(resultComponent.Component).AsNonNull(); + var particle = getParticleTexture(resultComponent.Component); if (particle != null) return new LegacyJudgementPieceNew(resultComponent.Component, createDrawable, particle); - else - return new LegacyJudgementPieceOld(resultComponent.Component, createDrawable); + + return new LegacyJudgementPieceOld(resultComponent.Component, createDrawable); } return null; - - case SkinnableSprite.SpriteComponent sprite: - return this.GetAnimation(sprite.LookupName, false, false); - - default: - throw new UnsupportedSkinComponentException(component); } + + return null; } private Texture? getParticleTexture(HitResult result) diff --git a/osu.Game/Skinning/LegacySkinDecoder.cs b/osu.Game/Skinning/LegacySkinDecoder.cs index aac343d710..1270f69339 100644 --- a/osu.Game/Skinning/LegacySkinDecoder.cs +++ b/osu.Game/Skinning/LegacySkinDecoder.cs @@ -46,7 +46,7 @@ namespace osu.Game.Skinning // osu!catch section only has colour settings // so no harm in handling the entire section case Section.CatchTheBeat: - HandleColours(skin, line); + HandleColours(skin, line, true); return; } diff --git a/osu.Game/Skinning/LegacySkinExtensions.cs b/osu.Game/Skinning/LegacySkinExtensions.cs index 514a06a4ee..0d2461567f 100644 --- a/osu.Game/Skinning/LegacySkinExtensions.cs +++ b/osu.Game/Skinning/LegacySkinExtensions.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 enable - using System; using System.Collections.Generic; using System.Diagnostics; @@ -11,14 +9,13 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using static osu.Game.Skinning.SkinConfiguration; namespace osu.Game.Skinning { - public static class LegacySkinExtensions + public static partial class LegacySkinExtensions { public static Drawable? GetAnimation(this ISkin? source, string componentName, bool animatable, bool looping, bool applyConfigFrameRate = false, string animationSeparator = "-", bool startAtCurrentTime = true, double? frameLength = null) @@ -149,7 +146,7 @@ namespace osu.Game.Skinning } } - public class SkinnableTextureAnimation : TextureAnimation + public partial class SkinnableTextureAnimation : TextureAnimation { [Resolved(canBeNull: true)] private IAnimationTimeReference? timeReference { get; set; } diff --git a/osu.Game/Skinning/LegacySkinTransformer.cs b/osu.Game/Skinning/LegacySkinTransformer.cs index 34714b9dc5..367e5bae01 100644 --- a/osu.Game/Skinning/LegacySkinTransformer.cs +++ b/osu.Game/Skinning/LegacySkinTransformer.cs @@ -1,13 +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 JetBrains.Annotations; using osu.Framework.Audio.Sample; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Textures; -using osu.Framework.Graphics.Textures; using osu.Game.Audio; using osu.Game.Rulesets.Objects.Legacy; using static osu.Game.Skinning.SkinConfiguration; @@ -17,27 +11,19 @@ namespace osu.Game.Skinning /// /// Transformer used to handle support of legacy features for individual rulesets. /// - public abstract class LegacySkinTransformer : ISkin + public abstract class LegacySkinTransformer : SkinTransformer { /// - /// The which is being transformed. + /// Whether the skin being transformed is able to provide legacy resources for the ruleset. /// - [NotNull] - public ISkin Skin { get; } + public virtual bool IsProvidingLegacyResources => this.HasFont(LegacyFont.Combo); - protected LegacySkinTransformer([NotNull] ISkin skin) + protected LegacySkinTransformer(ISkin skin) + : base(skin) { - Skin = skin ?? throw new ArgumentNullException(nameof(skin)); } - public virtual Drawable GetDrawableComponent(ISkinComponent component) => Skin.GetDrawableComponent(component); - - public Texture GetTexture(string componentName) => GetTexture(componentName, default, default); - - public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) - => Skin.GetTexture(componentName, wrapModeS, wrapModeT); - - public virtual ISample GetSample(ISampleInfo sampleInfo) + public override ISample? GetSample(ISampleInfo sampleInfo) { if (!(sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacySample)) return Skin.GetSample(sampleInfo); @@ -46,9 +32,7 @@ namespace osu.Game.Skinning if (legacySample.IsLayered && playLayeredHitSounds?.Value == false) return new SampleVirtual(); - return Skin.GetSample(sampleInfo); + return base.GetSample(sampleInfo); } - - public virtual IBindable GetConfig(TLookup lookup) => Skin.GetConfig(lookup); } } diff --git a/osu.Game/Skinning/LegacySongProgress.cs b/osu.Game/Skinning/LegacySongProgress.cs new file mode 100644 index 0000000000..22aea99291 --- /dev/null +++ b/osu.Game/Skinning/LegacySongProgress.cs @@ -0,0 +1,81 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Screens.Play.HUD; +using osuTK; + +namespace osu.Game.Skinning +{ + public partial class LegacySongProgress : SongProgress + { + private CircularProgress circularProgress = null!; + + // Legacy song progress doesn't support interaction for now. + public override bool HandleNonPositionalInput => false; + public override bool HandlePositionalInput => false; + + [BackgroundDependencyLoader] + private void load() + { + Size = new Vector2(33); + + InternalChildren = new Drawable[] + { + new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.92f), + Child = circularProgress = new CircularProgress + { + RelativeSizeAxes = Axes.Both, + }, + }, + new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderColour = Colour4.White, + BorderThickness = 2, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + AlwaysPresent = true, + Alpha = 0, + } + }, + new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = Colour4.White, + Size = new Vector2(4), + } + }; + } + + protected override void UpdateProgress(double progress, bool isIntro) + { + if (isIntro) + { + circularProgress.Scale = new Vector2(-1, 1); + circularProgress.Anchor = Anchor.TopRight; + circularProgress.Colour = new Colour4(199, 255, 47, 153); + circularProgress.Current.Value = 1 - progress; + } + else + { + circularProgress.Scale = new Vector2(1); + circularProgress.Anchor = Anchor.TopLeft; + circularProgress.Colour = new Colour4(255, 255, 255, 153); + circularProgress.Current.Value = progress; + } + } + } +} diff --git a/osu.Game/Skinning/LegacySpriteText.cs b/osu.Game/Skinning/LegacySpriteText.cs index 94383834fc..d6af52855b 100644 --- a/osu.Game/Skinning/LegacySpriteText.cs +++ b/osu.Game/Skinning/LegacySpriteText.cs @@ -10,11 +10,11 @@ using osuTK; namespace osu.Game.Skinning { - public sealed class LegacySpriteText : OsuSpriteText + public sealed partial class LegacySpriteText : OsuSpriteText { private readonly LegacyFont font; - private LegacyGlyphStore glyphStore; + private LegacyGlyphStore glyphStore = null!; protected override char FixedWidthReferenceCharacter => '5'; @@ -47,7 +47,7 @@ namespace osu.Game.Skinning this.skin = skin; } - public ITexturedCharacterGlyph Get(string fontName, char character) + public ITexturedCharacterGlyph? Get(string fontName, char character) { string lookup = getLookupName(character); @@ -77,7 +77,7 @@ namespace osu.Game.Skinning } } - public Task GetAsync(string fontName, char character) => Task.Run(() => Get(fontName, character)); + public Task GetAsync(string fontName, char character) => Task.Run(() => Get(fontName, character)); } } } diff --git a/osu.Game/Skinning/MaxDimensionLimitedTextureLoaderStore.cs b/osu.Game/Skinning/MaxDimensionLimitedTextureLoaderStore.cs new file mode 100644 index 0000000000..f15097a169 --- /dev/null +++ b/osu.Game/Skinning/MaxDimensionLimitedTextureLoaderStore.cs @@ -0,0 +1,84 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; + +namespace osu.Game.Skinning +{ + public class MaxDimensionLimitedTextureLoaderStore : IResourceStore + { + private readonly IResourceStore? textureStore; + + public MaxDimensionLimitedTextureLoaderStore(IResourceStore? textureStore) + { + this.textureStore = textureStore; + } + + public void Dispose() + { + textureStore?.Dispose(); + } + + public TextureUpload Get(string name) + { + var textureUpload = textureStore?.Get(name); + + // NRT not enabled on framework side classes (IResourceStore / TextureLoaderStore), welp. + if (textureUpload == null) + return null!; + + return limitTextureUploadSize(textureUpload); + } + + public async Task GetAsync(string name, CancellationToken cancellationToken = new CancellationToken()) + { + // NRT not enabled on framework side classes (IResourceStore / TextureLoaderStore), welp. + if (textureStore == null) + return null!; + + var textureUpload = await textureStore.GetAsync(name, cancellationToken).ConfigureAwait(false); + + if (textureUpload == null) + return null!; + + return await Task.Run(() => limitTextureUploadSize(textureUpload), cancellationToken).ConfigureAwait(false); + } + + private TextureUpload limitTextureUploadSize(TextureUpload textureUpload) + { + // So there's a thing where some users have taken it upon themselves to create skin elements of insane dimensions. + // To the point where GPUs cannot load the textures (along with most image editor apps). + // To work around this, let's look out for any stupid images and shrink them down into a usable size. + const int max_supported_texture_size = 8192; + + if (textureUpload.Height > max_supported_texture_size || textureUpload.Width > max_supported_texture_size) + { + var image = Image.LoadPixelData(textureUpload.Data.ToArray(), textureUpload.Width, textureUpload.Height); + + // The original texture upload will no longer be returned or used. + textureUpload.Dispose(); + + image.Mutate(i => i.Resize(new Size( + Math.Min(textureUpload.Width, max_supported_texture_size), + Math.Min(textureUpload.Height, max_supported_texture_size) + ))); + + return new TextureUpload(image); + } + + return textureUpload; + } + + public Stream? GetStream(string name) => textureStore?.GetStream(name); + + public IEnumerable GetAvailableResources() => textureStore?.GetAvailableResources() ?? Array.Empty(); + } +} diff --git a/osu.Game/Skinning/PausableSkinnableSound.cs b/osu.Game/Skinning/PausableSkinnableSound.cs index b34351d4e7..e752160984 100644 --- a/osu.Game/Skinning/PausableSkinnableSound.cs +++ b/osu.Game/Skinning/PausableSkinnableSound.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; @@ -11,7 +13,7 @@ using osu.Game.Audio; namespace osu.Game.Skinning { - public class PausableSkinnableSound : SkinnableSound + public partial class PausableSkinnableSound : SkinnableSound { public double Length => !DrawableSamples.Any() ? 0 : DrawableSamples.Max(sample => sample.Length); diff --git a/osu.Game/Skinning/PoolableSkinnableSample.cs b/osu.Game/Skinning/PoolableSkinnableSample.cs index 5db4f00b46..76c2c4d7ec 100644 --- a/osu.Game/Skinning/PoolableSkinnableSample.cs +++ b/osu.Game/Skinning/PoolableSkinnableSample.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using JetBrains.Annotations; @@ -17,7 +19,7 @@ namespace osu.Game.Skinning /// /// A sample corresponding to an that supports being pooled and responding to skin changes. /// - public class PoolableSkinnableSample : SkinReloadableDrawable, IAdjustableAudioComponent + public partial class PoolableSkinnableSample : SkinReloadableDrawable, IAdjustableAudioComponent { /// /// The currently-loaded . @@ -67,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); @@ -106,6 +94,8 @@ namespace osu.Game.Skinning private void updateSample() { + clearPreviousSamples(); + if (sampleInfo == null) return; @@ -126,6 +116,8 @@ namespace osu.Game.Skinning /// public void Play() { + FlushPendingSkinChanges(); + if (Sample == null) return; @@ -169,14 +161,6 @@ namespace osu.Game.Skinning } } - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (CurrentSkin != null) - CurrentSkin.SourceChanged -= skinChangedImmediate; - } - #region Re-expose AudioContainer public BindableNumber Volume => sampleContainer.Volume; diff --git a/osu.Game/Skinning/RealmBackedResourceStore.cs b/osu.Game/Skinning/RealmBackedResourceStore.cs index 7fa24284ee..cc887a7a61 100644 --- a/osu.Game/Skinning/RealmBackedResourceStore.cs +++ b/osu.Game/Skinning/RealmBackedResourceStore.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 enable - using System; using System.Collections.Generic; using System.Diagnostics; @@ -54,7 +52,7 @@ namespace osu.Game.Skinning private string? getPathForFile(string filename) { - if (fileToStoragePathMapping.Value.TryGetValue(filename.ToLowerInvariant(), out string path)) + if (fileToStoragePathMapping.Value.TryGetValue(filename.ToLowerInvariant(), out string? path)) return path; return null; diff --git a/osu.Game/Skinning/ResourceStoreBackedSkin.cs b/osu.Game/Skinning/ResourceStoreBackedSkin.cs index 48286bff59..f5c6192ba5 100644 --- a/osu.Game/Skinning/ResourceStoreBackedSkin.cs +++ b/osu.Game/Skinning/ResourceStoreBackedSkin.cs @@ -1,14 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - using System; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Framework.Platform; @@ -26,11 +23,11 @@ namespace osu.Game.Skinning public ResourceStoreBackedSkin(IResourceStore resources, GameHost host, AudioManager audio) { - textures = new TextureStore(host.CreateTextureLoaderStore(new NamespacedResourceStore(resources, @"Textures"))); + textures = new TextureStore(host.Renderer, host.CreateTextureLoaderStore(new NamespacedResourceStore(resources, @"Textures"))); samples = audio.GetSampleStore(new NamespacedResourceStore(resources, @"Samples")); } - public Drawable? GetDrawableComponent(ISkinComponent component) => null; + public Drawable? GetDrawableComponent(ISkinComponentLookup lookup) => null; public Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => textures.Get(componentName, wrapModeS, wrapModeT); diff --git a/osu.Game/Skinning/RulesetSkinProvidingContainer.cs b/osu.Game/Skinning/RulesetSkinProvidingContainer.cs index b884794739..07e238243b 100644 --- a/osu.Game/Skinning/RulesetSkinProvidingContainer.cs +++ b/osu.Game/Skinning/RulesetSkinProvidingContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -21,7 +23,7 @@ namespace osu.Game.Skinning /// A type of specialized for and other gameplay-related components. /// Providing access to parent skin sources and the beatmap skin each surrounded with the ruleset legacy skin transformer. /// - public class RulesetSkinProvidingContainer : SkinProvidingContainer + public partial class RulesetSkinProvidingContainer : SkinProvidingContainer { protected readonly Ruleset Ruleset; protected readonly IBeatmap Beatmap; @@ -39,7 +41,7 @@ namespace osu.Game.Skinning Ruleset = ruleset; Beatmap = beatmap; - InternalChild = new BeatmapSkinProvidingContainer(beatmapSkin is LegacySkin ? GetLegacyRulesetTransformedSkin(beatmapSkin) : beatmapSkin) + InternalChild = new BeatmapSkinProvidingContainer(GetRulesetTransformedSkin(beatmapSkin)) { Child = Content = new Container { @@ -65,21 +67,22 @@ namespace osu.Game.Skinning Debug.Assert(ParentSource != null); - foreach (var skin in ParentSource.AllSources) + foreach (var source in ParentSource.AllSources) { - switch (skin) + switch (source) { - case LegacySkin legacySkin: - sources.Add(GetLegacyRulesetTransformedSkin(legacySkin)); + case Skin skin: + sources.Add(GetRulesetTransformedSkin(skin)); break; default: - sources.Add(skin); + sources.Add(source); break; } } - int lastDefaultSkinIndex = sources.IndexOf(sources.OfType().LastOrDefault()); + // TODO: check + int lastDefaultSkinIndex = sources.IndexOf(sources.OfType().LastOrDefault()); // Ruleset resources should be given the ability to override game-wide defaults // This is achieved by placing them before the last instance of DefaultSkin. @@ -92,16 +95,16 @@ namespace osu.Game.Skinning SetSources(sources); } - protected ISkin GetLegacyRulesetTransformedSkin(ISkin legacySkin) + protected ISkin GetRulesetTransformedSkin(ISkin skin) { - if (legacySkin == null) + if (skin == null) return null; - var rulesetTransformed = Ruleset.CreateLegacySkinProvider(legacySkin, Beatmap); + var rulesetTransformed = Ruleset.CreateSkinTransformer(skin, Beatmap); if (rulesetTransformed != null) return rulesetTransformed; - return legacySkin; + return skin; } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Skinning/SerialisableDrawableExtensions.cs b/osu.Game/Skinning/SerialisableDrawableExtensions.cs new file mode 100644 index 0000000000..51b57a000d --- /dev/null +++ b/osu.Game/Skinning/SerialisableDrawableExtensions.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Configuration; +using osu.Game.Extensions; + +namespace osu.Game.Skinning +{ + public static class SerialisableDrawableExtensions + { + public static SerialisedDrawableInfo CreateSerialisedInfo(this Drawable component) => new SerialisedDrawableInfo(component); + + public static void ApplySerialisedInfo(this Drawable component, SerialisedDrawableInfo drawableInfo) + { + // todo: can probably make this better via deserialisation directly using a common interface. + component.Position = drawableInfo.Position; + component.Rotation = drawableInfo.Rotation; + component.Scale = drawableInfo.Scale; + component.Anchor = drawableInfo.Anchor; + component.Origin = drawableInfo.Origin; + + if (component is ISerialisableDrawable serialisableDrawable) + { + serialisableDrawable.UsesFixedAnchor = drawableInfo.UsesFixedAnchor; + + foreach (var (_, property) in component.GetSettingsSourceProperties()) + { + var bindable = ((IBindable)property.GetValue(component)!); + + if (!drawableInfo.Settings.TryGetValue(property.Name.ToSnakeCase(), out object? settingValue)) + { + // TODO: We probably want to restore default if not included in serialisation information. + // This is not simple to do as SetDefault() is only found in the typed Bindable interface right now. + continue; + } + + serialisableDrawable.CopyAdjustedSetting(bindable, settingValue); + } + } + + if (component is Container container) + { + foreach (var child in drawableInfo.Children) + container.Add(child.CreateInstance()); + } + } + } +} diff --git a/osu.Game/Screens/Play/HUD/SkinnableInfo.cs b/osu.Game/Skinning/SerialisedDrawableInfo.cs similarity index 53% rename from osu.Game/Screens/Play/HUD/SkinnableInfo.cs rename to osu.Game/Skinning/SerialisedDrawableInfo.cs index 1f659fd5bf..c515f228f7 100644 --- a/osu.Game/Screens/Play/HUD/SkinnableInfo.cs +++ b/osu.Game/Skinning/SerialisedDrawableInfo.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using Humanizer; using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -12,18 +11,23 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Game.Configuration; using osu.Game.Extensions; -using osu.Game.Skinning; +using osu.Game.Rulesets; using osuTK; -namespace osu.Game.Screens.Play.HUD +namespace osu.Game.Skinning { /// - /// Serialised information governing custom changes to an . + /// Serialised backing data for s. + /// Used for json serialisation in user skins. /// + /// + /// Can be created using . + /// Can also be applied to an existing drawable using . + /// [Serializable] - public class SkinnableInfo + public sealed class SerialisedDrawableInfo { - public Type Type { get; set; } + public Type Type { get; set; } = null!; public Vector2 Position { get; set; } @@ -35,15 +39,15 @@ namespace osu.Game.Screens.Play.HUD public Anchor Origin { get; set; } - /// + /// public bool UsesFixedAnchor { get; set; } public Dictionary Settings { get; set; } = new Dictionary(); - public List Children { get; } = new List(); + public List Children { get; } = new List(); [JsonConstructor] - public SkinnableInfo() + public SerialisedDrawableInfo() { } @@ -51,7 +55,7 @@ namespace osu.Game.Screens.Play.HUD /// Construct a new instance populating all attributes from the provided drawable. /// /// The drawable which attributes should be sourced from. - public SkinnableInfo(Drawable component) + public SerialisedDrawableInfo(Drawable component) { Type = component.GetType(); @@ -61,21 +65,20 @@ namespace osu.Game.Screens.Play.HUD Anchor = component.Anchor; Origin = component.Origin; - if (component is ISkinnableDrawable skinnable) - UsesFixedAnchor = skinnable.UsesFixedAnchor; + if (component is ISerialisableDrawable serialisableDrawable) + UsesFixedAnchor = serialisableDrawable.UsesFixedAnchor; foreach (var (_, property) in component.GetSettingsSourceProperties()) { - var bindable = (IBindable)property.GetValue(component); + var bindable = (IBindable)property.GetValue(component)!; - if (!bindable.IsDefault) - Settings.Add(property.Name.Underscore(), bindable.GetUnderlyingSettingValue()); + Settings.Add(property.Name.ToSnakeCase(), bindable.GetUnderlyingSettingValue()); } if (component is Container container) { - foreach (var child in container.OfType().OfType()) - Children.Add(child.CreateSkinnableInfo()); + foreach (var child in container.OfType().OfType()) + Children.Add(child.CreateSerialisedInfo()); } } @@ -87,8 +90,8 @@ namespace osu.Game.Screens.Play.HUD { try { - Drawable d = (Drawable)Activator.CreateInstance(Type); - d.ApplySkinnableInfo(this); + Drawable d = (Drawable)Activator.CreateInstance(Type)!; + d.ApplySerialisedInfo(this); return d; } catch (Exception e) @@ -97,5 +100,19 @@ namespace osu.Game.Screens.Play.HUD return Drawable.Empty(); } } + + /// + /// Retrieve all types available which support serialisation. + /// + /// The ruleset to filter results to. If null, global components will be returned instead. + public static Type[] GetAllAvailableDrawables(RulesetInfo? ruleset = null) + { + return (ruleset?.CreateInstance().GetType() ?? typeof(OsuGame)) + .Assembly.GetTypes() + .Where(t => !t.IsInterface && !t.IsAbstract && t.IsPublic) + .Where(t => typeof(ISerialisableDrawable).IsAssignableFrom(t)) + .OrderBy(t => t.Name) + .ToArray(); + } } } diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index b9f9d3bd10..a6250d7488 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.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 enable - using System; using System.Collections.Generic; using System.Diagnostics; @@ -13,14 +11,13 @@ using Newtonsoft.Json; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Framework.Logging; using osu.Game.Audio; using osu.Game.Database; using osu.Game.IO; -using osu.Game.Screens.Play.HUD; namespace osu.Game.Skinning { @@ -40,9 +37,10 @@ namespace osu.Game.Skinning public SkinConfiguration Configuration { get; set; } - public IDictionary DrawableComponentInfo => drawableComponentInfo; + public IDictionary LayoutInfos => layoutInfos; - private readonly Dictionary drawableComponentInfo = new Dictionary(); + private readonly Dictionary layoutInfos = + new Dictionary(); public abstract ISample? GetSample(ISampleInfo sampleInfo); @@ -71,14 +69,19 @@ namespace osu.Game.Skinning storage ??= realmBackedStorage = new RealmBackedResourceStore(SkinInfo, resources.Files, resources.RealmAccess); - (storage as ResourceStore)?.AddExtension("ogg"); - var samples = resources.AudioManager?.GetSampleStore(storage); + if (samples != null) + { samples.PlaybackConcurrency = OsuGameBase.SAMPLE_CONCURRENCY; + // osu-stable performs audio lookups in order of wav -> mp3 -> ogg. + // The GetSampleStore() call above internally adds wav and mp3, so ogg is added at the end to ensure expected ordering. + samples.AddExtension(@"ogg"); + } + Samples = samples; - Textures = new TextureStore(resources.CreateTextureLoaderStore(storage)); + Textures = new TextureStore(resources.Renderer, new MaxDimensionLimitedTextureLoaderStore(resources.CreateTextureLoaderStore(storage))); } else { @@ -98,7 +101,7 @@ namespace osu.Game.Skinning Configuration = new SkinConfiguration(); // skininfo files may be null for default skin. - foreach (SkinnableTarget skinnableTarget in Enum.GetValues(typeof(SkinnableTarget))) + foreach (SkinComponentsContainerLookup.TargetArea skinnableTarget in Enum.GetValues()) { string filename = $"{skinnableTarget}.json"; @@ -110,12 +113,42 @@ namespace osu.Game.Skinning try { string jsonContent = Encoding.UTF8.GetString(bytes); - var deserializedContent = JsonConvert.DeserializeObject>(jsonContent); - if (deserializedContent == null) - continue; + SkinLayoutInfo? layoutInfo = null; - DrawableComponentInfo[skinnableTarget] = deserializedContent.ToArray(); + try + { + // First attempt to deserialise using the new SkinLayoutInfo format + layoutInfo = JsonConvert.DeserializeObject(jsonContent); + } + catch + { + } + + // Of note, the migration code below runs on read of skins, but there's nothing to + // force a rewrite after migration. Let's not remove these migration rules until we + // have something in place to ensure we don't end up breaking skins of users that haven't + // manually saved their skin since a change was implemented. + + // If deserialisation using SkinLayoutInfo fails, attempt to deserialise using the old naked list. + if (layoutInfo == null) + { + // handle namespace changes... + jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.SongProgress", @"osu.Game.Screens.Play.HUD.DefaultSongProgress"); + jsonContent = jsonContent.Replace(@"osu.Game.Screens.Play.HUD.LegacyComboCounter", @"osu.Game.Skinning.LegacyComboCounter"); + + var deserializedContent = JsonConvert.DeserializeObject>(jsonContent); + + if (deserializedContent == null) + continue; + + layoutInfo = new SkinLayoutInfo(); + layoutInfo.Update(null, deserializedContent.ToArray()); + + Logger.Log($"Ferrying {deserializedContent.Count()} components in {skinnableTarget} to global section of new {nameof(SkinLayoutInfo)} format"); + } + + LayoutInfos[skinnableTarget] = layoutInfo; } catch (Exception ex) { @@ -134,36 +167,42 @@ namespace osu.Game.Skinning /// Remove all stored customisations for the provided target. /// /// The target container to reset. - public void ResetDrawableTarget(ISkinnableTarget targetContainer) + public void ResetDrawableTarget(SkinComponentsContainer targetContainer) { - DrawableComponentInfo.Remove(targetContainer.Target); + LayoutInfos.Remove(targetContainer.Lookup.Target); } /// /// Update serialised information for the provided target. /// /// The target container to serialise to this skin. - public void UpdateDrawableTarget(ISkinnableTarget targetContainer) + public void UpdateDrawableTarget(SkinComponentsContainer targetContainer) { - DrawableComponentInfo[targetContainer.Target] = targetContainer.CreateSkinnableInfo().ToArray(); + if (!LayoutInfos.TryGetValue(targetContainer.Lookup.Target, out var layoutInfo)) + layoutInfos[targetContainer.Lookup.Target] = layoutInfo = new SkinLayoutInfo(); + + layoutInfo.Update(targetContainer.Lookup.Ruleset, ((ISerialisableDrawableContainer)targetContainer).CreateSerialisedInfo().ToArray()); } - public virtual Drawable? GetDrawableComponent(ISkinComponent component) + public virtual Drawable? GetDrawableComponent(ISkinComponentLookup lookup) { - switch (component) + switch (lookup) { - case SkinnableTargetComponent target: - if (!DrawableComponentInfo.TryGetValue(target.Target, out var skinnableInfo)) - return null; + // This fallback is important for user skins which use SkinnableSprites. + case SkinnableSprite.SpriteComponentLookup sprite: + return this.GetAnimation(sprite.LookupName, false, false); - var components = new List(); + case SkinComponentsContainerLookup containerLookup: - foreach (var i in skinnableInfo) - components.Add(i.CreateInstance()); + // It is important to return null if the user has not configured this yet. + // This allows skin transformers the opportunity to provide default components. + if (!LayoutInfos.TryGetValue(containerLookup.Target, out var layoutInfo)) return null; + if (!layoutInfo.TryGetDrawableInfo(containerLookup.Ruleset, out var drawableInfos)) return null; - return new SkinnableTargetComponentsContainer + return new Container { - Children = components, + RelativeSizeAxes = Axes.Both, + ChildrenEnumerable = drawableInfos.Select(i => i.CreateInstance()) }; } diff --git a/osu.Game/Skinning/SkinComponentsContainer.cs b/osu.Game/Skinning/SkinComponentsContainer.cs new file mode 100644 index 0000000000..adf0a288b4 --- /dev/null +++ b/osu.Game/Skinning/SkinComponentsContainer.cs @@ -0,0 +1,122 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Skinning +{ + /// + /// A container which holds many skinnable components, with functionality to add, remove and reload layouts. + /// Used to allow user customisation of skin layouts. + /// + /// + /// This is currently used as a means of serialising skin layouts to files. + /// Currently, one json file in a skin will represent one , containing + /// the output of . + /// + public partial class SkinComponentsContainer : SkinReloadableDrawable, ISerialisableDrawableContainer + { + private Container? content; + + /// + /// The lookup criteria which will be used to retrieve components from the active skin. + /// + public SkinComponentsContainerLookup Lookup { get; } + + public IBindableList Components => components; + + private readonly BindableList components = new BindableList(); + + public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; // ensure that components are loaded even if the target container is hidden (ie. due to user toggle). + + public bool ComponentsLoaded { get; private set; } + + private CancellationTokenSource? cancellationSource; + + public SkinComponentsContainer(SkinComponentsContainerLookup lookup) + { + Lookup = lookup; + } + + public void Reload(SerialisedDrawableInfo[] skinnableInfo) + { + var drawables = new List(); + + foreach (var i in skinnableInfo) + drawables.Add(i.CreateInstance()); + + Reload(new Container + { + RelativeSizeAxes = Axes.Both, + Children = drawables, + }); + } + + public void Reload() => Reload(CurrentSkin.GetDrawableComponent(Lookup) as Container); + + public void Reload(Container? componentsContainer) + { + ClearInternal(); + components.Clear(); + ComponentsLoaded = false; + + content = componentsContainer ?? new Container + { + RelativeSizeAxes = Axes.Both + }; + + cancellationSource?.Cancel(); + cancellationSource = null; + + LoadComponentAsync(content, wrapper => + { + AddInternal(wrapper); + components.AddRange(wrapper.Children.OfType()); + ComponentsLoaded = true; + }, (cancellationSource = new CancellationTokenSource()).Token); + } + + /// + /// Thrown when attempting to add an element to a target which is not supported by the current skin. + /// Thrown if the provided instance is not a . + public void Add(ISerialisableDrawable component) + { + if (content == null) + throw new NotSupportedException("Attempting to add a new component to a target container which is not supported by the current skin."); + + if (!(component is Drawable drawable)) + throw new ArgumentException($"Provided argument must be of type {nameof(Drawable)}.", nameof(component)); + + content.Add(drawable); + components.Add(component); + } + + /// + /// Thrown when attempting to add an element to a target which is not supported by the current skin. + /// Thrown if the provided instance is not a . + public void Remove(ISerialisableDrawable component, bool disposeImmediately) + { + if (content == null) + throw new NotSupportedException("Attempting to remove a new component from a target container which is not supported by the current skin."); + + if (!(component is Drawable drawable)) + throw new ArgumentException($"Provided argument must be of type {nameof(Drawable)}.", nameof(component)); + + content.Remove(drawable, disposeImmediately); + components.Remove(component); + } + + protected override void SkinChanged(ISkinSource skin) + { + base.SkinChanged(skin); + + Reload(); + } + } +} diff --git a/osu.Game/Skinning/SkinComponentsContainerLookup.cs b/osu.Game/Skinning/SkinComponentsContainerLookup.cs new file mode 100644 index 0000000000..fbc0ab58ad --- /dev/null +++ b/osu.Game/Skinning/SkinComponentsContainerLookup.cs @@ -0,0 +1,74 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.ComponentModel; +using osu.Framework.Extensions; +using osu.Game.Rulesets; + +namespace osu.Game.Skinning +{ + /// + /// Represents a lookup of a collection of elements that make up a particular skinnable of the game. + /// + public class SkinComponentsContainerLookup : ISkinComponentLookup, IEquatable + { + /// + /// The target area / layer of the game for which skin components will be returned. + /// + public readonly TargetArea Target; + + /// + /// The ruleset for which skin components should be returned. + /// A value means that returned components are global and should be applied for all rulesets. + /// + public readonly RulesetInfo? Ruleset; + + public SkinComponentsContainerLookup(TargetArea target, RulesetInfo? ruleset = null) + { + Target = target; + Ruleset = ruleset; + } + + public override string ToString() + { + if (Ruleset == null) return Target.GetDescription(); + + return $"{Target.GetDescription()} (\"{Ruleset.Name}\" only)"; + } + + public bool Equals(SkinComponentsContainerLookup? other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + + return Target == other.Target && (ReferenceEquals(Ruleset, other.Ruleset) || Ruleset?.Equals(other.Ruleset) == true); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != GetType()) return false; + + return Equals((SkinComponentsContainerLookup)obj); + } + + public override int GetHashCode() + { + return HashCode.Combine((int)Target, Ruleset); + } + + /// + /// Represents a particular area or part of a game screen whose layout can be customised using the skin editor. + /// + public enum TargetArea + { + [Description("HUD")] + MainHUDComponents, + + [Description("Song select")] + SongSelect + } + } +} diff --git a/osu.Game/Skinning/SkinConfiguration.cs b/osu.Game/Skinning/SkinConfiguration.cs index f71f6811e8..937cca0aeb 100644 --- a/osu.Game/Skinning/SkinConfiguration.cs +++ b/osu.Game/Skinning/SkinConfiguration.cs @@ -36,7 +36,8 @@ namespace osu.Game.Skinning HitCirclePrefix, HitCircleOverlap, AnimationFramerate, - LayeredHitSounds + LayeredHitSounds, + AllowSliderBallTint, } public static List DefaultComboColours { get; } = new List @@ -49,7 +50,7 @@ namespace osu.Game.Skinning public List CustomComboColours { get; set; } = new List(); - public IReadOnlyList ComboColours + public IReadOnlyList? ComboColours { get { @@ -63,8 +64,6 @@ namespace osu.Game.Skinning } } - void IHasComboColours.AddComboColours(params Color4[] colours) => CustomComboColours.AddRange(colours); - public Dictionary CustomColours { get; } = new Dictionary(); public readonly Dictionary ConfigDictionary = new Dictionary(); diff --git a/osu.Game/Skinning/SkinFileInfo.cs b/osu.Game/Skinning/SkinFileInfo.cs deleted file mode 100644 index 4f1bf68e51..0000000000 --- a/osu.Game/Skinning/SkinFileInfo.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.ComponentModel.DataAnnotations; -using osu.Game.Database; -using osu.Game.IO; - -namespace osu.Game.Skinning -{ - public class SkinFileInfo : INamedFileInfo, IHasPrimaryKey, INamedFileUsage - { - public int ID { get; set; } - - public bool IsManaged => ID > 0; - - public int SkinInfoID { get; set; } - - public EFSkinInfo SkinInfo { get; set; } - - public int FileInfoID { get; set; } - - public FileInfo FileInfo { get; set; } - - [Required] - public string Filename { get; set; } - - IFileInfo INamedFileUsage.File => FileInfo; - } -} diff --git a/osu.Game/Skinning/SkinModelManager.cs b/osu.Game/Skinning/SkinImporter.cs similarity index 70% rename from osu.Game/Skinning/SkinModelManager.cs rename to osu.Game/Skinning/SkinImporter.cs index 23813e8eb2..43760c4a19 100644 --- a/osu.Game/Skinning/SkinModelManager.cs +++ b/osu.Game/Skinning/SkinImporter.cs @@ -4,53 +4,48 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; using System.Text; using System.Threading; using Newtonsoft.Json; -using osu.Framework.Logging; using osu.Framework.Platform; +using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Extensions; using osu.Game.IO; using osu.Game.IO.Archives; -using osu.Game.Stores; using Realms; -#nullable enable - namespace osu.Game.Skinning { - public class SkinModelManager : RealmArchiveModelManager + public class SkinImporter : RealmArchiveModelImporter { private const string skin_info_file = "skininfo.json"; private readonly IStorageResourceProvider skinResources; - public SkinModelManager(Storage storage, RealmAccess realm, IStorageResourceProvider skinResources) + private readonly ModelManager modelManager; + + public SkinImporter(Storage storage, RealmAccess realm, IStorageResourceProvider skinResources) : base(storage, realm) { this.skinResources = skinResources; - // can be removed 20220420. - populateMissingHashes(); + modelManager = new ModelManager(storage, realm); } public override IEnumerable HandledExtensions => new[] { ".osk" }; protected override string[] HashableFileTypes => new[] { ".ini", ".json" }; - protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path)?.ToLowerInvariant() == @".osk"; + protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path).ToLowerInvariant() == @".osk"; protected override SkinInfo CreateModel(ArchiveReader archive) => new SkinInfo { Name = archive.Name ?? @"No name" }; private const string unknown_creator_string = @"Unknown"; - protected override bool HasCustomHashFunction => true; - protected override void Populate(SkinInfo model, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default) { - var skinInfoFile = model.Files.SingleOrDefault(f => f.Filename == skin_info_file); + var skinInfoFile = model.GetFile(skin_info_file); if (skinInfoFile != null) { @@ -106,7 +101,8 @@ namespace osu.Game.Skinning // In both of these cases, the expectation from the user is that the filename or folder name is displayed somewhere to identify the skin. if (archiveName != item.Name // lazer exports use this format - && archiveName != item.GetDisplayString()) + // GetValidFilename accounts for skins with non-ASCII characters in the name that have been exported by lazer. + && archiveName != item.GetDisplayString().GetValidFilename()) item.Name = @$"{item.Name} [{archiveName}]"; } @@ -130,7 +126,7 @@ namespace osu.Game.Skinning authorLine, }; - var existingFile = item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase)); + var existingFile = item.GetFile(@"skin.ini"); if (existingFile == null) { @@ -157,19 +153,7 @@ namespace osu.Game.Skinning sw.WriteLine(line); } - ReplaceFile(existingFile, stream, realm); - - // can be removed 20220502. - if (!ensureIniWasUpdated(item)) - { - Logger.Log($"Skin {item}'s skin.ini had issues and has been removed. Please report this and provide the problematic skin.", LoggingTarget.Database, LogLevel.Important); - - var existingIni = item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase)); - if (existingIni != null) - item.Files.Remove(existingIni); - - writeNewSkinIni(); - } + modelManager.ReplaceFile(existingFile, stream, realm); } } @@ -187,61 +171,38 @@ namespace osu.Game.Skinning sw.WriteLine(line); } - AddFile(item, stream, @"skin.ini", realm); + modelManager.AddFile(item, stream, @"skin.ini", realm); } item.Hash = ComputeHash(item); } } - private bool ensureIniWasUpdated(SkinInfo item) - { - // This is a final consistency check to ensure that hash computation doesn't enter an infinite loop. - // With other changes to the surrounding code this should never be hit, but until we are 101% sure that there - // are no other cases let's avoid a hard startup crash by bailing and alerting. - - var instance = createInstance(item); - - return instance.Configuration.SkinInfo.Name == item.Name; - } - - private void populateMissingHashes() - { - Realm.Run(realm => - { - var skinsWithoutHashes = realm.All().Where(i => !i.Protected && string.IsNullOrEmpty(i.Hash)).ToArray(); - - foreach (SkinInfo skin in skinsWithoutHashes) - { - try - { - realm.Write(r => skin.Hash = ComputeHash(skin)); - } - catch (Exception e) - { - Delete(skin); - Logger.Error(e, $"Existing skin {skin} has been deleted during hash recomputation due to being invalid"); - } - } - }); - } - private Skin createInstance(SkinInfo item) => item.CreateInstance(skinResources); - public void Save(Skin skin) + /// + /// Save a skin, serialising any changes to skin layouts to relevant JSON structures. + /// + /// Whether any change actually occurred. + public bool Save(Skin skin) { + bool hadChanges = false; + skin.SkinInfo.PerformWrite(s => { + // Update for safety + s.InstantiationInfo = skin.GetType().GetInvariantInstantiationInfo(); + // Serialise out the SkinInfo itself. string skinInfoJson = JsonConvert.SerializeObject(s, new JsonSerializerSettings { Formatting = Formatting.Indented }); using (var streamContent = new MemoryStream(Encoding.UTF8.GetBytes(skinInfoJson))) { - AddFile(s, streamContent, skin_info_file, s.Realm); + modelManager.AddFile(s, streamContent, skin_info_file, s.Realm); } // Then serialise each of the drawable component groups into respective files. - foreach (var drawableInfo in skin.DrawableComponentInfo) + foreach (var drawableInfo in skin.LayoutInfos) { string json = JsonConvert.SerializeObject(drawableInfo.Value, new JsonSerializerSettings { Formatting = Formatting.Indented }); @@ -249,19 +210,23 @@ namespace osu.Game.Skinning { string filename = @$"{drawableInfo.Key}.json"; - var oldFile = s.Files.FirstOrDefault(f => f.Filename == filename); + var oldFile = s.GetFile(filename); if (oldFile != null) - ReplaceFile(oldFile, streamContent, s.Realm); + modelManager.ReplaceFile(oldFile, streamContent, s.Realm); else - AddFile(s, streamContent, filename, s.Realm); + modelManager.AddFile(s, streamContent, filename, s.Realm); } } - s.Hash = ComputeHash(s); - }); - } + string newHash = ComputeHash(s); - public override bool IsAvailableLocally(SkinInfo model) => true; // skins do not have online download support yet. + hadChanges = newHash != s.Hash; + + s.Hash = newHash; + }); + + return hadChanges; + } } } diff --git a/osu.Game/Skinning/SkinInfo.cs b/osu.Game/Skinning/SkinInfo.cs index a89725e466..9ad91f8725 100644 --- a/osu.Game/Skinning/SkinInfo.cs +++ b/osu.Game/Skinning/SkinInfo.cs @@ -5,15 +5,12 @@ using System; using System.Collections.Generic; using JetBrains.Annotations; using Newtonsoft.Json; -using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Testing; using osu.Game.Database; using osu.Game.IO; using osu.Game.Models; using Realms; -#nullable enable - namespace osu.Game.Skinning { [ExcludeFromDynamicCompile] @@ -21,7 +18,9 @@ namespace osu.Game.Skinning [JsonObject(MemberSerialization.OptIn)] public class SkinInfo : RealmObject, IHasRealmFiles, IEquatable, IHasGuidPrimaryKey, ISoftDelete, IHasNamedFiles { - internal static readonly Guid DEFAULT_SKIN = new Guid("2991CFD8-2140-469A-BCB9-2EC23FBCE4AD"); + internal static readonly Guid TRIANGLES_SKIN = new Guid("2991CFD8-2140-469A-BCB9-2EC23FBCE4AD"); + internal static readonly Guid ARGON_SKIN = new Guid("CFFA69DE-B3E3-4DEE-8563-3C4F425C05D0"); + internal static readonly Guid ARGON_PRO_SKIN = new Guid("9FC9CF5D-0F16-4C71-8256-98868321AC43"); internal static readonly Guid CLASSIC_SKIN = new Guid("81F02CD3-EEC6-4865-AC23-FAE26A386187"); internal static readonly Guid RANDOM_SKIN = new Guid("D39DFEFB-477C-4372-B1EA-2BCEA5FB8908"); @@ -47,9 +46,18 @@ namespace osu.Game.Skinning var type = string.IsNullOrEmpty(InstantiationInfo) // handle the case of skins imported before InstantiationInfo was added. ? typeof(LegacySkin) - : Type.GetType(InstantiationInfo).AsNonNull(); + : Type.GetType(InstantiationInfo); - return (Skin)Activator.CreateInstance(type, this, resources); + if (type == null) + { + // Since the class was renamed from "DefaultSkin" to "TrianglesSkin", the type retrieval would fail + // for user modified skins. This aims to amicably handle that. + // If we ever add more default skins in the future this will need some kind of proper migration rather than + // a single fallback. + return new TrianglesSkin(this, resources); + } + + return (Skin)Activator.CreateInstance(type, this, resources)!; } public IList Files { get; } = null!; diff --git a/osu.Game/Skinning/SkinLayoutInfo.cs b/osu.Game/Skinning/SkinLayoutInfo.cs new file mode 100644 index 0000000000..115d59b9d0 --- /dev/null +++ b/osu.Game/Skinning/SkinLayoutInfo.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Newtonsoft.Json; +using osu.Game.Rulesets; + +namespace osu.Game.Skinning +{ + /// + /// A serialisable model describing layout of a . + /// May contain multiple configurations for different rulesets, each of which should manifest their own as required. + /// + [Serializable] + public class SkinLayoutInfo + { + private const string global_identifier = @"global"; + + [JsonIgnore] + public IEnumerable AllDrawables => DrawableInfo.Values.SelectMany(v => v); + + [JsonProperty] + public Dictionary DrawableInfo { get; set; } = new Dictionary(); + + public bool TryGetDrawableInfo(RulesetInfo? ruleset, [NotNullWhen(true)] out SerialisedDrawableInfo[]? components) => + DrawableInfo.TryGetValue(ruleset?.ShortName ?? global_identifier, out components); + + public void Reset(RulesetInfo? ruleset) => + DrawableInfo.Remove(ruleset?.ShortName ?? global_identifier); + + public void Update(RulesetInfo? ruleset, SerialisedDrawableInfo[] components) => + DrawableInfo[ruleset?.ShortName ?? global_identifier] = components; + } +} diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index 5e1902f520..fca7dc0f5e 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -1,9 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Linq.Expressions; using System.Threading; @@ -13,7 +14,7 @@ using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Framework.Platform; @@ -23,8 +24,6 @@ using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Database; using osu.Game.IO; -using osu.Game.IO.Archives; -using osu.Game.Models; using osu.Game.Overlays.Notifications; using osu.Game.Utils; @@ -38,8 +37,13 @@ namespace osu.Game.Skinning /// For gameplay components, see which adds extra legacy and toggle logic that may affect the lookup process. /// [ExcludeFromDynamicCompile] - public class SkinManager : ISkinSource, IStorageResourceProvider, IModelImporter, IModelManager, IModelFileManager + public class SkinManager : ModelManager, ISkinSource, IStorageResourceProvider, IModelImporter { + /// + /// The default "classic" skin. + /// + public Skin DefaultClassicSkin { get; } + private readonly AudioManager audio; private readonly Scheduler scheduler; @@ -50,29 +54,29 @@ namespace osu.Game.Skinning public readonly Bindable CurrentSkin = new Bindable(); - public readonly Bindable> CurrentSkinInfo = new Bindable>(Skinning.DefaultSkin.CreateInfo().ToLiveUnmanaged()) - { - Default = Skinning.DefaultSkin.CreateInfo().ToLiveUnmanaged() - }; + public readonly Bindable> CurrentSkinInfo = new Bindable>(ArgonSkin.CreateInfo().ToLiveUnmanaged()); - private readonly SkinModelManager skinModelManager; - private readonly RealmAccess realm; + private readonly SkinImporter skinImporter; private readonly IResourceStore userFiles; - /// - /// The default skin. - /// - public Skin DefaultSkin { get; } + private Skin argonSkin { get; } - /// - /// The default legacy skin. - /// - public Skin DefaultLegacySkin { get; } + private Skin trianglesSkin { get; } + + public override bool PauseImports + { + get => base.PauseImports; + set + { + base.PauseImports = value; + skinImporter.PauseImports = value; + } + } public SkinManager(Storage storage, RealmAccess realm, GameHost host, IResourceStore resources, AudioManager audio, Scheduler scheduler) + : base(storage, realm) { - this.realm = realm; this.audio = audio; this.scheduler = scheduler; this.host = host; @@ -80,12 +84,17 @@ namespace osu.Game.Skinning userFiles = new StorageBackedResourceStore(storage.GetStorageForDirectory("files")); - skinModelManager = new SkinModelManager(storage, realm, this); + skinImporter = new SkinImporter(storage, realm, this) + { + PostNotification = obj => PostNotification?.Invoke(obj), + }; var defaultSkins = new[] { - DefaultLegacySkin = new DefaultLegacySkin(this), - DefaultSkin = new DefaultSkin(this), + DefaultClassicSkin = new DefaultLegacySkin(this), + trianglesSkin = new TrianglesSkin(this), + argonSkin = new ArgonSkin(this), + new ArgonProSkin(this), }; // Ensure the default entries are present. @@ -103,7 +112,7 @@ namespace osu.Game.Skinning CurrentSkin.Value = skin.NewValue.PerformRead(GetSkin); }; - CurrentSkin.Value = DefaultSkin; + CurrentSkin.Value = argonSkin; CurrentSkin.ValueChanged += skin => { if (!skin.NewValue.SkinInfo.Equals(CurrentSkinInfo.Value)) @@ -115,20 +124,22 @@ namespace osu.Game.Skinning public void SelectRandomSkin() { - realm.Run(r => + Realm.Run(r => { // choose from only user skins, removing the current selection to ensure a new one is chosen. - var randomChoices = r.All().Where(s => !s.DeletePending && s.ID != CurrentSkinInfo.Value.ID).ToArray(); + var randomChoices = r.All() + .Where(s => !s.DeletePending && s.ID != CurrentSkinInfo.Value.ID) + .ToArray(); if (randomChoices.Length == 0) { - CurrentSkinInfo.Value = Skinning.DefaultSkin.CreateInfo().ToLiveUnmanaged(); + CurrentSkinInfo.Value = ArgonSkin.CreateInfo().ToLiveUnmanaged(); return; } var chosen = randomChoices.ElementAt(RNG.Next(0, randomChoices.Length)); - CurrentSkinInfo.Value = chosen.ToLive(realm); + CurrentSkinInfo.Value = chosen.ToLive(Realm); }); } @@ -153,7 +164,7 @@ namespace osu.Game.Skinning if (!s.Protected) return false; - string[] existingSkinNames = realm.Run(r => r.All() + string[] existingSkinNames = Realm.Run(r => r.All() .Where(skin => !skin.DeletePending) .AsEnumerable() .Select(skin => skin.Name).ToArray()); @@ -166,7 +177,7 @@ namespace osu.Game.Skinning Name = NamingUtils.GetNextBestName(existingSkinNames, $@"{s.Name} (modified)") }; - var result = skinModelManager.Import(skinInfo); + var result = skinImporter.ImportModel(skinInfo); if (result != null) { @@ -181,12 +192,16 @@ namespace osu.Game.Skinning }); } - public void Save(Skin skin) + /// + /// Save a skin, serialising any changes to skin layouts to relevant JSON structures. + /// + /// Whether any change actually occurred. + public bool Save(Skin skin) { if (!skin.SkinInfo.IsManaged) throw new InvalidOperationException($"Attempting to save a skin which is not yet tracked. Call {nameof(EnsureMutableSkin)} first."); - skinModelManager.Save(skin); + return skinImporter.Save(skin); } /// @@ -196,12 +211,12 @@ namespace osu.Game.Skinning /// The first result for the provided query, or null if no results were found. public Live Query(Expression> query) { - return realm.Run(r => r.All().FirstOrDefault(query)?.ToLive(realm)); + return Realm.Run(r => r.All().FirstOrDefault(query)?.ToLive(Realm)); } public event Action SourceChanged; - public Drawable GetDrawableComponent(ISkinComponent component) => lookupWithFallback(s => s.GetDrawableComponent(component)); + public Drawable GetDrawableComponent(ISkinComponentLookup lookup) => lookupWithFallback(s => s.GetDrawableComponent(lookup)); public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => lookupWithFallback(s => s.GetTexture(componentName, wrapModeS, wrapModeT)); @@ -226,11 +241,15 @@ namespace osu.Game.Skinning { yield return CurrentSkin.Value; - if (CurrentSkin.Value is LegacySkin && CurrentSkin.Value != DefaultLegacySkin) - yield return DefaultLegacySkin; + // Skin manager provides default fallbacks. + // This handles cases where a user skin doesn't have the required resources for complete display of + // certain elements. - if (CurrentSkin.Value != DefaultSkin) - yield return DefaultSkin; + if (CurrentSkin.Value is LegacySkin && CurrentSkin.Value != DefaultClassicSkin) + yield return DefaultClassicSkin; + + if (CurrentSkin.Value != trianglesSkin) + yield return trianglesSkin; } } @@ -248,65 +267,42 @@ namespace osu.Game.Skinning #region IResourceStorageProvider + IRenderer IStorageResourceProvider.Renderer => host.Renderer; AudioManager IStorageResourceProvider.AudioManager => audio; IResourceStore IStorageResourceProvider.Resources => resources; IResourceStore IStorageResourceProvider.Files => userFiles; - RealmAccess IStorageResourceProvider.RealmAccess => realm; + RealmAccess IStorageResourceProvider.RealmAccess => Realm; IResourceStore IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore underlyingStore) => host.CreateTextureLoaderStore(underlyingStore); #endregion #region Implementation of IModelImporter - public Action PostNotification + public Action>> PresentImport { - set => skinModelManager.PostNotification = value; + set => skinImporter.PresentImport = value; } - public Action>> PostImport - { - set => skinModelManager.PostImport = value; - } + public Task Import(params string[] paths) => skinImporter.Import(paths); - public Task Import(params string[] paths) - { - return skinModelManager.Import(paths); - } + public Task Import(ImportTask[] imports, ImportParameters parameters = default) => skinImporter.Import(imports, parameters); - public Task Import(params ImportTask[] tasks) - { - return skinModelManager.Import(tasks); - } + public IEnumerable HandledExtensions => skinImporter.HandledExtensions; - public IEnumerable HandledExtensions => skinModelManager.HandledExtensions; + public Task>> Import(ProgressNotification notification, ImportTask[] tasks, ImportParameters parameters = default) => + skinImporter.Import(notification, tasks, parameters); - public Task>> Import(ProgressNotification notification, params ImportTask[] tasks) - { - return skinModelManager.Import(notification, tasks); - } + public Task> ImportAsUpdate(ProgressNotification notification, ImportTask task, SkinInfo original) => + skinImporter.ImportAsUpdate(notification, task, original); - public Task> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default) - { - return skinModelManager.Import(task, lowPriority, cancellationToken); - } - - public Task> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default) - { - return skinModelManager.Import(archive, lowPriority, cancellationToken); - } - - public Live Import(SkinInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) - { - return skinModelManager.Import(item, archive, lowPriority, cancellationToken); - } + public Task> Import(ImportTask task, ImportParameters parameters = default, CancellationToken cancellationToken = default) => + skinImporter.Import(task, parameters, cancellationToken); #endregion - #region Implementation of IModelManager - public void Delete([CanBeNull] Expression> filter = null, bool silent = false) { - realm.Run(r => + Realm.Run(r => { var items = r.All() .Where(s => !s.Protected && !s.DeletePending); @@ -317,52 +313,26 @@ namespace osu.Game.Skinning Guid currentUserSkin = CurrentSkinInfo.Value.ID; if (items.Any(s => s.ID == currentUserSkin)) - scheduler.Add(() => CurrentSkinInfo.Value = Skinning.DefaultSkin.CreateInfo().ToLiveUnmanaged()); + scheduler.Add(() => CurrentSkinInfo.Value = ArgonSkin.CreateInfo().ToLiveUnmanaged()); - skinModelManager.Delete(items.ToList(), silent); + Delete(items.ToList(), silent); }); } - #endregion - - public bool Delete(SkinInfo item) + public void SetSkinFromConfiguration(string guidString) { - return skinModelManager.Delete(item); - } + Live skinInfo = null; - public void Delete(List items, bool silent = false) - { - skinModelManager.Delete(items, silent); - } + if (Guid.TryParse(guidString, out var guid)) + skinInfo = Query(s => s.ID == guid); - public void Undelete(List items, bool silent = false) - { - skinModelManager.Undelete(items, silent); - } + if (skinInfo == null) + { + if (guid == SkinInfo.CLASSIC_SKIN) + skinInfo = DefaultClassicSkin.SkinInfo; + } - public void Undelete(SkinInfo item) - { - skinModelManager.Undelete(item); - } - - public bool IsAvailableLocally(SkinInfo model) - { - return skinModelManager.IsAvailableLocally(model); - } - - public void ReplaceFile(SkinInfo model, RealmNamedFileUsage file, Stream contents) - { - skinModelManager.ReplaceFile(model, file, contents); - } - - public void DeleteFile(SkinInfo model, RealmNamedFileUsage file) - { - skinModelManager.DeleteFile(model, file); - } - - public void AddFile(SkinInfo model, Stream contents, string filename) - { - skinModelManager.AddFile(model, contents, filename); + CurrentSkinInfo.Value = skinInfo ?? trianglesSkin.SkinInfo; } } } diff --git a/osu.Game/Skinning/SkinProvidingContainer.cs b/osu.Game/Skinning/SkinProvidingContainer.cs index 24198254a3..2612e0b47c 100644 --- a/osu.Game/Skinning/SkinProvidingContainer.cs +++ b/osu.Game/Skinning/SkinProvidingContainer.cs @@ -4,34 +4,36 @@ using System; using System.Collections.Generic; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Game.Audio; namespace osu.Game.Skinning { /// - /// A container which adds a local to the hierarchy. + /// A container which adds a provided to the DI skin lookup hierarchy. /// - public class SkinProvidingContainer : Container, ISkinSource + /// + /// This container will expose an to its children. + /// The source will first consider the skin provided via the constructor (if any), then fallback + /// to any providers in the parent DI hierarchy. + /// + public partial class SkinProvidingContainer : Container, ISkinSource { - public event Action SourceChanged; + public event Action? SourceChanged; - [CanBeNull] - protected ISkinSource ParentSource { get; private set; } + protected ISkinSource? ParentSource { get; private set; } /// /// Whether falling back to parent s is allowed in this container. /// protected virtual bool AllowFallingBackToParent => true; - protected virtual bool AllowDrawableLookup(ISkinComponent component) => true; + protected virtual bool AllowDrawableLookup(ISkinComponentLookup lookup) => true; protected virtual bool AllowTextureLookup(string componentName) => true; @@ -51,7 +53,7 @@ namespace osu.Game.Skinning /// /// Constructs a new initialised with a single skin source. /// - public SkinProvidingContainer([CanBeNull] ISkin skin) + public SkinProvidingContainer(ISkin? skin) : this() { if (skin != null) @@ -81,7 +83,7 @@ namespace osu.Game.Skinning return dependencies; } - public ISkin FindProvider(Func lookupFunction) + public ISkin? FindProvider(Func lookupFunction) { foreach (var (skin, lookupWrapper) in skinSources) { @@ -110,26 +112,26 @@ namespace osu.Game.Skinning } } - public Drawable GetDrawableComponent(ISkinComponent component) + public Drawable? GetDrawableComponent(ISkinComponentLookup lookup) { foreach (var (_, lookupWrapper) in skinSources) { - Drawable sourceDrawable; - if ((sourceDrawable = lookupWrapper.GetDrawableComponent(component)) != null) + Drawable? sourceDrawable; + if ((sourceDrawable = lookupWrapper.GetDrawableComponent(lookup)) != null) return sourceDrawable; } if (!AllowFallingBackToParent) return null; - return ParentSource?.GetDrawableComponent(component); + return ParentSource?.GetDrawableComponent(lookup); } - public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) + public Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) { foreach (var (_, lookupWrapper) in skinSources) { - Texture sourceTexture; + Texture? sourceTexture; if ((sourceTexture = lookupWrapper.GetTexture(componentName, wrapModeS, wrapModeT)) != null) return sourceTexture; } @@ -140,11 +142,11 @@ namespace osu.Game.Skinning return ParentSource?.GetTexture(componentName, wrapModeS, wrapModeT); } - public ISample GetSample(ISampleInfo sampleInfo) + public ISample? GetSample(ISampleInfo sampleInfo) { foreach (var (_, lookupWrapper) in skinSources) { - ISample sourceSample; + ISample? sourceSample; if ((sourceSample = lookupWrapper.GetSample(sampleInfo)) != null) return sourceSample; } @@ -155,11 +157,13 @@ namespace osu.Game.Skinning return ParentSource?.GetSample(sampleInfo); } - public IBindable GetConfig(TLookup lookup) + public IBindable? GetConfig(TLookup lookup) + where TLookup : notnull + where TValue : notnull { foreach (var (_, lookupWrapper) in skinSources) { - IBindable bindable; + IBindable? bindable; if ((bindable = lookupWrapper.GetConfig(lookup)) != null) return bindable; } @@ -239,15 +243,15 @@ namespace osu.Game.Skinning this.provider = provider; } - public Drawable GetDrawableComponent(ISkinComponent component) + public Drawable? GetDrawableComponent(ISkinComponentLookup lookup) { - if (provider.AllowDrawableLookup(component)) - return skin.GetDrawableComponent(component); + if (provider.AllowDrawableLookup(lookup)) + return skin.GetDrawableComponent(lookup); return null; } - public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) + public Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) { if (provider.AllowTextureLookup(componentName)) return skin.GetTexture(componentName, wrapModeS, wrapModeT); @@ -255,7 +259,7 @@ namespace osu.Game.Skinning return null; } - public ISample GetSample(ISampleInfo sampleInfo) + public ISample? GetSample(ISampleInfo sampleInfo) { if (provider.AllowSampleLookup(sampleInfo)) return skin.GetSample(sampleInfo); @@ -263,13 +267,15 @@ namespace osu.Game.Skinning return null; } - public IBindable GetConfig(TLookup lookup) + public IBindable? GetConfig(TLookup lookup) + where TLookup : notnull + where TValue : notnull { switch (lookup) { - case GlobalSkinColours _: - case SkinComboColourLookup _: - case SkinCustomColourLookup _: + case GlobalSkinColours: + case SkinComboColourLookup: + case SkinCustomColourLookup: if (provider.AllowColourLookup) return skin.GetConfig(lookup); diff --git a/osu.Game/Skinning/SkinReloadableDrawable.cs b/osu.Game/Skinning/SkinReloadableDrawable.cs index dec546b82d..c7b33dc539 100644 --- a/osu.Game/Skinning/SkinReloadableDrawable.cs +++ b/osu.Game/Skinning/SkinReloadableDrawable.cs @@ -3,24 +3,29 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics.Pooling; +using osu.Framework.Threading; namespace osu.Game.Skinning { /// - /// A drawable which has a callback when the skin changes. + /// A poolable drawable implementation which has a pre-wired callback (see ) that fires + /// once on load and again on any subsequent skin change. /// - public abstract class SkinReloadableDrawable : PoolableDrawable + public abstract partial class SkinReloadableDrawable : PoolableDrawable { + private ScheduledDelegate? pendingSkinChange; + /// /// Invoked when has changed. /// - public event Action OnSkinChanged; + public event Action? OnSkinChanged; /// /// The current skin source. /// - protected ISkinSource CurrentSkin { get; private set; } + protected ISkinSource CurrentSkin { get; private set; } = null!; [BackgroundDependencyLoader] private void load(ISkinSource source) @@ -29,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; } /// @@ -54,11 +68,27 @@ 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); - if (CurrentSkin != null) + if (CurrentSkin.IsNotNull()) CurrentSkin.SourceChanged -= onChange; OnSkinChanged = null; diff --git a/osu.Game/Skinning/SkinTransformer.cs b/osu.Game/Skinning/SkinTransformer.cs new file mode 100644 index 0000000000..ed5b04da1e --- /dev/null +++ b/osu.Game/Skinning/SkinTransformer.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; +using osu.Game.Audio; + +namespace osu.Game.Skinning +{ + /// + /// A default skin transformer, which falls back to the provided skin by default. + /// + /// + /// Implementations of skin transformers should generally derive this class and override + /// individual lookup methods, modifying the lookup flow as required. + /// + public abstract class SkinTransformer : ISkinTransformer + { + public ISkin Skin { get; } + + protected SkinTransformer(ISkin skin) + { + Skin = skin ?? throw new ArgumentNullException(nameof(skin)); + } + + public virtual Drawable? GetDrawableComponent(ISkinComponentLookup lookup) => Skin.GetDrawableComponent(lookup); + + public virtual Texture? GetTexture(string componentName) => GetTexture(componentName, default, default); + + public virtual Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => Skin.GetTexture(componentName, wrapModeS, wrapModeT); + + public virtual ISample? GetSample(ISampleInfo sampleInfo) => Skin.GetSample(sampleInfo); + + public virtual IBindable? GetConfig(TLookup lookup) where TLookup : notnull where TValue : notnull => Skin.GetConfig(lookup); + } +} diff --git a/osu.Game/Skinning/SkinUtils.cs b/osu.Game/Skinning/SkinUtils.cs index e3bc5e28b8..75eae82401 100644 --- a/osu.Game/Skinning/SkinUtils.cs +++ b/osu.Game/Skinning/SkinUtils.cs @@ -16,6 +16,6 @@ namespace osu.Game.Skinning /// The value. /// The type of value , and the type of the resulting bindable. /// The resulting bindable. - public static Bindable As(object value) => (Bindable)value; + public static Bindable? As(object? value) => (Bindable?)value; } } diff --git a/osu.Game/Skinning/SkinnableDrawable.cs b/osu.Game/Skinning/SkinnableDrawable.cs index 45409694b5..255aba94ae 100644 --- a/osu.Game/Skinning/SkinnableDrawable.cs +++ b/osu.Game/Skinning/SkinnableDrawable.cs @@ -12,12 +12,12 @@ namespace osu.Game.Skinning /// /// A drawable which can be skinned via an . /// - public class SkinnableDrawable : SkinReloadableDrawable + public partial class SkinnableDrawable : SkinReloadableDrawable { /// /// The displayed component. /// - public Drawable Drawable { get; private set; } + public Drawable Drawable { get; private set; } = null!; /// /// Whether the drawable component should be centered in available space. @@ -31,25 +31,25 @@ namespace osu.Game.Skinning set => base.AutoSizeAxes = value; } - protected readonly ISkinComponent Component; + protected readonly ISkinComponentLookup ComponentLookup; private readonly ConfineMode confineMode; /// /// Create a new skinnable drawable. /// - /// The namespace-complete resource name for this skinnable element. + /// The namespace-complete resource name for this skinnable element. /// A function to create the default skin implementation of this element. /// How (if at all) the should be resize to fit within our own bounds. - public SkinnableDrawable(ISkinComponent component, Func defaultImplementation = null, ConfineMode confineMode = ConfineMode.NoScaling) - : this(component, confineMode) + public SkinnableDrawable(ISkinComponentLookup lookup, Func? defaultImplementation = null, ConfineMode confineMode = ConfineMode.NoScaling) + : this(lookup, confineMode) { createDefault = defaultImplementation; } - protected SkinnableDrawable(ISkinComponent component, ConfineMode confineMode = ConfineMode.NoScaling) + protected SkinnableDrawable(ISkinComponentLookup lookup, ConfineMode confineMode = ConfineMode.NoScaling) { - Component = component; + ComponentLookup = lookup; this.confineMode = confineMode; RelativeSizeAxes = Axes.Both; @@ -60,13 +60,13 @@ namespace osu.Game.Skinning /// public void ResetAnimation() => (Drawable as IFramedAnimation)?.GotoFrame(0); - private readonly Func createDefault; + private readonly Func? createDefault; private readonly Cached scaling = new Cached(); private bool isDefault; - protected virtual Drawable CreateDefault(ISkinComponent component) => createDefault?.Invoke(component) ?? Empty(); + protected virtual Drawable CreateDefault(ISkinComponentLookup lookup) => createDefault?.Invoke(lookup) ?? Empty(); /// /// Whether to apply size restrictions (specified via ) to the default implementation. @@ -75,30 +75,28 @@ namespace osu.Game.Skinning protected override void SkinChanged(ISkinSource skin) { - Drawable = skin.GetDrawableComponent(Component); + var retrieved = skin.GetDrawableComponent(ComponentLookup); - isDefault = false; - - if (Drawable == null) + if (retrieved == null) { - Drawable = CreateDefault(Component); + Drawable = CreateDefault(ComponentLookup); isDefault = true; } - - if (Drawable != null) - { - scaling.Invalidate(); - - if (CentreComponent) - { - Drawable.Origin = Anchor.Centre; - Drawable.Anchor = Anchor.Centre; - } - - InternalChild = Drawable; - } else - ClearInternal(); + { + Drawable = retrieved; + isDefault = false; + } + + scaling.Invalidate(); + + if (CentreComponent) + { + Drawable.Origin = Anchor.Centre; + Drawable.Anchor = Anchor.Centre; + } + + InternalChild = Drawable; } protected override void Update() @@ -109,7 +107,7 @@ namespace osu.Game.Skinning { try { - if (Drawable == null || (isDefault && !ApplySizeRestrictionsToDefault)) return; + if (isDefault && !ApplySizeRestrictionsToDefault) return; switch (confineMode) { diff --git a/osu.Game/Skinning/SkinnableSound.cs b/osu.Game/Skinning/SkinnableSound.cs index c9e55c09aa..59b3799e0a 100644 --- a/osu.Game/Skinning/SkinnableSound.cs +++ b/osu.Game/Skinning/SkinnableSound.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; @@ -19,7 +18,7 @@ namespace osu.Game.Skinning /// /// A sound consisting of one or more samples to be played. /// - public class SkinnableSound : SkinReloadableDrawable, IAdjustableAudioComponent + public partial class SkinnableSound : SkinReloadableDrawable, IAdjustableAudioComponent { public override bool RemoveWhenNotAlive => false; public override bool RemoveCompletedTransforms => false; @@ -37,13 +36,12 @@ namespace osu.Game.Skinning /// /// All raw s contained in this . /// - [NotNull, ItemNotNull] protected IEnumerable DrawableSamples => samplesContainer.Select(c => c.Sample).Where(s => s != null); private readonly AudioContainer samplesContainer; - [Resolved(CanBeNull = true)] - private IPooledSampleProvider samplePool { get; set; } + [Resolved] + private IPooledSampleProvider? samplePool { get; set; } /// /// Creates a new . @@ -57,7 +55,7 @@ namespace osu.Game.Skinning /// Creates a new with some initial samples. /// /// The initial samples. - public SkinnableSound([NotNull] IEnumerable samples) + public SkinnableSound(IEnumerable samples) : this() { this.samples = samples.ToArray(); @@ -67,7 +65,7 @@ namespace osu.Game.Skinning /// Creates a new with an initial sample. /// /// The initial sample. - public SkinnableSound([NotNull] ISampleInfo sample) + public SkinnableSound(ISampleInfo sample) : this(new[] { sample }) { } @@ -82,8 +80,6 @@ namespace osu.Game.Skinning get => samples; set { - value ??= Array.Empty(); - if (samples == value) return; @@ -94,6 +90,8 @@ namespace osu.Game.Skinning } } + public void ClearSamples() => Samples = Array.Empty(); + private bool looping; /// @@ -117,6 +115,8 @@ namespace osu.Game.Skinning /// public virtual void Play() { + FlushPendingSkinChanges(); + samplesContainer.ForEach(c => { if (PlayWhenZeroVolume || c.AggregateVolume.Value > 0) @@ -149,7 +149,7 @@ namespace osu.Game.Skinning bool wasPlaying = IsPlaying; // Remove all pooled samples (return them to the pool), and dispose the rest. - samplesContainer.RemoveAll(s => s.IsInPool); + samplesContainer.RemoveAll(s => s.IsInPool, false); samplesContainer.Clear(); foreach (var s in samples) diff --git a/osu.Game/Skinning/SkinnableSprite.cs b/osu.Game/Skinning/SkinnableSprite.cs index 21b34fcd27..1d97566470 100644 --- a/osu.Game/Skinning/SkinnableSprite.cs +++ b/osu.Game/Skinning/SkinnableSprite.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Configuration; using osu.Game.Graphics.Sprites; +using osu.Game.Localisation.SkinComponents; using osu.Game.Overlays.Settings; using osuTK; @@ -20,62 +21,62 @@ namespace osu.Game.Skinning /// /// A skinnable element which uses a single texture backing. /// - public class SkinnableSprite : SkinnableDrawable, ISkinnableDrawable + public partial class SkinnableSprite : SkinnableDrawable, ISerialisableDrawable { protected override bool ApplySizeRestrictionsToDefault => true; [Resolved] - private TextureStore textures { get; set; } + private TextureStore textures { get; set; } = null!; - [SettingSource("Sprite name", "The filename of the sprite", SettingControlType = typeof(SpriteSelectorControl))] + [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.SpriteName), nameof(SkinnableComponentStrings.SpriteNameDescription), SettingControlType = typeof(SpriteSelectorControl))] public Bindable SpriteName { get; } = new Bindable(string.Empty); [Resolved] - private ISkinSource source { get; set; } + private ISkinSource source { get; set; } = null!; public SkinnableSprite(string textureName, ConfineMode confineMode = ConfineMode.NoScaling) - : base(new SpriteComponent(textureName), confineMode) + : base(new SpriteComponentLookup(textureName), confineMode) { SpriteName.Value = textureName; } public SkinnableSprite() - : base(new SpriteComponent(string.Empty), ConfineMode.NoScaling) + : base(new SpriteComponentLookup(string.Empty), ConfineMode.NoScaling) { RelativeSizeAxes = Axes.None; AutoSizeAxes = Axes.Both; SpriteName.BindValueChanged(name => { - ((SpriteComponent)Component).LookupName = name.NewValue ?? string.Empty; + ((SpriteComponentLookup)ComponentLookup).LookupName = name.NewValue ?? string.Empty; if (IsLoaded) SkinChanged(CurrentSkin); }); } - protected override Drawable CreateDefault(ISkinComponent component) + protected override Drawable CreateDefault(ISkinComponentLookup lookup) { - var texture = textures.Get(component.LookupName); + var texture = textures.Get(((SpriteComponentLookup)lookup).LookupName); if (texture == null) - return new SpriteNotFound(component.LookupName); + return new SpriteNotFound(((SpriteComponentLookup)lookup).LookupName); return new Sprite { Texture = texture }; } public bool UsesFixedAnchor { get; set; } - internal class SpriteComponent : ISkinComponent + internal class SpriteComponentLookup : ISkinComponentLookup { public string LookupName { get; set; } - public SpriteComponent(string textureName) + public SpriteComponentLookup(string textureName) { LookupName = textureName; } } - public class SpriteSelectorControl : SettingsDropdown + public partial class SpriteSelectorControl : SettingsDropdown { protected override void LoadComplete() { @@ -86,19 +87,19 @@ namespace osu.Game.Skinning // but that requires further thought. var highestPrioritySkin = getHighestPriorityUserSkin(((SkinnableSprite)SettingSourceObject).source.AllSources) as Skin; - string[] availableFiles = highestPrioritySkin?.SkinInfo.PerformRead(s => s.Files - .Where(f => f.Filename.EndsWith(".png", StringComparison.Ordinal) - || f.Filename.EndsWith(".jpg", StringComparison.Ordinal)) - .Select(f => f.Filename).Distinct()).ToArray(); + string[]? availableFiles = highestPrioritySkin?.SkinInfo.PerformRead(s => s.Files + .Where(f => f.Filename.EndsWith(".png", StringComparison.Ordinal) + || f.Filename.EndsWith(".jpg", StringComparison.Ordinal)) + .Select(f => f.Filename).Distinct()).ToArray(); if (availableFiles?.Length > 0) Items = availableFiles; - static ISkin getHighestPriorityUserSkin(IEnumerable skins) + static ISkin? getHighestPriorityUserSkin(IEnumerable skins) { foreach (var skin in skins) { - if (skin is LegacySkinTransformer transformer && isUserSkin(transformer.Skin)) + if (skin is ISkinTransformer transformer && isUserSkin(transformer.Skin)) return transformer.Skin; if (isUserSkin(skin)) @@ -110,13 +111,15 @@ namespace osu.Game.Skinning // Temporarily used to exclude undesirable ISkin implementations static bool isUserSkin(ISkin skin) - => skin.GetType() == typeof(DefaultSkin) + => skin.GetType() == typeof(TrianglesSkin) + || skin.GetType() == typeof(ArgonProSkin) + || skin.GetType() == typeof(ArgonSkin) || skin.GetType() == typeof(DefaultLegacySkin) || skin.GetType() == typeof(LegacySkin); } } - public class SpriteNotFound : CompositeDrawable + public partial class SpriteNotFound : CompositeDrawable { public SpriteNotFound(string lookup) { diff --git a/osu.Game/Skinning/SkinnableSpriteText.cs b/osu.Game/Skinning/SkinnableSpriteText.cs index 2bde3c4180..7c42449ae7 100644 --- a/osu.Game/Skinning/SkinnableSpriteText.cs +++ b/osu.Game/Skinning/SkinnableSpriteText.cs @@ -7,10 +7,10 @@ using osu.Framework.Localisation; namespace osu.Game.Skinning { - public class SkinnableSpriteText : SkinnableDrawable, IHasText + public partial class SkinnableSpriteText : SkinnableDrawable, IHasText { - public SkinnableSpriteText(ISkinComponent component, Func defaultImplementation, ConfineMode confineMode = ConfineMode.NoScaling) - : base(component, defaultImplementation, confineMode) + public SkinnableSpriteText(ISkinComponentLookup lookup, Func defaultImplementation, ConfineMode confineMode = ConfineMode.NoScaling) + : base(lookup, defaultImplementation, confineMode) { } diff --git a/osu.Game/Skinning/SkinnableTargetComponent.cs b/osu.Game/Skinning/SkinnableTargetComponent.cs deleted file mode 100644 index a17aafe6e7..0000000000 --- a/osu.Game/Skinning/SkinnableTargetComponent.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. - -namespace osu.Game.Skinning -{ - public class SkinnableTargetComponent : ISkinComponent - { - public readonly SkinnableTarget Target; - - public string LookupName => Target.ToString(); - - public SkinnableTargetComponent(SkinnableTarget target) - { - Target = target; - } - } -} diff --git a/osu.Game/Skinning/SkinnableTargetContainer.cs b/osu.Game/Skinning/SkinnableTargetContainer.cs deleted file mode 100644 index 20c2fcc075..0000000000 --- a/osu.Game/Skinning/SkinnableTargetContainer.cs +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Linq; -using System.Threading; -using osu.Framework.Bindables; -using osu.Framework.Graphics; - -namespace osu.Game.Skinning -{ - public class SkinnableTargetContainer : SkinReloadableDrawable, ISkinnableTarget - { - private SkinnableTargetComponentsContainer content; - - public SkinnableTarget Target { get; } - - public IBindableList Components => components; - - private readonly BindableList components = new BindableList(); - - public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; // ensure that components are loaded even if the target container is hidden (ie. due to user toggle). - - public bool ComponentsLoaded { get; private set; } - - private CancellationTokenSource cancellationSource; - - public SkinnableTargetContainer(SkinnableTarget target) - { - Target = target; - } - - /// - /// Reload all components in this container from the current skin. - /// - public void Reload() - { - ClearInternal(); - components.Clear(); - ComponentsLoaded = false; - - content = CurrentSkin.GetDrawableComponent(new SkinnableTargetComponent(Target)) as SkinnableTargetComponentsContainer; - - cancellationSource?.Cancel(); - cancellationSource = null; - - if (content != null) - { - LoadComponentAsync(content, wrapper => - { - AddInternal(wrapper); - components.AddRange(wrapper.Children.OfType()); - ComponentsLoaded = true; - }, (cancellationSource = new CancellationTokenSource()).Token); - } - else - ComponentsLoaded = true; - } - - /// - /// Thrown when attempting to add an element to a target which is not supported by the current skin. - /// Thrown if the provided instance is not a . - public void Add(ISkinnableDrawable component) - { - if (content == null) - throw new NotSupportedException("Attempting to add a new component to a target container which is not supported by the current skin."); - - if (!(component is Drawable drawable)) - throw new ArgumentException($"Provided argument must be of type {nameof(Drawable)}.", nameof(component)); - - content.Add(drawable); - components.Add(component); - } - - /// - /// Thrown when attempting to add an element to a target which is not supported by the current skin. - /// Thrown if the provided instance is not a . - public void Remove(ISkinnableDrawable component) - { - if (content == null) - throw new NotSupportedException("Attempting to remove a new component from a target container which is not supported by the current skin."); - - if (!(component is Drawable drawable)) - throw new ArgumentException($"Provided argument must be of type {nameof(Drawable)}.", nameof(component)); - - content.Remove(drawable); - components.Remove(component); - } - - protected override void SkinChanged(ISkinSource skin) - { - base.SkinChanged(skin); - - Reload(); - } - } -} diff --git a/osu.Game/Skinning/DefaultSkin.cs b/osu.Game/Skinning/TrianglesSkin.cs similarity index 73% rename from osu.Game/Skinning/DefaultSkin.cs rename to osu.Game/Skinning/TrianglesSkin.cs index fb24084659..e88b827807 100644 --- a/osu.Game/Skinning/DefaultSkin.cs +++ b/osu.Game/Skinning/TrianglesSkin.cs @@ -7,14 +7,11 @@ using JetBrains.Annotations; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.OpenGL.Textures; -using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Audio; using osu.Game.Beatmaps.Formats; using osu.Game.Extensions; using osu.Game.IO; -using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD.HitErrorMeters; using osuTK; @@ -22,38 +19,38 @@ using osuTK.Graphics; namespace osu.Game.Skinning { - public class DefaultSkin : Skin + public class TrianglesSkin : Skin { public static SkinInfo CreateInfo() => new SkinInfo { - ID = osu.Game.Skinning.SkinInfo.DEFAULT_SKIN, - Name = "osu! (triangles)", + ID = osu.Game.Skinning.SkinInfo.TRIANGLES_SKIN, + Name = "osu! \"triangles\" (2017)", Creator = "team osu!", Protected = true, - InstantiationInfo = typeof(DefaultSkin).GetInvariantInstantiationInfo() + InstantiationInfo = typeof(TrianglesSkin).GetInvariantInstantiationInfo() }; private readonly IStorageResourceProvider resources; - public DefaultSkin(IStorageResourceProvider resources) + public TrianglesSkin(IStorageResourceProvider resources) : this(CreateInfo(), resources) { } [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)] - public DefaultSkin(SkinInfo skin, IStorageResourceProvider resources) + public TrianglesSkin(SkinInfo skin, IStorageResourceProvider resources) : base(skin, resources) { this.resources = resources; } - public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => Textures?.Get(componentName, wrapModeS, wrapModeT); + public override Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => Textures?.Get(componentName, wrapModeS, wrapModeT); - public override ISample GetSample(ISampleInfo sampleInfo) + public override ISample? GetSample(ISampleInfo sampleInfo) { foreach (string lookup in sampleInfo.LookupNames) { - var sample = Samples?.Get(lookup) ?? resources.AudioManager.Samples.Get(lookup); + var sample = Samples?.Get(lookup) ?? resources.AudioManager?.Samples.Get(lookup); if (sample != null) return sample; } @@ -61,26 +58,33 @@ namespace osu.Game.Skinning return null; } - public override Drawable GetDrawableComponent(ISkinComponent component) + public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup) { - if (base.GetDrawableComponent(component) is Drawable c) + // Temporary until default skin has a valid hit lighting. + if ((lookup as SkinnableSprite.SpriteComponentLookup)?.LookupName == @"lighting") return Drawable.Empty(); + + if (base.GetDrawableComponent(lookup) is Drawable c) return c; - switch (component) + switch (lookup) { - case SkinnableTargetComponent target: - switch (target.Target) + case SkinComponentsContainerLookup containerLookup: + // Only handle global level defaults for now. + if (containerLookup.Ruleset != null) + return null; + + switch (containerLookup.Target) { - case SkinnableTarget.SongSelect: - var songSelectComponents = new SkinnableTargetComponentsContainer(container => + case SkinComponentsContainerLookup.TargetArea.SongSelect: + var songSelectComponents = new DefaultSkinComponentsContainer(_ => { // do stuff when we need to. }); return songSelectComponents; - case SkinnableTarget.MainHUDComponents: - var skinnableTargetWrapper = new SkinnableTargetComponentsContainer(container => + case SkinComponentsContainerLookup.TargetArea.MainHUDComponents: + var skinnableTargetWrapper = new DefaultSkinComponentsContainer(container => { var score = container.OfType().FirstOrDefault(); var accuracy = container.OfType().FirstOrDefault(); @@ -111,12 +115,12 @@ namespace osu.Game.Skinning accuracy.Position = new Vector2(-accuracy.ScreenSpaceDeltaToParentSpace(score.ScreenSpaceDrawQuad.Size).X / 2 - horizontal_padding, vertical_offset + 5); accuracy.Origin = Anchor.TopRight; accuracy.Anchor = Anchor.TopCentre; - } - if (combo != null) - { - combo.Position = new Vector2(accuracy.ScreenSpaceDeltaToParentSpace(score.ScreenSpaceDrawQuad.Size).X / 2 + horizontal_padding, vertical_offset + 5); - combo.Anchor = Anchor.TopCentre; + if (combo != null) + { + combo.Position = new Vector2(accuracy.ScreenSpaceDeltaToParentSpace(score.ScreenSpaceDrawQuad.Size).X / 2 + horizontal_padding, vertical_offset + 5); + combo.Anchor = Anchor.TopCentre; + } } var hitError = container.OfType().FirstOrDefault(); @@ -145,7 +149,7 @@ namespace osu.Game.Skinning new DefaultScoreCounter(), new DefaultAccuracyCounter(), new DefaultHealthDisplay(), - new SongProgress(), + new DefaultSongProgress(), new BarHitErrorMeter(), new BarHitErrorMeter(), new PerformancePointsCounter() @@ -158,20 +162,10 @@ namespace osu.Game.Skinning return null; } - switch (component.LookupName) - { - // Temporary until default skin has a valid hit lighting. - case @"lighting": - return Drawable.Empty(); - } - - if (GetTexture(component.LookupName) is Texture t) - return new Sprite { Texture = t }; - return null; } - public override IBindable GetConfig(TLookup lookup) + public override IBindable? GetConfig(TLookup lookup) { // todo: this code is pulled from LegacySkin and should not exist. // will likely change based on how databased storage of skin configuration goes. @@ -181,7 +175,7 @@ namespace osu.Game.Skinning switch (global) { case GlobalSkinColours.ComboColours: - return SkinUtils.As(new Bindable>(Configuration.ComboColours)); + return SkinUtils.As(new Bindable?>(Configuration.ComboColours)); } break; diff --git a/osu.Game/Skinning/UnsupportedSkinComponentException.cs b/osu.Game/Skinning/UnsupportedSkinComponentException.cs index 7f0dd51d5b..b8dfb7a31d 100644 --- a/osu.Game/Skinning/UnsupportedSkinComponentException.cs +++ b/osu.Game/Skinning/UnsupportedSkinComponentException.cs @@ -7,8 +7,8 @@ namespace osu.Game.Skinning { public class UnsupportedSkinComponentException : Exception { - public UnsupportedSkinComponentException(ISkinComponent component) - : base($@"Unsupported component type: {component.GetType()} (lookup: ""{component.LookupName}"").") + public UnsupportedSkinComponentException(ISkinComponentLookup lookup) + : base($@"Unsupported component type: {lookup.GetType()} (lookup: ""{lookup}"").") { } } diff --git a/osu.Game/Storyboards/CommandLoop.cs b/osu.Game/Storyboards/CommandLoop.cs index 0713cb8670..29e034d86c 100644 --- a/osu.Game/Storyboards/CommandLoop.cs +++ b/osu.Game/Storyboards/CommandLoop.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; @@ -16,7 +18,12 @@ namespace osu.Game.Storyboards public readonly int TotalIterations; public override double StartTime => LoopStartTime + CommandsStartTime; - public override double EndTime => StartTime + CommandsDuration * TotalIterations; + + public override double EndTime => + // In an ideal world, we would multiply the command duration by TotalIterations here. + // Unfortunately this would clash with how stable handled end times, and results in some storyboards playing outro + // sequences for minutes or hours. + StartTime + CommandsDuration; /// /// Construct a new command loop. diff --git a/osu.Game/Storyboards/CommandTimeline.cs b/osu.Game/Storyboards/CommandTimeline.cs index 8ded3ee975..0650c97165 100644 --- a/osu.Game/Storyboards/CommandTimeline.cs +++ b/osu.Game/Storyboards/CommandTimeline.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using System; using System.Collections.Generic; @@ -25,7 +27,9 @@ namespace osu.Game.Storyboards public void Add(Easing easing, double startTime, double endTime, T startValue, T endValue) { if (endTime < startTime) - return; + { + endTime = startTime; + } commands.Add(new TypedCommand { Easing = easing, StartTime = startTime, EndTime = endTime, StartValue = startValue, EndValue = endValue }); diff --git a/osu.Game/Storyboards/CommandTimelineGroup.cs b/osu.Game/Storyboards/CommandTimelineGroup.cs index e7de135ce8..d198ed68bd 100644 --- a/osu.Game/Storyboards/CommandTimelineGroup.cs +++ b/osu.Game/Storyboards/CommandTimelineGroup.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osuTK; using osuTK.Graphics; @@ -45,30 +47,11 @@ namespace osu.Game.Storyboards }; } - /// - /// Returns the earliest visible time. Will be null unless this group's first command has a start value of zero. - /// - public double? EarliestDisplayedTime - { - get - { - var first = Alpha.Commands.FirstOrDefault(); - - return first?.StartValue == 0 ? first.StartTime : (double?)null; - } - } - [JsonIgnore] public double CommandsStartTime { get { - // if the first alpha command starts at zero it should be given priority over anything else. - // this is due to it creating a state where the target is not present before that time, causing any other events to not be visible. - double? earliestDisplay = EarliestDisplayedTime; - if (earliestDisplay != null) - return earliestDisplay.Value; - double min = double.MaxValue; for (int i = 0; i < timelines.Length; i++) @@ -101,9 +84,6 @@ namespace osu.Game.Storyboards [JsonIgnore] public virtual double EndTime => CommandsEndTime; - [JsonIgnore] - public double Duration => EndTime - StartTime; - [JsonIgnore] public bool HasCommands { diff --git a/osu.Game/Storyboards/CommandTrigger.cs b/osu.Game/Storyboards/CommandTrigger.cs index 011f345df2..50f3f0ef49 100644 --- a/osu.Game/Storyboards/CommandTrigger.cs +++ b/osu.Game/Storyboards/CommandTrigger.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Storyboards { public class CommandTrigger : CommandTimelineGroup diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs index a0fb7b0b4a..aa264fa719 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -15,11 +17,10 @@ using osu.Framework.Platform; using osu.Game.Database; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Play; -using osu.Game.Stores; namespace osu.Game.Storyboards.Drawables { - public class DrawableStoryboard : Container + public partial class DrawableStoryboard : Container { [Cached] public Storyboard Storyboard { get; } @@ -84,12 +85,12 @@ namespace osu.Game.Storyboards.Drawables } [BackgroundDependencyLoader(true)] - private void load(GameplayClock clock, CancellationToken? cancellationToken, GameHost host, RealmAccess realm) + private void load(IGameplayClock clock, CancellationToken? cancellationToken, GameHost host, RealmAccess realm) { if (clock != null) Clock = clock; - dependencies.Cache(new TextureStore(host.CreateTextureLoaderStore(new RealmFileStore(realm, host.Storage).Store), false, scaleAdjust: 1)); + dependencies.Cache(new TextureStore(host.Renderer, host.CreateTextureLoaderStore(new RealmFileStore(realm, host.Storage).Store), false, scaleAdjust: 1)); foreach (var layer in Storyboard.Layers) { diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs index 8a14b8b183..e598c79b08 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.IO; using osu.Framework.Allocation; @@ -8,12 +10,13 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Textures; using osu.Framework.Utils; +using osu.Game.Beatmaps; using osu.Game.Skinning; using osuTK; namespace osu.Game.Storyboards.Drawables { - public class DrawableStoryboardAnimation : TextureAnimation, IFlippable, IVectorScalable + public partial class DrawableStoryboardAnimation : TextureAnimation, IFlippable, IVectorScalable { public StoryboardAnimation Animation { get; } @@ -54,11 +57,6 @@ namespace osu.Game.Storyboards.Drawables get => vectorScale; set { - if (Math.Abs(value.X) < Precision.FLOAT_EPSILON) - value.X = Precision.FLOAT_EPSILON; - if (Math.Abs(value.Y) < Precision.FLOAT_EPSILON) - value.Y = Precision.FLOAT_EPSILON; - if (vectorScale == value) return; @@ -93,6 +91,9 @@ namespace osu.Game.Storyboards.Drawables [Resolved] private ISkinSource skin { get; set; } + [Resolved] + private IBeatSyncProvider beatSyncProvider { get; set; } + [BackgroundDependencyLoader] private void load(TextureStore textureStore, Storyboard storyboard) { @@ -118,13 +119,25 @@ namespace osu.Game.Storyboards.Drawables Animation.ApplyTransforms(this); } + protected override void LoadComplete() + { + base.LoadComplete(); + + // Framework animation class tries its best to synchronise the animation at LoadComplete, + // but in some cases (such as fast forward) this results in an incorrect start offset. + // + // In the case of storyboard animations, we want to synchronise with game time perfectly + // so let's get a correct time based on gameplay clock and earliest transform. + PlaybackPosition = (beatSyncProvider.Clock?.CurrentTime ?? Clock.CurrentTime) - Animation.EarliestTransformTime; + } + private void skinSourceChanged() { ClearFrames(); // When reading from a skin, we match stables weird behaviour where `FrameCount` is ignored // and resources are retrieved until the end of the animation. - foreach (var texture in skin.GetTextures(Path.GetFileNameWithoutExtension(Animation.Path), default, default, true, string.Empty, out _)) + foreach (var texture in skin.GetTextures(Path.GetFileNameWithoutExtension(Animation.Path)!, default, default, true, string.Empty, out _)) AddFrame(texture, Animation.FrameDelay); } diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardLayer.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardLayer.cs index 1085b52d65..6fc8d124c7 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardLayer.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardLayer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Threading; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -9,7 +11,7 @@ using osuTK; namespace osu.Game.Storyboards.Drawables { - public class DrawableStoryboardLayer : CompositeDrawable + public partial class DrawableStoryboardLayer : CompositeDrawable { public StoryboardLayer Layer { get; } public bool Enabled; @@ -30,7 +32,7 @@ namespace osu.Game.Storyboards.Drawables InternalChild = ElementContainer = new LayerElementContainer(layer); } - protected class LayerElementContainer : LifetimeManagementContainer + protected partial class LayerElementContainer : LifetimeManagementContainer { private readonly StoryboardLayer storyboardLayer; diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs index 4e3f72512c..c281d23804 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -10,7 +12,7 @@ using osu.Game.Skinning; namespace osu.Game.Storyboards.Drawables { - public class DrawableStoryboardSample : PausableSkinnableSound + public partial class DrawableStoryboardSample : PausableSkinnableSound { /// /// The amount of time allowable beyond the start time of the sample, for the sample to start. diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs index a6f2b8fcbd..f9b09ed57c 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osuTK; namespace osu.Game.Storyboards.Drawables { - public class DrawableStoryboardSprite : Sprite, IFlippable, IVectorScalable + public partial class DrawableStoryboardSprite : Sprite, IFlippable, IVectorScalable { public StoryboardSprite Sprite { get; } @@ -53,11 +55,6 @@ namespace osu.Game.Storyboards.Drawables get => vectorScale; set { - if (Math.Abs(value.X) < Precision.FLOAT_EPSILON) - value.X = Precision.FLOAT_EPSILON; - if (Math.Abs(value.Y) < Precision.FLOAT_EPSILON) - value.Y = Precision.FLOAT_EPSILON; - if (vectorScale == value) return; diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs index 0625ddaffd..eec2cd6a60 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -10,14 +8,14 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Textures; using osu.Framework.Graphics.Video; using osu.Game.Beatmaps; -using osu.Game.Extensions; namespace osu.Game.Storyboards.Drawables { - public class DrawableStoryboardVideo : CompositeDrawable + public partial class DrawableStoryboardVideo : CompositeDrawable { public readonly StoryboardVideo Video; - private Video video; + + private Video? drawableVideo; public override bool RemoveWhenNotAlive => false; @@ -31,7 +29,7 @@ namespace osu.Game.Storyboards.Drawables [BackgroundDependencyLoader(true)] private void load(IBindable beatmap, TextureStore textureStore) { - string path = beatmap.Value.BeatmapSetInfo?.Files.FirstOrDefault(f => f.Filename.Equals(Video.Path, StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath(); + string? path = beatmap.Value.BeatmapSetInfo?.GetPathForFile(Video.Path); if (path == null) return; @@ -41,7 +39,7 @@ namespace osu.Game.Storyboards.Drawables if (stream == null) return; - InternalChild = video = new Video(stream, false) + InternalChild = drawableVideo = new Video(stream, false) { RelativeSizeAxes = Axes.Both, FillMode = FillMode.Fill, @@ -55,12 +53,16 @@ namespace osu.Game.Storyboards.Drawables { base.LoadComplete(); - if (video == null) return; + if (drawableVideo == null) return; - using (video.BeginAbsoluteSequence(Video.StartTime)) + using (drawableVideo.BeginAbsoluteSequence(Video.StartTime)) { - Schedule(() => video.PlaybackPosition = Time.Current - Video.StartTime); - video.FadeIn(500); + Schedule(() => drawableVideo.PlaybackPosition = Time.Current - Video.StartTime); + + drawableVideo.FadeIn(500); + + using (drawableVideo.BeginDelayedSequence(drawableVideo.Duration - 500)) + drawableVideo.FadeOut(500); } } } diff --git a/osu.Game/Storyboards/Drawables/DrawablesExtensions.cs b/osu.Game/Storyboards/Drawables/DrawablesExtensions.cs index bbc55a336d..779c8384c5 100644 --- a/osu.Game/Storyboards/Drawables/DrawablesExtensions.cs +++ b/osu.Game/Storyboards/Drawables/DrawablesExtensions.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Transforms; diff --git a/osu.Game/Storyboards/Drawables/IFlippable.cs b/osu.Game/Storyboards/Drawables/IFlippable.cs index 165b3d97cc..aceb5c041c 100644 --- a/osu.Game/Storyboards/Drawables/IFlippable.cs +++ b/osu.Game/Storyboards/Drawables/IFlippable.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Transforms; diff --git a/osu.Game/Storyboards/Drawables/IVectorScalable.cs b/osu.Game/Storyboards/Drawables/IVectorScalable.cs index fcc407d460..3b43a35a90 100644 --- a/osu.Game/Storyboards/Drawables/IVectorScalable.cs +++ b/osu.Game/Storyboards/Drawables/IVectorScalable.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Graphics.Transforms; using osuTK; diff --git a/osu.Game/Storyboards/IStoryboardElement.cs b/osu.Game/Storyboards/IStoryboardElement.cs index 9a059991e6..7e83f8b692 100644 --- a/osu.Game/Storyboards/IStoryboardElement.cs +++ b/osu.Game/Storyboards/IStoryboardElement.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; namespace osu.Game.Storyboards diff --git a/osu.Game/Storyboards/IStoryboardElementWithDuration.cs b/osu.Game/Storyboards/IStoryboardElementWithDuration.cs index 55f163ee07..c8daeb3b3d 100644 --- a/osu.Game/Storyboards/IStoryboardElementWithDuration.cs +++ b/osu.Game/Storyboards/IStoryboardElementWithDuration.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Storyboards { /// diff --git a/osu.Game/Storyboards/Storyboard.cs b/osu.Game/Storyboards/Storyboard.cs index 1d21b5dce2..566e064aad 100644 --- a/osu.Game/Storyboards/Storyboard.cs +++ b/osu.Game/Storyboards/Storyboard.cs @@ -1,12 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; +using System.IO; using System.Linq; using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; -using osu.Game.Extensions; using osu.Game.Rulesets.Mods; using osu.Game.Storyboards.Drawables; @@ -33,7 +32,7 @@ namespace osu.Game.Storyboards /// /// This iterates all elements and as such should be used sparingly or stored locally. /// - public double? EarliestEventTime => Layers.SelectMany(l => l.Elements).OrderBy(e => e.StartTime).FirstOrDefault()?.StartTime; + public double? EarliestEventTime => Layers.SelectMany(l => l.Elements).MinBy(e => e.StartTime)?.StartTime; /// /// Across all layers, find the latest point in time that a storyboard element ends at. @@ -43,7 +42,7 @@ namespace osu.Game.Storyboards /// This iterates all elements and as such should be used sparingly or stored locally. /// Videos and samples return StartTime as their EndTIme. /// - public double? LatestEventTime => Layers.SelectMany(l => l.Elements).OrderBy(e => e.GetEndTime()).LastOrDefault()?.GetEndTime(); + public double? LatestEventTime => Layers.SelectMany(l => l.Elements).MaxBy(e => e.GetEndTime())?.GetEndTime(); /// /// Depth of the currently front-most storyboard layer, excluding the overlay layer. @@ -88,15 +87,34 @@ namespace osu.Game.Storyboards } } - public DrawableStoryboard CreateDrawable(IReadOnlyList mods = null) => + public DrawableStoryboard CreateDrawable(IReadOnlyList? mods = null) => new DrawableStoryboard(this, mods); - public Texture GetTextureFromPath(string path, TextureStore textureStore) - { - string storyboardPath = BeatmapInfo.BeatmapSet?.Files.FirstOrDefault(f => f.Filename.Equals(path, StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath(); + private static readonly string[] image_extensions = { @".png", @".jpg" }; - if (!string.IsNullOrEmpty(storyboardPath)) - return textureStore.Get(storyboardPath); + public Texture? GetTextureFromPath(string path, TextureStore textureStore) + { + string? resolvedPath = null; + + if (Path.HasExtension(path)) + { + resolvedPath = BeatmapInfo.BeatmapSet?.GetPathForFile(path); + } + else + { + // Just doing this extension logic locally here for simplicity. + // + // A more "sane" path may be to use the ISkinSource.GetTexture path (which will use the extensions of the underlying TextureStore), + // but comes with potential complexity (what happens if the user has beatmap skins disabled?). + foreach (string ext in image_extensions) + { + if ((resolvedPath = BeatmapInfo.BeatmapSet?.GetPathForFile($"{path}{ext}")) != null) + break; + } + } + + if (!string.IsNullOrEmpty(resolvedPath)) + return textureStore.Get(resolvedPath); return null; } diff --git a/osu.Game/Storyboards/StoryboardAnimation.cs b/osu.Game/Storyboards/StoryboardAnimation.cs index 1a4b6bb923..16deac8e9e 100644 --- a/osu.Game/Storyboards/StoryboardAnimation.cs +++ b/osu.Game/Storyboards/StoryboardAnimation.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK; using osu.Framework.Graphics; using osu.Game.Storyboards.Drawables; diff --git a/osu.Game/Storyboards/StoryboardExtensions.cs b/osu.Game/Storyboards/StoryboardExtensions.cs index 4e8251c9e7..e5cafc152b 100644 --- a/osu.Game/Storyboards/StoryboardExtensions.cs +++ b/osu.Game/Storyboards/StoryboardExtensions.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osuTK; diff --git a/osu.Game/Storyboards/StoryboardLayer.cs b/osu.Game/Storyboards/StoryboardLayer.cs index fa9d4ebfea..2ab8d9fc2a 100644 --- a/osu.Game/Storyboards/StoryboardLayer.cs +++ b/osu.Game/Storyboards/StoryboardLayer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Storyboards.Drawables; using System.Collections.Generic; diff --git a/osu.Game/Storyboards/StoryboardSample.cs b/osu.Game/Storyboards/StoryboardSample.cs index 5d6ce215f5..752d086993 100644 --- a/osu.Game/Storyboards/StoryboardSample.cs +++ b/osu.Game/Storyboards/StoryboardSample.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using osu.Framework.Graphics; using osu.Game.Audio; diff --git a/osu.Game/Storyboards/StoryboardSprite.cs b/osu.Game/Storyboards/StoryboardSprite.cs index ebd1a941a8..5b7b194be7 100644 --- a/osu.Game/Storyboards/StoryboardSprite.cs +++ b/osu.Game/Storyboards/StoryboardSprite.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -28,24 +30,43 @@ namespace osu.Game.Storyboards { get { - // check for presence affecting commands as an initial pass. - double earliestStartTime = TimelineGroup.EarliestDisplayedTime ?? double.MaxValue; + // To get the initial start time, we need to check whether the first alpha command to exist (across all loops) has a StartValue of zero. + // A StartValue of zero governs, above all else, the first valid display time of a sprite. + // + // You can imagine that the first command of each type decides that type's start value, so if the initial alpha is zero, + // anything before that point can be ignored (the sprite is not visible after all). + var alphaCommands = new List<(double startTime, bool isZeroStartValue)>(); - foreach (var l in loops) + var command = TimelineGroup.Alpha.Commands.FirstOrDefault(); + if (command != null) alphaCommands.Add((command.StartTime, command.StartValue == 0)); + + foreach (var loop in loops) { - if (l.EarliestDisplayedTime is double loopEarliestDisplayTime) - earliestStartTime = Math.Min(earliestStartTime, l.LoopStartTime + loopEarliestDisplayTime); + command = loop.Alpha.Commands.FirstOrDefault(); + if (command != null) alphaCommands.Add((command.StartTime + loop.LoopStartTime, command.StartValue == 0)); } - if (earliestStartTime < double.MaxValue) - return earliestStartTime; + if (alphaCommands.Count > 0) + { + var firstAlpha = alphaCommands.MinBy(t => t.startTime); - // if an alpha-affecting command was not found, use the earliest of any command. - earliestStartTime = TimelineGroup.StartTime; + if (firstAlpha.isZeroStartValue) + return firstAlpha.startTime; + } + return EarliestTransformTime; + } + } + + public double EarliestTransformTime + { + get + { + // If we got to this point, either no alpha commands were present, or the earliest had a non-zero start value. + // The sprite's StartTime will be determined by the earliest command, regardless of type. + double earliestStartTime = TimelineGroup.StartTime; foreach (var l in loops) earliestStartTime = Math.Min(earliestStartTime, l.StartTime); - return earliestStartTime; } } @@ -107,20 +128,20 @@ namespace osu.Game.Storyboards generateCommands(generated, getCommands(g => g.Rotation, triggeredGroups), (d, value) => d.Rotation = value, (d, value, duration, easing) => d.RotateTo(value, duration, easing)); generateCommands(generated, getCommands(g => g.Colour, triggeredGroups), (d, value) => d.Colour = value, (d, value, duration, easing) => d.FadeColour(value, duration, easing)); generateCommands(generated, getCommands(g => g.Alpha, triggeredGroups), (d, value) => d.Alpha = value, (d, value, duration, easing) => d.FadeTo(value, duration, easing)); - generateCommands(generated, getCommands(g => g.BlendingParameters, triggeredGroups), (d, value) => d.Blending = value, (d, value, duration, easing) => d.TransformBlendingMode(value, duration), + generateCommands(generated, getCommands(g => g.BlendingParameters, triggeredGroups), (d, value) => d.Blending = value, (d, value, duration, _) => d.TransformBlendingMode(value, duration), false); if (drawable is IVectorScalable vectorScalable) { - generateCommands(generated, getCommands(g => g.VectorScale, triggeredGroups), (d, value) => vectorScalable.VectorScale = value, - (d, value, duration, easing) => vectorScalable.VectorScaleTo(value, duration, easing)); + generateCommands(generated, getCommands(g => g.VectorScale, triggeredGroups), (_, value) => vectorScalable.VectorScale = value, + (_, value, duration, easing) => vectorScalable.VectorScaleTo(value, duration, easing)); } if (drawable is IFlippable flippable) { - generateCommands(generated, getCommands(g => g.FlipH, triggeredGroups), (d, value) => flippable.FlipH = value, (d, value, duration, easing) => flippable.TransformFlipH(value, duration), + generateCommands(generated, getCommands(g => g.FlipH, triggeredGroups), (_, value) => flippable.FlipH = value, (_, value, duration, _) => flippable.TransformFlipH(value, duration), false); - generateCommands(generated, getCommands(g => g.FlipV, triggeredGroups), (d, value) => flippable.FlipV = value, (d, value, duration, easing) => flippable.TransformFlipV(value, duration), + generateCommands(generated, getCommands(g => g.FlipV, triggeredGroups), (_, value) => flippable.FlipV = value, (_, value, duration, _) => flippable.TransformFlipV(value, duration), false); } diff --git a/osu.Game/Storyboards/StoryboardVideo.cs b/osu.Game/Storyboards/StoryboardVideo.cs index 4652e45852..04ff941397 100644 --- a/osu.Game/Storyboards/StoryboardVideo.cs +++ b/osu.Game/Storyboards/StoryboardVideo.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Storyboards.Drawables; diff --git a/osu.Game/Storyboards/StoryboardVideoLayer.cs b/osu.Game/Storyboards/StoryboardVideoLayer.cs index 2a01c2274a..f08c02cfd2 100644 --- a/osu.Game/Storyboards/StoryboardVideoLayer.cs +++ b/osu.Game/Storyboards/StoryboardVideoLayer.cs @@ -1,13 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Game.Storyboards.Drawables; using osuTK; namespace osu.Game.Storyboards { - public class StoryboardVideoLayer : StoryboardLayer + public partial class StoryboardVideoLayer : StoryboardLayer { public StoryboardVideoLayer(string name, int depth, bool masking) : base(name, depth, masking) @@ -17,7 +19,7 @@ namespace osu.Game.Storyboards public override DrawableStoryboardLayer CreateDrawable() => new DrawableStoryboardVideoLayer(this) { Depth = Depth, Name = Name }; - public class DrawableStoryboardVideoLayer : DrawableStoryboardLayer + public partial class DrawableStoryboardVideoLayer : DrawableStoryboardLayer { public DrawableStoryboardVideoLayer(StoryboardVideoLayer layer) : base(layer) diff --git a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs index 8d622955b7..79f629ce49 100644 --- a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs +++ b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.IO; @@ -183,7 +185,7 @@ namespace osu.Game.Tests.Beatmaps private Stream openResource(string name) { - string localPath = Path.GetDirectoryName(Uri.UnescapeDataString(new UriBuilder(Assembly.GetExecutingAssembly().CodeBase).Path)).AsNonNull(); + string localPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location).AsNonNull(); return Assembly.LoadFrom(Path.Combine(localPath, $"{ResourceAssembly}.dll")).GetManifestResourceStream($@"{ResourceAssembly}.Resources.{name}"); } diff --git a/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs b/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs index ed00c7959b..16434406b5 100644 --- a/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs +++ b/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs @@ -1,7 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; +#nullable disable + using System.IO; using System.Reflection; using NUnit.Framework; @@ -52,7 +53,7 @@ namespace osu.Game.Tests.Beatmaps private Stream openResource(string name) { - string localPath = Path.GetDirectoryName(Uri.UnescapeDataString(new UriBuilder(Assembly.GetExecutingAssembly().CodeBase).Path)).AsNonNull(); + string localPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location).AsNonNull(); return Assembly.LoadFrom(Path.Combine(localPath, $"{ResourceAssembly}.dll")).GetManifestResourceStream($@"{ResourceAssembly}.Resources.{name}"); } diff --git a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs index 4667a385b3..bb4e06654a 100644 --- a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs +++ b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; @@ -10,8 +12,10 @@ using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Audio; +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.Timing; using osu.Game.Beatmaps; @@ -29,7 +33,7 @@ using osu.Game.Tests.Visual; namespace osu.Game.Tests.Beatmaps { [HeadlessTest] - public abstract class HitObjectSampleTest : PlayerTestScene, IStorageResourceProvider + public abstract partial class HitObjectSampleTest : PlayerTestScene, IStorageResourceProvider { protected abstract IResourceStore RulesetResources { get; } protected LegacySkin Skin { get; private set; } @@ -37,6 +41,9 @@ namespace osu.Game.Tests.Beatmaps [Resolved] private RulesetStore rulesetStore { get; set; } + [Resolved] + private GameHost host { get; set; } + private readonly SkinInfo userSkinInfo = new SkinInfo(); private readonly BeatmapInfo beatmapInfo = new BeatmapInfo @@ -121,6 +128,7 @@ namespace osu.Game.Tests.Beatmaps #region IResourceStorageProvider + public IRenderer Renderer => host.Renderer; public AudioManager AudioManager => Audio; public IResourceStore Files => userSkinResourceStore; public new IResourceStore Resources => base.Resources; @@ -156,7 +164,7 @@ namespace osu.Game.Tests.Beatmaps return fallback.Get(type, info); } - public void Inject(T instance) where T : class + public void Inject(T instance) where T : class, IDependencyInjectionCandidate { // Never used directly } @@ -210,6 +218,7 @@ namespace osu.Game.Tests.Beatmaps protected internal override ISkin GetSkin() => new LegacyBeatmapSkin(skinBeatmapInfo, this); + public IRenderer Renderer => resources.Renderer; public AudioManager AudioManager => resources.AudioManager; public IResourceStore Files { get; } diff --git a/osu.Game/Tests/Beatmaps/LegacyBeatmapSkinColourTest.cs b/osu.Game/Tests/Beatmaps/LegacyBeatmapSkinColourTest.cs index 597c5e9a2b..2488aecced 100644 --- a/osu.Game/Tests/Beatmaps/LegacyBeatmapSkinColourTest.cs +++ b/osu.Game/Tests/Beatmaps/LegacyBeatmapSkinColourTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; @@ -15,7 +17,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Beatmaps { - public abstract class LegacyBeatmapSkinColourTest : ScreenTestScene + public abstract partial class LegacyBeatmapSkinColourTest : ScreenTestScene { protected readonly Bindable BeatmapSkins = new Bindable(); protected readonly Bindable BeatmapColours = new Bindable(); @@ -58,7 +60,7 @@ namespace osu.Game.Tests.Beatmaps protected virtual ExposedPlayer CreateTestPlayer(bool userHasCustomColours) => new ExposedPlayer(userHasCustomColours); - protected class ExposedPlayer : TestPlayer + protected partial class ExposedPlayer : TestPlayer { protected readonly bool UserHasCustomColours; diff --git a/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs b/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs index b7803f3420..921a039065 100644 --- a/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs +++ b/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using NUnit.Framework; diff --git a/osu.Game/Tests/Beatmaps/TestBeatmap.cs b/osu.Game/Tests/Beatmaps/TestBeatmap.cs index ff670e1232..1aa99ceed9 100644 --- a/osu.Game/Tests/Beatmaps/TestBeatmap.cs +++ b/osu.Game/Tests/Beatmaps/TestBeatmap.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Diagnostics; using System.IO; diff --git a/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs b/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs index 19974701db..7d2aa99dbe 100644 --- a/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs +++ b/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs @@ -14,7 +14,7 @@ namespace osu.Game.Tests.Beatmaps public class TestWorkingBeatmap : WorkingBeatmap { private readonly IBeatmap beatmap; - private readonly Storyboard storyboard; + private readonly Storyboard? storyboard; /// /// Create an instance which provides the when requested. @@ -22,27 +22,25 @@ namespace osu.Game.Tests.Beatmaps /// The beatmap. /// An optional storyboard. /// The . - public TestWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null, AudioManager audioManager = null) + public TestWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null, AudioManager? audioManager = null) : base(beatmap.BeatmapInfo, audioManager) { this.beatmap = beatmap; this.storyboard = storyboard; } - public override bool TrackLoaded => true; - public override bool BeatmapLoaded => true; protected override IBeatmap GetBeatmap() => beatmap; protected override Storyboard GetStoryboard() => storyboard ?? base.GetStoryboard(); - protected internal override ISkin GetSkin() => null; + protected internal override ISkin? GetSkin() => null; - public override Stream GetStream(string storagePath) => null; + public override Stream? GetStream(string storagePath) => null; - protected override Texture GetBackground() => null; + protected override Texture? GetBackground() => null; - protected override Track GetBeatmapTrack() => null; + protected override Track? GetBeatmapTrack() => null; } } diff --git a/osu.Game/Tests/CleanRunHeadlessGameHost.cs b/osu.Game/Tests/CleanRunHeadlessGameHost.cs index bdb171c528..02d67de5a5 100644 --- a/osu.Game/Tests/CleanRunHeadlessGameHost.cs +++ b/osu.Game/Tests/CleanRunHeadlessGameHost.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Runtime.CompilerServices; using osu.Framework; @@ -13,30 +15,38 @@ namespace osu.Game.Tests /// public class CleanRunHeadlessGameHost : TestRunHeadlessGameHost { + private readonly bool bypassCleanupOnSetup; + /// /// Create a new instance. /// /// Whether to bind IPC channels. /// Whether the host should be forced to run in realtime, rather than accelerated test time. - /// Whether to bypass directory cleanup on host disposal. Should be used only if a subsequent test relies on the files still existing. + /// Whether to bypass directory cleanup on . + /// Whether to bypass directory cleanup on host disposal. Should be used only if a subsequent test relies on the files still existing. /// The name of the calling method, used for test file isolation and clean-up. - public CleanRunHeadlessGameHost(bool bindIPC = false, bool realtime = true, bool bypassCleanup = false, [CallerMemberName] string callingMethodName = @"") + public CleanRunHeadlessGameHost(bool bindIPC = false, bool realtime = true, bool bypassCleanupOnSetup = false, bool bypassCleanupOnDispose = false, + [CallerMemberName] string callingMethodName = @"") : base($"{callingMethodName}-{Guid.NewGuid()}", new HostOptions { BindIPC = bindIPC, - }, bypassCleanup: bypassCleanup, realtime: realtime) + }, bypassCleanup: bypassCleanupOnDispose, realtime: realtime) { + this.bypassCleanupOnSetup = bypassCleanupOnSetup; } protected override void SetupForRun() { - try + if (!bypassCleanupOnSetup) { - Storage.DeleteDirectory(string.Empty); - } - catch - { - // May fail if a logging target has already been set via OsuStorage.ChangeTargetStorage. + try + { + Storage.DeleteDirectory(string.Empty); + } + catch + { + // May fail if a logging target has already been set via OsuStorage.ChangeTargetStorage. + } } // base call needs to be run *after* storage is emptied, as it updates the (static) logger's storage and may start writing diff --git a/osu.Game/Tests/FlakyTestAttribute.cs b/osu.Game/Tests/FlakyTestAttribute.cs new file mode 100644 index 0000000000..c61ce80bf5 --- /dev/null +++ b/osu.Game/Tests/FlakyTestAttribute.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; + +namespace osu.Game.Tests +{ + /// + /// An attribute to mark any flaky tests. + /// Will add a retry count unless environment variable `FAIL_FLAKY_TESTS` is set to `1`. + /// + public class FlakyTestAttribute : RetryAttribute + { + public FlakyTestAttribute() + : this(10) + { + } + + public FlakyTestAttribute(int tryCount) + : base(Environment.GetEnvironmentVariable("OSU_TESTS_FAIL_FLAKY") == "1" ? 1 : tryCount) + { + } + } +} diff --git a/osu.Game/Tests/Gameplay/TestGameplayState.cs b/osu.Game/Tests/Gameplay/TestGameplayState.cs index f14f8c44ec..bb82335543 100644 --- a/osu.Game/Tests/Gameplay/TestGameplayState.cs +++ b/osu.Game/Tests/Gameplay/TestGameplayState.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 enable - using System.Collections.Generic; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; diff --git a/osu.Game/Tests/OsuTestBrowser.cs b/osu.Game/Tests/OsuTestBrowser.cs index 71b0b02fa6..689eae336e 100644 --- a/osu.Game/Tests/OsuTestBrowser.cs +++ b/osu.Game/Tests/OsuTestBrowser.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Platform; using osu.Framework.Screens; @@ -10,7 +12,7 @@ using osu.Game.Screens.Backgrounds; namespace osu.Game.Tests { - public class OsuTestBrowser : OsuGameBase + public partial class OsuTestBrowser : OsuGameBase { protected override void LoadComplete() { @@ -20,7 +22,7 @@ namespace osu.Game.Tests { Depth = 10, RelativeSizeAxes = Axes.Both, - }, AddInternal); + }, Add); // Have to construct this here, rather than in the constructor, because // we depend on some dependencies to be loaded within OsuGameBase.load(). diff --git a/osu.Game/Tests/PollingNotificationsClient.cs b/osu.Game/Tests/PollingNotificationsClient.cs new file mode 100644 index 0000000000..450c763170 --- /dev/null +++ b/osu.Game/Tests/PollingNotificationsClient.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading; +using System.Threading.Tasks; +using osu.Game.Online.API; +using osu.Game.Online.Notifications; + +namespace osu.Game.Tests +{ + /// + /// A notifications client which polls for new messages every second. + /// + public class PollingNotificationsClient : NotificationsClient + { + public PollingNotificationsClient(IAPIProvider api) + : base(api) + { + } + + public override Task ConnectAsync(CancellationToken cancellationToken) + { + Task.Run(async () => + { + while (!cancellationToken.IsCancellationRequested) + { + await API.PerformAsync(CreateInitialFetchRequest()).ConfigureAwait(true); + await Task.Delay(1000, cancellationToken).ConfigureAwait(true); + } + }, cancellationToken); + + return Task.CompletedTask; + } + } +} diff --git a/osu.Game/Tests/PollingNotificationsClientConnector.cs b/osu.Game/Tests/PollingNotificationsClientConnector.cs new file mode 100644 index 0000000000..823fc9d157 --- /dev/null +++ b/osu.Game/Tests/PollingNotificationsClientConnector.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading; +using System.Threading.Tasks; +using osu.Game.Online.API; +using osu.Game.Online.Notifications; + +namespace osu.Game.Tests +{ + /// + /// A connector for s that poll for new messages. + /// + public class PollingNotificationsClientConnector : NotificationsClientConnector + { + public PollingNotificationsClientConnector(IAPIProvider api) + : base(api) + { + } + + protected override Task BuildNotificationClientAsync(CancellationToken cancellationToken) + => Task.FromResult((NotificationsClient)new PollingNotificationsClient(API)); + } +} diff --git a/osu.Game/Tests/Rulesets/TestRulesetConfigCache.cs b/osu.Game/Tests/Rulesets/TestRulesetConfigCache.cs index 537bee6824..cf637983d9 100644 --- a/osu.Game/Tests/Rulesets/TestRulesetConfigCache.cs +++ b/osu.Game/Tests/Rulesets/TestRulesetConfigCache.cs @@ -12,8 +12,8 @@ namespace osu.Game.Tests.Rulesets /// public class TestRulesetConfigCache : IRulesetConfigCache { - private readonly ConcurrentDictionary configCache = new ConcurrentDictionary(); + private readonly ConcurrentDictionary configCache = new ConcurrentDictionary(); - public IRulesetConfigManager GetConfigFor(Ruleset ruleset) => configCache.GetOrAdd(ruleset.ShortName, _ => ruleset.CreateConfig(null)); + public IRulesetConfigManager? GetConfigFor(Ruleset ruleset) => configCache.GetOrAdd(ruleset.ShortName, _ => ruleset.CreateConfig(null)); } } diff --git a/osu.Game/Tests/Visual/DependencyProvidingContainer.cs b/osu.Game/Tests/Visual/DependencyProvidingContainer.cs index d1290fc5ac..acfff4cefe 100644 --- a/osu.Game/Tests/Visual/DependencyProvidingContainer.cs +++ b/osu.Game/Tests/Visual/DependencyProvidingContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; @@ -13,13 +15,12 @@ namespace osu.Game.Tests.Visual /// /// The must be set while this is not loaded. /// - public class DependencyProvidingContainer : Container + public partial class DependencyProvidingContainer : Container { /// /// The dependencies provided to the children. /// - // TODO: should be an init-only property when C# 9 - public (Type, object)[] CachedDependencies { get; set; } = Array.Empty<(Type, object)>(); + public (Type, object)[] CachedDependencies { get; init; } = Array.Empty<(Type, object)>(); protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { diff --git a/osu.Game/Tests/Visual/EditorClockTestScene.cs b/osu.Game/Tests/Visual/EditorClockTestScene.cs index 15e4fc4d8f..204a817b3a 100644 --- a/osu.Game/Tests/Visual/EditorClockTestScene.cs +++ b/osu.Game/Tests/Visual/EditorClockTestScene.cs @@ -1,9 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; using osu.Game.Beatmaps; using osu.Game.Overlays; @@ -15,37 +19,46 @@ namespace osu.Game.Tests.Visual /// Provides a clock, beat-divisor, and scrolling capability for test cases of editor components that /// are preferrably tested within the presence of a clock and seek controls. /// - public abstract class EditorClockTestScene : OsuManualInputManagerTestScene + public abstract partial class EditorClockTestScene : OsuManualInputManagerTestScene { [Cached] private readonly OverlayColourProvider overlayColour = new OverlayColourProvider(OverlayColourScheme.Aquamarine); protected readonly BindableBeatDivisor BeatDivisor = new BindableBeatDivisor(); - [Cached] - protected new readonly EditorClock Clock; + protected EditorClock EditorClock; private readonly Bindable frequencyAdjustment = new BindableDouble(1); + private IBeatmap editorClockBeatmap; protected virtual bool ScrollUsingMouseWheel => true; - protected EditorClockTestScene() - { - Clock = new EditorClock(new Beatmap(), BeatDivisor) { IsCoupled = false }; - } + protected override Container Content => content; + + private readonly Container content = new Container { RelativeSizeAxes = Axes.Both }; protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + editorClockBeatmap = CreateEditorClockBeatmap(); + + base.Content.AddRange(new Drawable[] + { + EditorClock = new EditorClock(editorClockBeatmap, BeatDivisor), + content + }); + dependencies.Cache(BeatDivisor); - dependencies.CacheAs(Clock); + dependencies.CacheAs(EditorClock); return dependencies; } protected override void LoadComplete() { + Beatmap.Value = CreateWorkingBeatmap(editorClockBeatmap); + base.LoadComplete(); Beatmap.BindValueChanged(beatmapChanged, true); @@ -53,22 +66,13 @@ namespace osu.Game.Tests.Visual AddSliderStep("editor clock rate", 0.0, 2.0, 1.0, v => frequencyAdjustment.Value = v); } + protected virtual IBeatmap CreateEditorClockBeatmap() => new Beatmap(); + private void beatmapChanged(ValueChangedEvent e) { e.OldValue?.Track.RemoveAdjustment(AdjustableProperty.Frequency, frequencyAdjustment); - - Clock.Beatmap = e.NewValue.Beatmap; - Clock.ChangeSource(e.NewValue.Track); - Clock.ProcessFrame(); - e.NewValue.Track.AddAdjustment(AdjustableProperty.Frequency, frequencyAdjustment); - } - - protected override void Update() - { - base.Update(); - - Clock.ProcessFrame(); + EditorClock.ChangeSource(e.NewValue.Track); } protected override bool OnScroll(ScrollEvent e) @@ -77,9 +81,9 @@ namespace osu.Game.Tests.Visual return false; if (e.ScrollDelta.Y > 0) - Clock.SeekBackward(true); + EditorClock.SeekBackward(true); else - Clock.SeekForward(true); + EditorClock.SeekForward(true); return true; } diff --git a/osu.Game/Tests/Visual/EditorSavingTestScene.cs b/osu.Game/Tests/Visual/EditorSavingTestScene.cs index cc39ead1de..cd9e9e1d52 100644 --- a/osu.Game/Tests/Visual/EditorSavingTestScene.cs +++ b/osu.Game/Tests/Visual/EditorSavingTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Input; using osu.Framework.Testing; @@ -16,7 +18,7 @@ namespace osu.Game.Tests.Visual /// /// Tests the general expected flow of creating a new beatmap, saving it, then loading it back from song select. /// - public abstract class EditorSavingTestScene : OsuGameTestScene + public abstract partial class EditorSavingTestScene : OsuGameTestScene { protected Editor Editor => Game.ChildrenOfType().FirstOrDefault(); diff --git a/osu.Game/Tests/Visual/EditorTestScene.cs b/osu.Game/Tests/Visual/EditorTestScene.cs index 46f31ae53b..6e2f1e99cd 100644 --- a/osu.Game/Tests/Visual/EditorTestScene.cs +++ b/osu.Game/Tests/Visual/EditorTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -15,15 +17,13 @@ using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; using osu.Game.Rulesets; -using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit; -using osu.Game.Screens.Edit.Compose.Components.Timeline; using osu.Game.Screens.Menu; using osu.Game.Skinning; namespace osu.Game.Tests.Visual { - public abstract class EditorTestScene : ScreenTestScene + public abstract partial class EditorTestScene : ScreenTestScene { private TestEditorLoader editorLoader; @@ -56,15 +56,13 @@ namespace osu.Game.Tests.Visual Dependencies.CacheAs(testBeatmapManager = new TestBeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); } - protected virtual bool EditorComponentsReady => Editor.ChildrenOfType().FirstOrDefault()?.IsLoaded == true - && Editor.ChildrenOfType().FirstOrDefault()?.IsLoaded == true; - public override void SetUpSteps() { base.SetUpSteps(); AddStep("load editor", LoadEditor); - AddUntilStep("wait for editor to load", () => EditorComponentsReady); + AddUntilStep("wait for editor to load", () => Editor?.ReadyForUse == true); + AddUntilStep("wait for beatmap updated", () => !Beatmap.IsDefault); } protected virtual void LoadEditor() @@ -85,7 +83,7 @@ namespace osu.Game.Tests.Visual protected sealed override Ruleset CreateRuleset() => CreateEditorRuleset(); - protected class TestEditorLoader : EditorLoader + protected partial class TestEditorLoader : EditorLoader { public TestEditor Editor { get; private set; } @@ -94,7 +92,7 @@ namespace osu.Game.Tests.Visual protected virtual TestEditor CreateTestEditor(EditorLoader loader) => new TestEditor(loader); } - protected class TestEditor : Editor + protected partial class TestEditor : Editor { [Resolved(canBeNull: true)] [CanBeNull] @@ -104,6 +102,8 @@ namespace osu.Game.Tests.Visual public new void Redo() => base.Redo(); + public new void SetPreviewPointToCurrentTime() => base.SetPreviewPointToCurrentTime(); + public new bool Save() => base.Save(); public new void Cut() => base.Cut(); @@ -112,6 +112,8 @@ namespace osu.Game.Tests.Visual public new void Paste() => base.Paste(); + public new void Clone() => base.Clone(); + public new void SwitchToDifficulty(BeatmapInfo beatmapInfo) => base.SwitchToDifficulty(beatmapInfo); public new void CreateNewDifficulty(RulesetInfo rulesetInfo) => base.CreateNewDifficulty(rulesetInfo); @@ -141,15 +143,10 @@ namespace osu.Game.Tests.Visual public WorkingBeatmap TestBeatmap; public TestBeatmapManager(Storage storage, RealmAccess realm, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore resources, GameHost host, WorkingBeatmap defaultBeatmap) - : base(storage, realm, rulesets, api, audioManager, resources, host, defaultBeatmap) + : base(storage, realm, api, audioManager, resources, host, defaultBeatmap) { } - protected override BeatmapModelManager CreateBeatmapModelManager(Storage storage, RealmAccess realm, RulesetStore rulesets, BeatmapOnlineLookupQueue onlineLookupQueue) - { - return new TestBeatmapModelManager(storage, realm, onlineLookupQueue); - } - protected override WorkingBeatmapCache CreateWorkingBeatmapCache(AudioManager audioManager, IResourceStore resources, IResourceStore storage, WorkingBeatmap defaultBeatmap, GameHost host) { return new TestWorkingBeatmapCache(this, audioManager, resources, storage, defaultBeatmap, host); @@ -181,17 +178,6 @@ namespace osu.Game.Tests.Visual => testBeatmapManager.TestBeatmap; } - internal class TestBeatmapModelManager : BeatmapModelManager - { - public TestBeatmapModelManager(Storage storage, RealmAccess databaseAccess, BeatmapOnlineLookupQueue beatmapOnlineLookupQueue) - : base(databaseAccess, storage, beatmapOnlineLookupQueue) - { - } - - protected override string ComputeHash(BeatmapSetInfo item) - => string.Empty; - } - public override void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin beatmapSkin = null) { // don't actually care about saving for this context. diff --git a/osu.Game/Tests/Visual/LegacySkinPlayerTestScene.cs b/osu.Game/Tests/Visual/LegacySkinPlayerTestScene.cs index d74be70df8..2e254f5b95 100644 --- a/osu.Game/Tests/Visual/LegacySkinPlayerTestScene.cs +++ b/osu.Game/Tests/Visual/LegacySkinPlayerTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -12,7 +14,7 @@ using osu.Game.Skinning; namespace osu.Game.Tests.Visual { [TestFixture] - public abstract class LegacySkinPlayerTestScene : PlayerTestScene + public abstract partial class LegacySkinPlayerTestScene : PlayerTestScene { protected LegacySkin LegacySkin { get; private set; } @@ -43,16 +45,16 @@ namespace osu.Game.Tests.Visual private void addResetTargetsStep() { - AddStep("reset targets", () => this.ChildrenOfType().ForEach(t => + AddStep("reset targets", () => this.ChildrenOfType().ForEach(t => { LegacySkin.ResetDrawableTarget(t); t.Reload(); })); - AddUntilStep("wait for components to load", () => this.ChildrenOfType().All(t => t.ComponentsLoaded)); + AddUntilStep("wait for components to load", () => this.ChildrenOfType().All(t => t.ComponentsLoaded)); } - public class SkinProvidingPlayer : TestPlayer + public partial class SkinProvidingPlayer : TestPlayer { [Cached(typeof(ISkinSource))] private readonly ISkinSource skinSource; diff --git a/osu.Game/Tests/Visual/ModPerfectTestScene.cs b/osu.Game/Tests/Visual/ModPerfectTestScene.cs index 93b38a149c..167d5450e9 100644 --- a/osu.Game/Tests/Visual/ModPerfectTestScene.cs +++ b/osu.Game/Tests/Visual/ModPerfectTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -8,7 +10,7 @@ using osu.Game.Rulesets.Objects; namespace osu.Game.Tests.Visual { - public abstract class ModPerfectTestScene : ModTestScene + public abstract partial class ModPerfectTestScene : ModTestScene { private readonly ModPerfect mod; @@ -31,7 +33,7 @@ namespace osu.Game.Tests.Visual protected override TestPlayer CreateModPlayer(Ruleset ruleset) => new PerfectModTestPlayer(); - private class PerfectModTestPlayer : TestPlayer + private partial class PerfectModTestPlayer : TestPlayer { public PerfectModTestPlayer() : base(showResults: false) diff --git a/osu.Game/Tests/Visual/ModTestScene.cs b/osu.Game/Tests/Visual/ModTestScene.cs index 2505864d59..aa5b506343 100644 --- a/osu.Game/Tests/Visual/ModTestScene.cs +++ b/osu.Game/Tests/Visual/ModTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using JetBrains.Annotations; @@ -14,7 +16,7 @@ using osu.Game.Scoring; namespace osu.Game.Tests.Visual { - public abstract class ModTestScene : PlayerTestScene + public abstract partial class ModTestScene : PlayerTestScene { protected sealed override bool HasCustomSteps => true; @@ -56,7 +58,7 @@ namespace osu.Game.Tests.Visual protected virtual TestPlayer CreateModPlayer(Ruleset ruleset) => new ModTestPlayer(currentTestData, AllowFail); - protected class ModTestPlayer : TestPlayer + protected partial class ModTestPlayer : TestPlayer { private readonly bool allowFail; private readonly ModTestData currentTestData; @@ -64,7 +66,7 @@ namespace osu.Game.Tests.Visual protected override bool CheckModsAllowFailure() => allowFail; public ModTestPlayer(ModTestData data, bool allowFail) - : base(false, false) + : base(true, false) { this.allowFail = allowFail; currentTestData = data; diff --git a/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs b/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs index 62d1c9ceca..0570c4e2f2 100644 --- a/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs +++ b/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Screens.OnlinePlay; using osu.Game.Tests.Visual.OnlinePlay; using osu.Game.Tests.Visual.Spectator; diff --git a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs index a26c6f9be9..93c6e72aa2 100644 --- a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs +++ b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs @@ -1,7 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using NUnit.Framework; +#nullable disable + using osu.Game.Online.Rooms; using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Visual.OnlinePlay; @@ -12,7 +13,7 @@ namespace osu.Game.Tests.Visual.Multiplayer /// /// The base test scene for all multiplayer components and screens. /// - public abstract class MultiplayerTestScene : OnlinePlayTestScene, IMultiplayerTestSceneDependencies + public abstract partial class MultiplayerTestScene : OnlinePlayTestScene, IMultiplayerTestSceneDependencies { public const int PLAYER_1_ID = 55; public const int PLAYER_2_ID = 56; @@ -32,13 +33,6 @@ namespace osu.Game.Tests.Visual.Multiplayer this.joinRoom = joinRoom; } - [SetUp] - public new void Setup() => Schedule(() => - { - if (joinRoom) - SelectedRoom.Value = CreateRoom(); - }); - protected virtual Room CreateRoom() { return new Room @@ -61,7 +55,12 @@ namespace osu.Game.Tests.Visual.Multiplayer if (joinRoom) { - AddStep("join room", () => RoomManager.CreateRoom(SelectedRoom.Value)); + AddStep("join room", () => + { + SelectedRoom.Value = CreateRoom(); + RoomManager.CreateRoom(SelectedRoom.Value); + }); + AddUntilStep("wait for room join", () => RoomJoined); } } diff --git a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestSceneDependencies.cs b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestSceneDependencies.cs index 6b4e01b673..0f286475bd 100644 --- a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestSceneDependencies.cs +++ b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestSceneDependencies.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Game.Online.Multiplayer; using osu.Game.Online.Spectator; using osu.Game.Screens.OnlinePlay; diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 725499d0e5..ad5e3f6c4d 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -1,16 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; +using MessagePack; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; @@ -23,7 +23,7 @@ namespace osu.Game.Tests.Visual.Multiplayer /// /// A for use in multiplayer test scenes. Should generally not be used by itself outside of a . /// - public class TestMultiplayerClient : MultiplayerClient + public partial class TestMultiplayerClient : MultiplayerClient { public override IBindable IsConnected => isConnected; private readonly Bindable isConnected = new Bindable(true); @@ -31,7 +31,32 @@ namespace osu.Game.Tests.Visual.Multiplayer /// /// The local client's . This is not always equivalent to the server-side room. /// - public new Room? APIRoom => base.APIRoom; + public Room? ClientAPIRoom => base.APIRoom; + + /// + /// The local client's . This is not always equivalent to the server-side room. + /// + public MultiplayerRoom? ClientRoom => base.Room; + + /// + /// The server's . This is always up-to-date. + /// + public Room? ServerAPIRoom { get; private set; } + + /// + /// The server's . This is always up-to-date. + /// + public MultiplayerRoom? ServerRoom { get; private set; } + + [Obsolete] + protected new Room APIRoom => throw new InvalidOperationException($"Accessing the client-side API room via {nameof(TestMultiplayerClient)} is unsafe. " + + $"Use {nameof(ClientAPIRoom)} if this was intended."); + + [Obsolete] + public new MultiplayerRoom Room => throw new InvalidOperationException($"Accessing the client-side room via {nameof(TestMultiplayerClient)} is unsafe. " + + $"Use {nameof(ClientRoom)} if this was intended."); + + public new MultiplayerRoomUser? LocalUser => ServerRoom?.Users.SingleOrDefault(u => u.User?.Id == API.LocalUser.Value.Id); public Action? RoomSetupAction; @@ -42,17 +67,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private readonly TestMultiplayerRoomManager roomManager; - /// - /// Guaranteed up-to-date playlist. - /// - private readonly List serverSidePlaylist = new List(); - - /// - /// Guaranteed up-to-date API room. - /// - private Room? serverSideAPIRoom; - - private MultiplayerPlaylistItem? currentItem => Room?.Playlist[currentIndex]; + private MultiplayerPlaylistItem? currentItem => ServerRoom?.Playlist[currentIndex]; private int currentIndex; private long lastPlaylistItemId; @@ -66,13 +81,14 @@ namespace osu.Game.Tests.Visual.Multiplayer public void Disconnect() => isConnected.Value = false; public MultiplayerRoomUser AddUser(APIUser user, bool markAsPlaying = false) - { - var roomUser = new MultiplayerRoomUser(user.Id) { User = user }; + => AddUser(new MultiplayerRoomUser(user.Id) { User = user }, markAsPlaying); + public MultiplayerRoomUser AddUser(MultiplayerRoomUser roomUser, bool markAsPlaying = false) + { addUser(roomUser); if (markAsPlaying) - PlayingUserIds.Add(user.Id); + PlayingUserIds.Add(roomUser.UserID); return roomUser; } @@ -81,158 +97,161 @@ namespace osu.Game.Tests.Visual.Multiplayer private void addUser(MultiplayerRoomUser user) { - ((IMultiplayerClient)this).UserJoined(user).WaitSafely(); + Debug.Assert(ServerRoom != null); - // We want the user to be immediately available for testing, so force a scheduler update to run the update-bound continuation. - Scheduler.Update(); + ServerRoom.Users.Add(user); + ((IMultiplayerClient)this).UserJoined(clone(user)).WaitSafely(); - switch (Room?.MatchState) + switch (ServerRoom?.MatchState) { case TeamVersusRoomState teamVersus: - Debug.Assert(Room != null); - // 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: Room.Users.Count(u => (u.MatchState as TeamVersusUserState)?.TeamID == team.ID))) - .OrderBy(pair => pair.userCount) - .First().teamID; - ((IMultiplayerClient)this).MatchUserStateChanged(user.UserID, new TeamVersusUserState { TeamID = bestTeam }).WaitSafely(); + .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(); break; } } public void RemoveUser(APIUser user) { - Debug.Assert(Room != null); - ((IMultiplayerClient)this).UserLeft(new MultiplayerRoomUser(user.Id)); + Debug.Assert(ServerRoom != null); - Schedule(() => - { - if (Room.Users.Any()) - TransferHost(Room.Users.First().UserID); - }); + ServerRoom.Users.Remove(ServerRoom.Users.Single(u => u.UserID == user.Id)); + ((IMultiplayerClient)this).UserLeft(clone(new MultiplayerRoomUser(user.Id))); + + if (ServerRoom.Users.Any()) + TransferHost(ServerRoom.Users.First().UserID); } public void ChangeRoomState(MultiplayerRoomState newState) { - Debug.Assert(Room != null); - ((IMultiplayerClient)this).RoomStateChanged(newState); + Debug.Assert(ServerRoom != null); + + ServerRoom.State = clone(newState); + + ((IMultiplayerClient)this).RoomStateChanged(clone(ServerRoom.State)); } public void ChangeUserState(int userId, MultiplayerUserState newState) { - Debug.Assert(Room != null); + Debug.Assert(ServerRoom != null); + + var user = ServerRoom.Users.Single(u => u.UserID == userId); + user.State = clone(newState); + + ((IMultiplayerClient)this).UserStateChanged(clone(userId), clone(user.State)); - ((IMultiplayerClient)this).UserStateChanged(userId, newState); updateRoomStateIfRequired(); } private void updateRoomStateIfRequired() { - Debug.Assert(Room != null); - Debug.Assert(APIRoom != null); + Debug.Assert(ServerRoom != null); - Schedule(() => + switch (ServerRoom.State) { - switch (Room.State) - { - case MultiplayerRoomState.Open: - break; + case MultiplayerRoomState.Open: + break; - case MultiplayerRoomState.WaitingForLoad: - if (Room.Users.All(u => u.State != MultiplayerUserState.WaitingForLoad)) + case MultiplayerRoomState.WaitingForLoad: + if (ServerRoom.Users.All(u => u.State != MultiplayerUserState.WaitingForLoad)) + { + var loadedUsers = ServerRoom.Users.Where(u => u.State == MultiplayerUserState.Loaded).ToArray(); + + if (loadedUsers.Length == 0) { - var loadedUsers = Room.Users.Where(u => u.State == MultiplayerUserState.Loaded).ToArray(); - - if (loadedUsers.Length == 0) - { - // all users have bailed from the load sequence. cancel the game start. - ChangeRoomState(MultiplayerRoomState.Open); - return; - } - - foreach (var u in Room.Users.Where(u => u.State == MultiplayerUserState.Loaded)) - ChangeUserState(u.UserID, MultiplayerUserState.Playing); - - ((IMultiplayerClient)this).GameplayStarted(); - - ChangeRoomState(MultiplayerRoomState.Playing); - } - - break; - - case MultiplayerRoomState.Playing: - if (Room.Users.All(u => u.State != MultiplayerUserState.Playing)) - { - foreach (var u in Room.Users.Where(u => u.State == MultiplayerUserState.FinishedPlay)) - ChangeUserState(u.UserID, MultiplayerUserState.Results); - + // all users have bailed from the load sequence. cancel the game start. ChangeRoomState(MultiplayerRoomState.Open); - ((IMultiplayerClient)this).ResultsReady(); - - FinishCurrentItem().WaitSafely(); + return; } - break; - } - }); + foreach (var u in ServerRoom.Users.Where(u => u.State == MultiplayerUserState.Loaded)) + ChangeUserState(u.UserID, MultiplayerUserState.Playing); + + ((IMultiplayerClient)this).GameplayStarted(); + + ChangeRoomState(MultiplayerRoomState.Playing); + } + + break; + + case MultiplayerRoomState.Playing: + if (ServerRoom.Users.All(u => u.State != MultiplayerUserState.Playing)) + { + foreach (var u in ServerRoom.Users.Where(u => u.State == MultiplayerUserState.FinishedPlay)) + ChangeUserState(u.UserID, MultiplayerUserState.Results); + + ChangeRoomState(MultiplayerRoomState.Open); + ((IMultiplayerClient)this).ResultsReady(); + + FinishCurrentItem().WaitSafely(); + } + + break; + } } public void ChangeUserBeatmapAvailability(int userId, BeatmapAvailability newBeatmapAvailability) { - Debug.Assert(Room != null); + Debug.Assert(ServerRoom != null); - ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged(userId, newBeatmapAvailability); + var user = ServerRoom.Users.Single(u => u.UserID == userId); + user.BeatmapAvailability = newBeatmapAvailability; + + ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged(clone(userId), clone(user.BeatmapAvailability)); } protected override async Task JoinRoom(long roomId, string? password = null) { - serverSideAPIRoom = roomManager.ServerSideRooms.Single(r => r.RoomID.Value == roomId); + roomId = clone(roomId); + password = clone(password); - if (password != serverSideAPIRoom.Password.Value) + ServerAPIRoom = roomManager.ServerSideRooms.Single(r => r.RoomID.Value == roomId); + + if (password != ServerAPIRoom.Password.Value) throw new InvalidOperationException("Invalid password."); - serverSidePlaylist.Clear(); - serverSidePlaylist.AddRange(serverSideAPIRoom.Playlist.Select(item => new MultiplayerPlaylistItem(item))); - lastPlaylistItemId = serverSidePlaylist.Max(item => item.ID); + lastPlaylistItemId = ServerAPIRoom.Playlist.Max(item => item.ID); var localUser = new MultiplayerRoomUser(api.LocalUser.Value.Id) { User = api.LocalUser.Value }; - var room = new MultiplayerRoom(roomId) + ServerRoom = new MultiplayerRoom(roomId) { Settings = { - Name = serverSideAPIRoom.Name.Value, - MatchType = serverSideAPIRoom.Type.Value, + Name = ServerAPIRoom.Name.Value, + MatchType = ServerAPIRoom.Type.Value, Password = password, - QueueMode = serverSideAPIRoom.QueueMode.Value, - AutoStartDuration = serverSideAPIRoom.AutoStartDuration.Value + QueueMode = ServerAPIRoom.QueueMode.Value, + AutoStartDuration = ServerAPIRoom.AutoStartDuration.Value }, - Playlist = serverSidePlaylist.ToList(), + Playlist = ServerAPIRoom.Playlist.Select(item => new MultiplayerPlaylistItem(item)).ToList(), Users = { localUser }, Host = localUser }; - await updatePlaylistOrder(room).ConfigureAwait(false); - await updateCurrentItem(room, false).ConfigureAwait(false); + await updatePlaylistOrder(ServerRoom).ConfigureAwait(false); + await updateCurrentItem(ServerRoom, false).ConfigureAwait(false); - RoomSetupAction?.Invoke(room); + RoomSetupAction?.Invoke(ServerRoom); RoomSetupAction = null; - return room; + return clone(ServerRoom); } protected override void OnRoomJoined() { - Debug.Assert(APIRoom != null); - Debug.Assert(Room != null); + Debug.Assert(ServerRoom != null); // emulate the server sending this after the join room. scheduler required to make sure the join room event is fired first (in Join). - changeMatchType(Room.Settings.MatchType).WaitSafely(); + changeMatchType(ServerRoom.Settings.MatchType).WaitSafely(); RoomJoined = true; } @@ -243,29 +262,45 @@ namespace osu.Game.Tests.Visual.Multiplayer return Task.CompletedTask; } - public override Task TransferHost(int userId) => ((IMultiplayerClient)this).HostChanged(userId); + public override Task TransferHost(int userId) + { + userId = clone(userId); + + Debug.Assert(ServerRoom != null); + + ServerRoom.Host = ServerRoom.Users.Single(u => u.UserID == userId); + + return ((IMultiplayerClient)this).HostChanged(clone(userId)); + } public override Task KickUser(int userId) { - Debug.Assert(Room != null); + userId = clone(userId); - return ((IMultiplayerClient)this).UserKicked(Room.Users.Single(u => u.UserID == userId)); + Debug.Assert(ServerRoom != null); + + var user = ServerRoom.Users.Single(u => u.UserID == userId); + ServerRoom.Users.Remove(user); + + return ((IMultiplayerClient)this).UserKicked(clone(user)); } public override async Task ChangeSettings(MultiplayerRoomSettings settings) { - Debug.Assert(Room != null); - Debug.Assert(APIRoom != null); + settings = clone(settings); + + Debug.Assert(ServerRoom != null); Debug.Assert(currentItem != null); // Server is authoritative for the time being. - settings.PlaylistItemId = Room.Settings.PlaylistItemId; + settings.PlaylistItemId = ServerRoom.Settings.PlaylistItemId; + ServerRoom.Settings = settings; await changeQueueMode(settings.QueueMode).ConfigureAwait(false); - await ((IMultiplayerClient)this).SettingsChanged(settings).ConfigureAwait(false); + await ((IMultiplayerClient)this).SettingsChanged(clone(settings)).ConfigureAwait(false); - foreach (var user in Room.Users.Where(u => u.State == MultiplayerUserState.Ready)) + foreach (var user in ServerRoom.Users.Where(u => u.State == MultiplayerUserState.Ready)) ChangeUserState(user.UserID, MultiplayerUserState.Idle); await changeMatchType(settings.MatchType).ConfigureAwait(false); @@ -274,46 +309,52 @@ namespace osu.Game.Tests.Visual.Multiplayer public override Task ChangeState(MultiplayerUserState newState) { - Debug.Assert(Room != null); + newState = clone(newState); if (newState == MultiplayerUserState.Idle && LocalUser?.State == MultiplayerUserState.WaitingForLoad) return Task.CompletedTask; - ChangeUserState(api.LocalUser.Value.Id, newState); + ChangeUserState(api.LocalUser.Value.Id, clone(newState)); return Task.CompletedTask; } public override Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability) { - ChangeUserBeatmapAvailability(api.LocalUser.Value.Id, newBeatmapAvailability); + ChangeUserBeatmapAvailability(api.LocalUser.Value.Id, clone(newBeatmapAvailability)); return Task.CompletedTask; } public void ChangeUserMods(int userId, IEnumerable newMods) - => ChangeUserMods(userId, newMods.Select(m => new APIMod(m)).ToList()); + => ChangeUserMods(userId, newMods.Select(m => new APIMod(m))); public void ChangeUserMods(int userId, IEnumerable newMods) { - Debug.Assert(Room != null); - ((IMultiplayerClient)this).UserModsChanged(userId, newMods.ToList()); + Debug.Assert(ServerRoom != null); + + var user = ServerRoom.Users.Single(u => u.UserID == userId); + user.Mods = newMods.ToArray(); + + ((IMultiplayerClient)this).UserModsChanged(clone(userId), clone(user.Mods)); } public override Task ChangeUserMods(IEnumerable newMods) { - ChangeUserMods(api.LocalUser.Value.Id, newMods); + ChangeUserMods(api.LocalUser.Value.Id, clone(newMods)); return Task.CompletedTask; } public override async Task SendMatchRequest(MatchUserRequest request) { - Debug.Assert(Room != null); + request = clone(request); + + Debug.Assert(ServerRoom != null); Debug.Assert(LocalUser != null); switch (request) { case ChangeTeamRequest changeTeam: - TeamVersusRoomState roomState = (TeamVersusRoomState)Room.MatchState!; + TeamVersusRoomState roomState = (TeamVersusRoomState)ServerRoom.MatchState!; TeamVersusUserState userState = (TeamVersusUserState)LocalUser.MatchState!; var targetTeam = roomState.Teams.FirstOrDefault(t => t.ID == changeTeam.TeamID); @@ -322,7 +363,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { userState.TeamID = targetTeam.ID; - await ((IMultiplayerClient)this).MatchUserStateChanged(LocalUser.UserID, userState).ConfigureAwait(false); + await ((IMultiplayerClient)this).MatchUserStateChanged(clone(LocalUser.UserID), clone(userState)).ConfigureAwait(false); } break; @@ -331,10 +372,10 @@ namespace osu.Game.Tests.Visual.Multiplayer public override Task StartMatch() { - Debug.Assert(Room != null); + Debug.Assert(ServerRoom != null); ChangeRoomState(MultiplayerRoomState.WaitingForLoad); - foreach (var user in Room.Users.Where(u => u.State == MultiplayerUserState.Ready)) + foreach (var user in ServerRoom.Users.Where(u => u.State == MultiplayerUserState.Ready)) ChangeUserState(user.UserID, MultiplayerUserState.WaitingForLoad); return ((IMultiplayerClient)this).LoadRequested(); @@ -342,7 +383,6 @@ namespace osu.Game.Tests.Visual.Multiplayer public override Task AbortGameplay() { - Debug.Assert(Room != null); Debug.Assert(LocalUser != null); ChangeUserState(LocalUser.UserID, MultiplayerUserState.Idle); @@ -352,36 +392,35 @@ namespace osu.Game.Tests.Visual.Multiplayer public async Task AddUserPlaylistItem(int userId, MultiplayerPlaylistItem item) { - Debug.Assert(Room != null); - Debug.Assert(APIRoom != null); + Debug.Assert(ServerRoom != null); Debug.Assert(currentItem != null); - if (Room.Settings.QueueMode == QueueMode.HostOnly && Room.Host?.UserID != LocalUser?.UserID) + if (ServerRoom.Settings.QueueMode == QueueMode.HostOnly && ServerRoom.Host?.UserID != LocalUser?.UserID) throw new InvalidOperationException("Local user is not the room host."); item.OwnerID = userId; await addItem(item).ConfigureAwait(false); - await updateCurrentItem(Room).ConfigureAwait(false); + await updateCurrentItem(ServerRoom).ConfigureAwait(false); updateRoomStateIfRequired(); } - public override Task AddPlaylistItem(MultiplayerPlaylistItem item) => AddUserPlaylistItem(api.LocalUser.Value.OnlineID, item); + public override Task AddPlaylistItem(MultiplayerPlaylistItem item) => AddUserPlaylistItem(api.LocalUser.Value.OnlineID, clone(item)); public async Task EditUserPlaylistItem(int userId, MultiplayerPlaylistItem item) { - Debug.Assert(Room != null); + Debug.Assert(ServerRoom != null); Debug.Assert(currentItem != null); - Debug.Assert(serverSideAPIRoom != null); + Debug.Assert(ServerAPIRoom != null); item.OwnerID = userId; - var existingItem = serverSidePlaylist.SingleOrDefault(i => i.ID == item.ID); + var existingItem = ServerRoom.Playlist.SingleOrDefault(i => i.ID == item.ID); if (existingItem == null) throw new InvalidOperationException("Attempted to change an item that doesn't exist."); - if (existingItem.OwnerID != userId && Room.Host?.UserID != LocalUser?.UserID) + if (existingItem.OwnerID != userId && ServerRoom.Host?.UserID != LocalUser?.UserID) throw new InvalidOperationException("Attempted to change an item which is not owned by the user."); if (existingItem.Expired) @@ -390,21 +429,20 @@ namespace osu.Game.Tests.Visual.Multiplayer // Ensure the playlist order doesn't change. item.PlaylistOrder = existingItem.PlaylistOrder; - serverSidePlaylist[serverSidePlaylist.IndexOf(existingItem)] = item; - serverSideAPIRoom.Playlist[serverSideAPIRoom.Playlist.IndexOf(serverSideAPIRoom.Playlist.Single(i => i.ID == item.ID))] = new PlaylistItem(item); + ServerRoom.Playlist[ServerRoom.Playlist.IndexOf(existingItem)] = item; + ServerAPIRoom.Playlist[ServerAPIRoom.Playlist.IndexOf(ServerAPIRoom.Playlist.Single(i => i.ID == item.ID))] = new PlaylistItem(item); - await ((IMultiplayerClient)this).PlaylistItemChanged(item).ConfigureAwait(false); + await ((IMultiplayerClient)this).PlaylistItemChanged(clone(item)).ConfigureAwait(false); } - public override Task EditPlaylistItem(MultiplayerPlaylistItem item) => EditUserPlaylistItem(api.LocalUser.Value.OnlineID, item); + public override Task EditPlaylistItem(MultiplayerPlaylistItem item) => EditUserPlaylistItem(api.LocalUser.Value.OnlineID, clone(item)); public async Task RemoveUserPlaylistItem(int userId, long playlistItemId) { - Debug.Assert(Room != null); - Debug.Assert(APIRoom != null); - Debug.Assert(serverSideAPIRoom != null); + Debug.Assert(ServerRoom != null); + Debug.Assert(ServerAPIRoom != null); - var item = serverSidePlaylist.Find(i => i.ID == playlistItemId); + var item = ServerRoom.Playlist.FirstOrDefault(i => i.ID == playlistItemId); if (item == null) throw new InvalidOperationException("Item does not exist in the room."); @@ -418,70 +456,78 @@ namespace osu.Game.Tests.Visual.Multiplayer if (item.Expired) throw new InvalidOperationException("Attempted to remove an item which has already been played."); - serverSidePlaylist.Remove(item); - serverSideAPIRoom.Playlist.RemoveAll(i => i.ID == item.ID); - await ((IMultiplayerClient)this).PlaylistItemRemoved(playlistItemId).ConfigureAwait(false); + ServerRoom.Playlist.Remove(item); + ServerAPIRoom.Playlist.RemoveAll(i => i.ID == item.ID); + await ((IMultiplayerClient)this).PlaylistItemRemoved(clone(playlistItemId)).ConfigureAwait(false); - await updateCurrentItem(Room).ConfigureAwait(false); + await updateCurrentItem(ServerRoom).ConfigureAwait(false); updateRoomStateIfRequired(); } - public override Task RemovePlaylistItem(long playlistItemId) => RemoveUserPlaylistItem(api.LocalUser.Value.OnlineID, playlistItemId); + public override Task RemovePlaylistItem(long playlistItemId) => RemoveUserPlaylistItem(api.LocalUser.Value.OnlineID, clone(playlistItemId)); private async Task changeMatchType(MatchType type) { - Debug.Assert(Room != null); + Debug.Assert(ServerRoom != null); switch (type) { case MatchType.HeadToHead: - await ((IMultiplayerClient)this).MatchRoomStateChanged(null).ConfigureAwait(false); + ServerRoom.MatchState = null; + await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).ConfigureAwait(false); + + foreach (var user in ServerRoom.Users) + { + user.MatchState = null; + await ((IMultiplayerClient)this).MatchUserStateChanged(clone(user.UserID), clone(user.MatchState)).ConfigureAwait(false); + } - foreach (var user in Room.Users) - await ((IMultiplayerClient)this).MatchUserStateChanged(user.UserID, null).ConfigureAwait(false); break; case MatchType.TeamVersus: - await ((IMultiplayerClient)this).MatchRoomStateChanged(TeamVersusRoomState.CreateDefault()).ConfigureAwait(false); + ServerRoom.MatchState = TeamVersusRoomState.CreateDefault(); + await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).ConfigureAwait(false); + + foreach (var user in ServerRoom.Users) + { + user.MatchState = new TeamVersusUserState(); + await ((IMultiplayerClient)this).MatchUserStateChanged(clone(user.UserID), clone(user.MatchState)).ConfigureAwait(false); + } - foreach (var user in Room.Users) - await ((IMultiplayerClient)this).MatchUserStateChanged(user.UserID, new TeamVersusUserState()).ConfigureAwait(false); break; } } private async Task changeQueueMode(QueueMode newMode) { - Debug.Assert(Room != null); - Debug.Assert(APIRoom != null); + Debug.Assert(ServerRoom != null); Debug.Assert(currentItem != null); // When changing to host-only mode, ensure that at least one non-expired playlist item exists by duplicating the current item. - if (newMode == QueueMode.HostOnly && serverSidePlaylist.All(item => item.Expired)) + if (newMode == QueueMode.HostOnly && ServerRoom.Playlist.All(item => item.Expired)) await duplicateCurrentItem().ConfigureAwait(false); - await updatePlaylistOrder(Room).ConfigureAwait(false); - await updateCurrentItem(Room).ConfigureAwait(false); + await updatePlaylistOrder(ServerRoom).ConfigureAwait(false); + await updateCurrentItem(ServerRoom).ConfigureAwait(false); } public async Task FinishCurrentItem() { - Debug.Assert(Room != null); - Debug.Assert(APIRoom != null); + Debug.Assert(ServerRoom != null); Debug.Assert(currentItem != null); // Expire the current playlist item. currentItem.Expired = true; currentItem.PlayedAt = DateTimeOffset.Now; - await ((IMultiplayerClient)this).PlaylistItemChanged(currentItem).ConfigureAwait(false); - await updatePlaylistOrder(Room).ConfigureAwait(false); + await ((IMultiplayerClient)this).PlaylistItemChanged(clone(currentItem)).ConfigureAwait(false); + await updatePlaylistOrder(ServerRoom).ConfigureAwait(false); // In host-only mode, a duplicate playlist item will be used for the next round. - if (Room.Settings.QueueMode == QueueMode.HostOnly && serverSidePlaylist.All(item => item.Expired)) + if (ServerRoom.Settings.QueueMode == QueueMode.HostOnly && ServerRoom.Playlist.All(item => item.Expired)) await duplicateCurrentItem().ConfigureAwait(false); - await updateCurrentItem(Room).ConfigureAwait(false); + await updateCurrentItem(ServerRoom).ConfigureAwait(false); } private async Task duplicateCurrentItem() @@ -500,51 +546,54 @@ namespace osu.Game.Tests.Visual.Multiplayer private async Task addItem(MultiplayerPlaylistItem item) { - Debug.Assert(Room != null); - Debug.Assert(serverSideAPIRoom != null); + Debug.Assert(ServerRoom != null); + Debug.Assert(ServerAPIRoom != null); item.ID = ++lastPlaylistItemId; - serverSidePlaylist.Add(item); - serverSideAPIRoom.Playlist.Add(new PlaylistItem(item)); - await ((IMultiplayerClient)this).PlaylistItemAdded(item).ConfigureAwait(false); + ServerRoom.Playlist.Add(item); + ServerAPIRoom.Playlist.Add(new PlaylistItem(item)); + await ((IMultiplayerClient)this).PlaylistItemAdded(clone(item)).ConfigureAwait(false); - await updatePlaylistOrder(Room).ConfigureAwait(false); + await updatePlaylistOrder(ServerRoom).ConfigureAwait(false); } - private IEnumerable upcomingItems => serverSidePlaylist.Where(i => !i.Expired).OrderBy(i => i.PlaylistOrder); + private IEnumerable upcomingItems => ServerRoom?.Playlist.Where(i => !i.Expired).OrderBy(i => i.PlaylistOrder) ?? Enumerable.Empty(); private async Task updateCurrentItem(MultiplayerRoom room, bool notify = true) { - // Pick the next non-expired playlist item by playlist order, or default to the most-recently-expired item. - MultiplayerPlaylistItem nextItem = upcomingItems.FirstOrDefault() ?? serverSidePlaylist.OrderByDescending(i => i.PlayedAt).First(); + Debug.Assert(ServerRoom != null); - currentIndex = serverSidePlaylist.IndexOf(nextItem); + // Pick the next non-expired playlist item by playlist order, or default to the most-recently-expired item. + MultiplayerPlaylistItem nextItem = upcomingItems.FirstOrDefault() ?? ServerRoom.Playlist.OrderByDescending(i => i.PlayedAt).First(); + + currentIndex = ServerRoom.Playlist.IndexOf(nextItem); long lastItem = room.Settings.PlaylistItemId; room.Settings.PlaylistItemId = nextItem.ID; if (notify && nextItem.ID != lastItem) - await ((IMultiplayerClient)this).SettingsChanged(room.Settings).ConfigureAwait(false); + await ((IMultiplayerClient)this).SettingsChanged(clone(room.Settings)).ConfigureAwait(false); } private async Task updatePlaylistOrder(MultiplayerRoom room) { - Debug.Assert(serverSideAPIRoom != null); + Debug.Assert(ServerRoom != null); + Debug.Assert(ServerAPIRoom != null); List orderedActiveItems; switch (room.Settings.QueueMode) { default: - orderedActiveItems = serverSidePlaylist.Where(item => !item.Expired).OrderBy(item => item.ID).ToList(); + orderedActiveItems = ServerRoom.Playlist.Where(item => !item.Expired).OrderBy(item => item.ID).ToList(); break; case QueueMode.AllPlayersRoundRobin: var itemsByPriority = new List<(MultiplayerPlaylistItem item, int priority)>(); // Assign a priority for items from each user, starting from 0 and increasing in order which the user added the items. - foreach (var group in serverSidePlaylist.Where(item => !item.Expired).OrderBy(item => item.ID).GroupBy(item => item.OwnerID)) + foreach (var group in ServerRoom.Playlist.Where(item => !item.Expired).OrderBy(item => item.ID).GroupBy(item => item.OwnerID)) { int priority = 0; itemsByPriority.AddRange(group.Select(item => (item, priority++))); @@ -575,12 +624,18 @@ namespace osu.Game.Tests.Visual.Multiplayer item.PlaylistOrder = (ushort)i; - await ((IMultiplayerClient)this).PlaylistItemChanged(item).ConfigureAwait(false); + await ((IMultiplayerClient)this).PlaylistItemChanged(clone(item)).ConfigureAwait(false); } // Also ensure that the API room's playlist is correct. - foreach (var item in serverSideAPIRoom.Playlist) - item.PlaylistOrder = serverSidePlaylist.Single(i => i.ID == item.ID).PlaylistOrder; + foreach (var item in ServerAPIRoom.Playlist) + item.PlaylistOrder = ServerRoom.Playlist.Single(i => i.ID == item.ID).PlaylistOrder; + } + + private T clone(T incoming) + { + byte[]? serialized = MessagePackSerializer.Serialize(typeof(T), incoming, SignalRUnionWorkaroundResolver.OPTIONS); + return MessagePackSerializer.Deserialize(serialized, SignalRUnionWorkaroundResolver.OPTIONS); } } } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs index 296db3152d..8d04c808fd 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using osu.Game.Online.API.Requests.Responses; @@ -15,7 +17,7 @@ namespace osu.Game.Tests.Visual.Multiplayer /// A for use in multiplayer test scenes. /// Should generally not be used by itself outside of a . /// - public class TestMultiplayerRoomManager : MultiplayerRoomManager + public partial class TestMultiplayerRoomManager : MultiplayerRoomManager { private readonly TestRoomRequestsHandler requestsHandler; diff --git a/osu.Game/Tests/Visual/OnlinePlay/IOnlinePlayTestSceneDependencies.cs b/osu.Game/Tests/Visual/OnlinePlay/IOnlinePlayTestSceneDependencies.cs index c94e288e11..12d1846ece 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/IOnlinePlayTestSceneDependencies.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/IOnlinePlayTestSceneDependencies.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Bindables; using osu.Game.Database; using osu.Game.Online.Rooms; diff --git a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs index df3974664e..87488710a7 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs @@ -1,15 +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; -using System.Threading.Tasks; -using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Database; +using osu.Framework.Logging; using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay; @@ -19,7 +20,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay /// /// A base test scene for all online play components and screens. /// - public abstract class OnlinePlayTestScene : ScreenTestScene, IOnlinePlayTestSceneDependencies + public abstract partial class OnlinePlayTestScene : ScreenTestScene, IOnlinePlayTestSceneDependencies { public Bindable SelectedRoom => OnlinePlayDependencies?.SelectedRoom; public IRoomManager RoomManager => OnlinePlayDependencies?.RoomManager; @@ -54,43 +55,43 @@ namespace osu.Game.Tests.Visual.OnlinePlay return dependencies; } - [SetUp] - public void Setup() => Schedule(() => + public override void SetUpSteps() { - // Reset the room dependencies to a fresh state. - drawableDependenciesContainer.Clear(); - dependencies.OnlinePlayDependencies = CreateOnlinePlayDependencies(); - drawableDependenciesContainer.AddRange(OnlinePlayDependencies.DrawableComponents); + base.SetUpSteps(); - var handler = OnlinePlayDependencies.RequestsHandler; - - // Resolving the BeatmapManager in the test scene will inject the game-wide BeatmapManager, while many test scenes cache their own BeatmapManager instead. - // To get around this, the BeatmapManager is looked up from the dependencies provided to the children of the test scene instead. - var beatmapManager = dependencies.Get(); - - ((DummyAPIAccess)API).HandleRequest = request => + AddStep("setup dependencies", () => { - TaskCompletionSource tcs = new TaskCompletionSource(); + // Reset the room dependencies to a fresh state. + drawableDependenciesContainer.Clear(); + dependencies.OnlinePlayDependencies = CreateOnlinePlayDependencies(); + drawableDependenciesContainer.AddRange(OnlinePlayDependencies.DrawableComponents); - // Because some of the handlers use realm, we need to ensure the game is still alive when firing. - // If we don't, a stray `PerformAsync` could hit an `ObjectDisposedException` if running too late. - Scheduler.Add(() => + var handler = OnlinePlayDependencies.RequestsHandler; + + // Resolving the BeatmapManager in the test scene will inject the game-wide BeatmapManager, while many test scenes cache their own BeatmapManager instead. + // To get around this, the BeatmapManager is looked up from the dependencies provided to the children of the test scene instead. + var beatmapManager = dependencies.Get(); + + ((DummyAPIAccess)API).HandleRequest = request => { - bool result = handler.HandleRequest(request, API.LocalUser.Value, beatmapManager); - tcs.SetResult(result); - }, false); - -#pragma warning disable RS0030 - // We can't GetResultSafely() here (will fail with "Can't use GetResultSafely from inside an async operation."), but Wait is safe enough due to - // the task being a TaskCompletionSource. - // Importantly, this doesn't deadlock because of the scheduler call above running inline where feasible (see the `false` argument). - return tcs.Task.Result; -#pragma warning restore RS0030 - }; - }); + try + { + return handler.HandleRequest(request, API.LocalUser.Value, beatmapManager); + } + catch (ObjectDisposedException) + { + // These requests can be fired asynchronously, but potentially arrive after game components + // have been disposed (ie. realm in BeatmapManager). + // This only happens in tests and it's easiest to ignore them for now. + Logger.Log($"Handled {nameof(ObjectDisposedException)} in test request handling"); + return true; + } + }; + }); + } /// - /// Creates the room dependencies. Called every . + /// Creates the room dependencies. Called every . /// /// /// Any custom dependencies required for online play sub-classes should be added here. @@ -127,7 +128,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay => OnlinePlayDependencies?.Get(type, info) ?? parent.Get(type, info); public void Inject(T instance) - where T : class + where T : class, IDependencyInjectionCandidate => injectableDependencies.Inject(instance); } } diff --git a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestSceneDependencies.cs b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestSceneDependencies.cs index 7c8bc2d535..a9acbdcd7e 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestSceneDependencies.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestSceneDependencies.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using osu.Framework.Allocation; @@ -63,7 +65,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay => dependencies.Get(type, info); public void Inject(T instance) - where T : class + where T : class, IDependencyInjectionCandidate => dependencies.Inject(instance); protected void Cache(object instance) diff --git a/osu.Game/Tests/Visual/OnlinePlay/TestRoomManager.cs b/osu.Game/Tests/Visual/OnlinePlay/TestRoomManager.cs index 3de4e7afd9..e9980e822c 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/TestRoomManager.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/TestRoomManager.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Game.Beatmaps; using osu.Game.Online.API.Requests.Responses; @@ -13,7 +15,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay /// /// A very simple for use in online play test scenes. /// - public class TestRoomManager : RoomManager + public partial class TestRoomManager : RoomManager { public Action JoinRoomRequested; diff --git a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs index 8fea77833e..ef4539ba56 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; @@ -133,25 +135,55 @@ namespace osu.Game.Tests.Visual.OnlinePlay }); return true; + case GetBeatmapRequest getBeatmapRequest: + { + getBeatmapRequest.TriggerSuccess(createResponseBeatmaps(getBeatmapRequest.BeatmapInfo.OnlineID).Single()); + return true; + } + case GetBeatmapsRequest getBeatmapsRequest: - var result = new List(); + { + getBeatmapsRequest.TriggerSuccess(new GetBeatmapsResponse { Beatmaps = createResponseBeatmaps(getBeatmapsRequest.BeatmapIds.ToArray()) }); + return true; + } - foreach (int id in getBeatmapsRequest.BeatmapIds) + case GetBeatmapSetRequest getBeatmapSetRequest: + { + var baseBeatmap = getBeatmapSetRequest.Type == BeatmapSetLookupType.BeatmapId + ? beatmapManager.QueryBeatmap(b => b.OnlineID == getBeatmapSetRequest.ID) + : beatmapManager.QueryBeatmap(b => b.BeatmapSet.OnlineID == getBeatmapSetRequest.ID); + + if (baseBeatmap == null) { - var baseBeatmap = beatmapManager.QueryBeatmap(b => b.OnlineID == id); - - if (baseBeatmap == null) - { - baseBeatmap = new TestBeatmap(new RulesetInfo { OnlineID = 0 }).BeatmapInfo; - baseBeatmap.OnlineID = id; - baseBeatmap.BeatmapSet!.OnlineID = id; - } - - result.Add(OsuTestScene.CreateAPIBeatmap(baseBeatmap)); + baseBeatmap = new TestBeatmap(new RulesetInfo { OnlineID = 0 }).BeatmapInfo; + baseBeatmap.OnlineID = getBeatmapSetRequest.ID; + baseBeatmap.BeatmapSet!.OnlineID = getBeatmapSetRequest.ID; } - getBeatmapsRequest.TriggerSuccess(new GetBeatmapsResponse { Beatmaps = result }); + getBeatmapSetRequest.TriggerSuccess(OsuTestScene.CreateAPIBeatmapSet(baseBeatmap)); return true; + } + } + + List createResponseBeatmaps(params int[] beatmapIds) + { + var result = new List(); + + foreach (int id in beatmapIds) + { + var baseBeatmap = beatmapManager.QueryBeatmap(b => b.OnlineID == id); + + if (baseBeatmap == null) + { + baseBeatmap = new TestBeatmap(new RulesetInfo { OnlineID = 0 }).BeatmapInfo; + baseBeatmap.OnlineID = id; + baseBeatmap.BeatmapSet!.OnlineID = id; + } + + result.Add(OsuTestScene.CreateAPIBeatmap(baseBeatmap)); + } + + return result; } return false; diff --git a/osu.Game/Tests/Visual/OsuGameTestScene.cs b/osu.Game/Tests/Visual/OsuGameTestScene.cs index 6e4adb4d4c..94be4a375d 100644 --- a/osu.Game/Tests/Visual/OsuGameTestScene.cs +++ b/osu.Game/Tests/Visual/OsuGameTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using osu.Framework.Allocation; @@ -15,8 +17,10 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; +using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; +using osu.Game.Online.Spectator; using osu.Game.Overlays; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -32,7 +36,7 @@ namespace osu.Game.Tests.Visual /// /// A scene which tests full game flow. /// - public abstract class OsuGameTestScene : OsuManualInputManagerTestScene + public abstract partial class OsuGameTestScene : OsuManualInputManagerTestScene { protected TestOsuGame Game; @@ -40,6 +44,8 @@ namespace osu.Game.Tests.Visual protected override bool CreateNestedActionContainer => false; + protected override bool DisplayCursorForManualInput => false; + [BackgroundDependencyLoader] private void load() { @@ -56,10 +62,7 @@ namespace osu.Game.Tests.Visual AddStep("Create new game instance", () => { if (Game?.Parent != null) - { - Remove(Game); - Game.Dispose(); - } + Remove(Game, true); RecycleLocalStorage(false); @@ -73,7 +76,7 @@ namespace osu.Game.Tests.Visual } [TearDownSteps] - public void TearDownSteps() + public virtual void TearDownSteps() { if (DebugUtils.IsNUnitRunning && Game != null) { @@ -112,7 +115,7 @@ namespace osu.Game.Tests.Visual /// protected void DismissAnyNotifications() => Game.Notifications.State.Value = Visibility.Hidden; - public class TestOsuGame : OsuGame + public partial class TestOsuGame : OsuGame { public new const float SIDE_OVERLAY_OFFSET_RATIO = OsuGame.SIDE_OVERLAY_OFFSET_RATIO; @@ -120,6 +123,8 @@ namespace osu.Game.Tests.Visual public RealmAccess Realm => Dependencies.Get(); + public new GlobalCursorDisplay GlobalCursorDisplay => base.GlobalCursorDisplay; + public new BackButton BackButton => base.BackButton; public new BeatmapManager BeatmapManager => base.BeatmapManager; @@ -132,6 +137,8 @@ namespace osu.Game.Tests.Visual public new NotificationOverlay Notifications => base.Notifications; + public new FirstRunSetupOverlay FirstRunOverlay => base.FirstRunOverlay; + public new MusicController MusicController => base.MusicController; public new OsuConfigManager LocalConfig => base.LocalConfig; @@ -142,6 +149,8 @@ namespace osu.Game.Tests.Visual public new Bindable> SelectedMods => base.SelectedMods; + public new SpectatorClient SpectatorClient => base.SpectatorClient; + // if we don't apply these changes, when running under nUnit the version that gets populated is that of nUnit. public override Version AssemblyVersion => new Version(0, 0); public override string Version => "test game"; @@ -167,6 +176,11 @@ namespace osu.Game.Tests.Visual API.Login("Rhythm Champion", "osu!"); Dependencies.Get().SetValue(Static.MutedAudioNotificationShownOnce, true); + + // set applied version to latest so that the BackgroundBeatmapProcessor doesn't consider + // beatmap star ratings as outdated and reset them throughout the test. + foreach (var ruleset in RulesetStore.AvailableRulesets) + ruleset.LastAppliedDifficultyVersion = ruleset.CreateInstance().CreateDifficultyCalculator(Beatmap.Default).Version; } protected override void Update() @@ -178,11 +192,11 @@ namespace osu.Game.Tests.Visual } } - public class TestLoader : Loader + public partial class TestLoader : Loader { protected override ShaderPrecompiler CreateShaderPrecompiler() => new TestShaderPrecompiler(); - private class TestShaderPrecompiler : ShaderPrecompiler + private partial class TestShaderPrecompiler : ShaderPrecompiler { protected override bool AllLoaded => true; } diff --git a/osu.Game/Tests/Visual/OsuGridTestScene.cs b/osu.Game/Tests/Visual/OsuGridTestScene.cs index 48f85be6ba..9ef3b2a59d 100644 --- a/osu.Game/Tests/Visual/OsuGridTestScene.cs +++ b/osu.Game/Tests/Visual/OsuGridTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -11,7 +13,7 @@ namespace osu.Game.Tests.Visual /// An abstract test case which exposes small cells arranged in a grid. /// Useful for displaying multiple configurations of a tested component at a glance. /// - public abstract class OsuGridTestScene : OsuTestScene + public abstract partial class OsuGridTestScene : OsuTestScene { private readonly Drawable[,] cells; diff --git a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs index 2deb8686cc..a5e0bddc6b 100644 --- a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs +++ b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,7 +12,7 @@ using osu.Framework.Testing; using osu.Framework.Testing.Input; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Input.Bindings; using osuTK; using osuTK.Graphics; @@ -18,15 +20,15 @@ using osuTK.Input; namespace osu.Game.Tests.Visual { - public abstract class OsuManualInputManagerTestScene : OsuTestScene + public abstract partial class OsuManualInputManagerTestScene : OsuTestScene { protected override Container Content => content; private readonly Container content; protected readonly ManualInputManager InputManager; - private readonly TriangleButton buttonTest; - private readonly TriangleButton buttonLocal; + private readonly RoundedButton buttonTest; + private readonly RoundedButton buttonLocal; /// /// Whether to create a nested container to handle s that result from local (manual) test input. @@ -34,21 +36,30 @@ namespace osu.Game.Tests.Visual /// protected virtual bool CreateNestedActionContainer => true; + /// + /// Whether a menu cursor controlled by the manual input manager should be displayed. + /// True by default, but is disabled for s as they provide their own global cursor. + /// + protected virtual bool DisplayCursorForManualInput => true; + protected OsuManualInputManagerTestScene() { - MenuCursorContainer cursorContainer; + var mainContent = content = new Container { RelativeSizeAxes = Axes.Both }; - CompositeDrawable mainContent = cursorContainer = new MenuCursorContainer { RelativeSizeAxes = Axes.Both }; - - cursorContainer.Child = content = new OsuTooltipContainer(cursorContainer.Cursor) + if (DisplayCursorForManualInput) { - RelativeSizeAxes = Axes.Both - }; + var cursorDisplay = new GlobalCursorDisplay { RelativeSizeAxes = Axes.Both }; + + cursorDisplay.Add(content = new OsuTooltipContainer(cursorDisplay.MenuCursor) + { + RelativeSizeAxes = Axes.Both, + }); + + mainContent.Add(cursorDisplay); + } if (CreateNestedActionContainer) - { - mainContent = new GlobalActionContainer(null).WithChild(mainContent); - } + mainContent.Add(new GlobalActionContainer(null)); base.Content.AddRange(new Drawable[] { @@ -98,13 +109,13 @@ namespace osu.Game.Tests.Visual Children = new Drawable[] { - buttonLocal = new TriangleButton + buttonLocal = new RoundedButton { Text = "local", Size = new Vector2(50, 30), Action = returnUserInput }, - buttonTest = new TriangleButton + buttonTest = new RoundedButton { Text = "test", Size = new Vector2(50, 30), diff --git a/osu.Game/Tests/Visual/OsuTestScene.cs b/osu.Game/Tests/Visual/OsuTestScene.cs index 1582bdfca4..46c7c3a57c 100644 --- a/osu.Game/Tests/Visual/OsuTestScene.cs +++ b/osu.Game/Tests/Visual/OsuTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Diagnostics; @@ -35,7 +37,7 @@ using osu.Game.Tests.Rulesets; namespace osu.Game.Tests.Visual { [ExcludeFromDynamicCompile] - public abstract class OsuTestScene : TestScene + public abstract partial class OsuTestScene : TestScene { [Cached] protected Bindable Beatmap { get; } = new Bindable(); @@ -363,6 +365,11 @@ namespace osu.Game.Tests.Visual } else track = audio?.Tracks.GetVirtual(trackLength); + + // We are guaranteed to have a virtual track. + // To ease testability, ensure the track is available from point of construction. + // (Usually this would be done by MusicController for us). + LoadTrack(); } ~ClockBackedTestWorkingBeatmap() @@ -397,9 +404,9 @@ namespace osu.Game.Tests.Visual public IEnumerable GetAvailableResources() => throw new NotImplementedException(); - public Track GetVirtual(double length = double.PositiveInfinity) + public Track GetVirtual(double length = double.PositiveInfinity, string name = "virtual") { - var track = new TrackVirtualManual(referenceClock) { Length = length }; + var track = new TrackVirtualManual(referenceClock, name) { Length = length }; AddItem(track); return track; } @@ -414,7 +421,8 @@ namespace osu.Game.Tests.Visual private bool running; - public TrackVirtualManual(IFrameBasedClock referenceClock) + public TrackVirtualManual(IFrameBasedClock referenceClock, string name = "virtual") + : base(name) { this.referenceClock = referenceClock; Length = double.PositiveInfinity; @@ -428,11 +436,19 @@ namespace osu.Game.Tests.Visual return accumulated == seek; } + public override Task SeekAsync(double seek) => Task.FromResult(Seek(seek)); + public override void Start() { running = true; } + public override Task StartAsync() + { + Start(); + return Task.CompletedTask; + } + public override void Reset() { Seek(0); @@ -448,6 +464,12 @@ namespace osu.Game.Tests.Visual } } + public override Task StopAsync() + { + Stop(); + return Task.CompletedTask; + } + public override bool IsRunning => running; private double? lastReferenceTime; @@ -481,7 +503,7 @@ namespace osu.Game.Tests.Visual } } - public class OsuTestSceneTestRunner : OsuGameBase, ITestSceneTestRunner + public partial class OsuTestSceneTestRunner : OsuGameBase, ITestSceneTestRunner { private TestSceneTestRunner.TestRunner runner; diff --git a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs index b981a31bd1..0027e03492 100644 --- a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs +++ b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -14,7 +16,7 @@ using osu.Game.Screens.Edit.Compose; namespace osu.Game.Tests.Visual { - public abstract class PlacementBlueprintTestScene : OsuManualInputManagerTestScene, IPlacementHandler + public abstract partial class PlacementBlueprintTestScene : OsuManualInputManagerTestScene, IPlacementHandler { protected readonly Container HitObjectContainer; protected PlacementBlueprint CurrentBlueprint { get; private set; } @@ -28,10 +30,15 @@ namespace osu.Game.Tests.Visual { var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - dependencies.CacheAs(new EditorClock()); - var playable = GetPlayableBeatmap(); - dependencies.CacheAs(new EditorBeatmap(playable)); + + var editorClock = new EditorClock(); + base.Content.Add(editorClock); + dependencies.CacheAs(editorClock); + + var editorBeatmap = new EditorBeatmap(playable); + // Not adding to hierarchy as we don't satisfy its dependencies. Probably not good. + dependencies.CacheAs(editorBeatmap); return dependencies; } @@ -65,7 +72,7 @@ namespace osu.Game.Tests.Visual protected void ResetPlacement() { if (CurrentBlueprint != null) - Remove(CurrentBlueprint); + Remove(CurrentBlueprint, true); Add(CurrentBlueprint = CreateBlueprint()); } diff --git a/osu.Game/Tests/Visual/PlayerTestScene.cs b/osu.Game/Tests/Visual/PlayerTestScene.cs index b34f7e2d5f..0392e3ae52 100644 --- a/osu.Game/Tests/Visual/PlayerTestScene.cs +++ b/osu.Game/Tests/Visual/PlayerTestScene.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; -using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Logging; using osu.Framework.Testing; using osu.Game.Configuration; using osu.Game.Rulesets; @@ -13,7 +15,7 @@ using osu.Game.Rulesets.Mods; namespace osu.Game.Tests.Visual { - public abstract class PlayerTestScene : RateAdjustedBeatmapTestScene + public abstract partial class PlayerTestScene : RateAdjustedBeatmapTestScene { /// /// Whether custom test steps are provided. Custom tests should invoke to create the test steps. @@ -24,6 +26,24 @@ namespace osu.Game.Tests.Visual protected OsuConfigManager LocalConfig; + private double lastReportedTime; + + protected override void Update() + { + base.Update(); + + if (Player?.GameplayClockContainer != null) + { + int roundedTime = (int)Player.GameplayClockContainer.CurrentTime / 1000; + + if (roundedTime != lastReportedTime) + { + lastReportedTime = roundedTime; + Logger.Log($"⏱️ Gameplay clock reached {lastReportedTime * 1000:N0} ms"); + } + } + } + [BackgroundDependencyLoader] private void load() { @@ -37,17 +57,17 @@ namespace osu.Game.Tests.Visual base.SetUpSteps(); if (!HasCustomSteps) - CreateTest(null); + CreateTest(); } - protected void CreateTest(Action action) + protected void CreateTest([CanBeNull] Action action = null) { if (action != null && !HasCustomSteps) throw new InvalidOperationException($"Cannot add custom test steps without {nameof(HasCustomSteps)} being set."); action?.Invoke(); - AddStep(CreatePlayerRuleset().Description, LoadPlayer); + AddStep($"Load player for {CreatePlayerRuleset().Description}", LoadPlayer); AddUntilStep("player loaded", () => Player.IsLoaded && Player.Alpha == 1); } @@ -55,7 +75,9 @@ namespace osu.Game.Tests.Visual protected virtual bool Autoplay => false; - protected void LoadPlayer() + protected void LoadPlayer() => LoadPlayer(Array.Empty()); + + protected void LoadPlayer(Mod[] mods) { var ruleset = CreatePlayerRuleset(); Ruleset.Value = ruleset.RulesetInfo; @@ -63,20 +85,21 @@ namespace osu.Game.Tests.Visual var beatmap = CreateBeatmap(ruleset.RulesetInfo); Beatmap.Value = CreateWorkingBeatmap(beatmap); - SelectedMods.Value = Array.Empty(); + + SelectedMods.Value = mods; if (!AllowFail) { var noFailMod = ruleset.CreateMod(); if (noFailMod != null) - SelectedMods.Value = new[] { noFailMod }; + SelectedMods.Value = SelectedMods.Value.Append(noFailMod).ToArray(); } if (Autoplay) { var mod = ruleset.GetAutoplayMod(); if (mod != null) - SelectedMods.Value = SelectedMods.Value.Concat(mod.Yield()).ToArray(); + SelectedMods.Value = SelectedMods.Value.Append(mod).ToArray(); } Player = CreatePlayer(ruleset); diff --git a/osu.Game/Tests/Visual/RateAdjustedBeatmapTestScene.cs b/osu.Game/Tests/Visual/RateAdjustedBeatmapTestScene.cs index 1034f208a9..a77ea80958 100644 --- a/osu.Game/Tests/Visual/RateAdjustedBeatmapTestScene.cs +++ b/osu.Game/Tests/Visual/RateAdjustedBeatmapTestScene.cs @@ -6,7 +6,7 @@ namespace osu.Game.Tests.Visual /// /// Test case which adjusts the beatmap's rate to match any speed adjustments in visual tests. /// - public abstract class RateAdjustedBeatmapTestScene : ScreenTestScene + public abstract partial class RateAdjustedBeatmapTestScene : ScreenTestScene { protected override void Update() { diff --git a/osu.Game/Tests/Visual/ScreenTestScene.cs b/osu.Game/Tests/Visual/ScreenTestScene.cs index 0fa2f3e786..7d382ca1bc 100644 --- a/osu.Game/Tests/Visual/ScreenTestScene.cs +++ b/osu.Game/Tests/Visual/ScreenTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -18,7 +20,7 @@ namespace osu.Game.Tests.Visual /// /// A test case which can be used to test a screen (that relies on OnEntering being called to execute startup instructions). /// - public abstract class ScreenTestScene : OsuManualInputManagerTestScene, IOverlayManager + public abstract partial class ScreenTestScene : OsuManualInputManagerTestScene, IOverlayManager { protected readonly OsuScreenStack Stack; @@ -47,8 +49,8 @@ namespace osu.Game.Tests.Visual } }); - Stack.ScreenPushed += (lastScreen, newScreen) => Logger.Log($"{nameof(ScreenTestScene)} screen changed → {newScreen}"); - Stack.ScreenExited += (lastScreen, newScreen) => Logger.Log($"{nameof(ScreenTestScene)} screen changed ← {newScreen}"); + Stack.ScreenPushed += (_, newScreen) => Logger.Log($"{nameof(ScreenTestScene)} screen changed → {newScreen}"); + Stack.ScreenExited += (_, newScreen) => Logger.Log($"{nameof(ScreenTestScene)} screen changed ← {newScreen}"); } protected void LoadScreen(OsuScreen screen) => Stack.Push(screen); diff --git a/osu.Game/Tests/Visual/ScrollingTestContainer.cs b/osu.Game/Tests/Visual/ScrollingTestContainer.cs index 994f23577d..b8b39e16b5 100644 --- a/osu.Game/Tests/Visual/ScrollingTestContainer.cs +++ b/osu.Game/Tests/Visual/ScrollingTestContainer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; @@ -16,7 +18,7 @@ namespace osu.Game.Tests.Visual /// A container which provides a to children. /// This should only be used when testing /// - public class ScrollingTestContainer : Container + public partial class ScrollingTestContainer : Container { public SortedList ControlPoints => scrollingInfo.Algorithm.ControlPoints; @@ -97,8 +99,8 @@ namespace osu.Game.Tests.Visual public float GetLength(double startTime, double endTime, double timeRange, float scrollLength) => implementation.GetLength(startTime, endTime, timeRange, scrollLength); - public float PositionAt(double time, double currentTime, double timeRange, float scrollLength) - => implementation.PositionAt(time, currentTime, timeRange, scrollLength); + public float PositionAt(double time, double currentTime, double timeRange, float scrollLength, double? originTime = null) + => implementation.PositionAt(time, currentTime, timeRange, scrollLength, originTime); public double TimeAt(float position, double currentTime, double timeRange, float scrollLength) => implementation.TimeAt(position, currentTime, timeRange, scrollLength); diff --git a/osu.Game/Tests/Visual/SelectionBlueprintTestScene.cs b/osu.Game/Tests/Visual/SelectionBlueprintTestScene.cs index 5448783f6d..350410e7c6 100644 --- a/osu.Game/Tests/Visual/SelectionBlueprintTestScene.cs +++ b/osu.Game/Tests/Visual/SelectionBlueprintTestScene.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 JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -12,24 +11,28 @@ using osu.Game.Screens.Edit; namespace osu.Game.Tests.Visual { - public abstract class SelectionBlueprintTestScene : OsuManualInputManagerTestScene + public abstract partial class SelectionBlueprintTestScene : OsuManualInputManagerTestScene { [Cached] private readonly EditorClock editorClock = new EditorClock(); - protected override Container Content => content ?? base.Content; + protected override Container Content => content; private readonly Container content; protected SelectionBlueprintTestScene() { - base.Content.Add(content = new Container + base.Content.AddRange(new Drawable[] { - Clock = new FramedClock(new StopwatchClock()), - RelativeSizeAxes = Axes.Both + editorClock, + content = new Container + { + Clock = new FramedClock(new StopwatchClock()), + RelativeSizeAxes = Axes.Both + } }); } - protected void AddBlueprint(HitObjectSelectionBlueprint blueprint, [CanBeNull] DrawableHitObject drawableObject = null) + protected void AddBlueprint(HitObjectSelectionBlueprint blueprint, DrawableHitObject? drawableObject = null) { Add(blueprint.With(d => { diff --git a/osu.Game/Tests/Visual/SkinnableTestScene.cs b/osu.Game/Tests/Visual/SkinnableTestScene.cs index 296ed80e37..aab1b72990 100644 --- a/osu.Game/Tests/Visual/SkinnableTestScene.cs +++ b/osu.Game/Tests/Visual/SkinnableTestScene.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Text.RegularExpressions; @@ -9,7 +11,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; @@ -25,10 +27,12 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual { - public abstract class SkinnableTestScene : OsuGridTestScene, IStorageResourceProvider + public abstract partial class SkinnableTestScene : OsuGridTestScene, IStorageResourceProvider { + private TrianglesSkin trianglesSkin; private Skin metricsSkin; - private Skin defaultSkin; + private Skin legacySkin; + private Skin argonSkin; private Skin specialSkin; private Skin oldSkin; @@ -45,8 +49,10 @@ namespace osu.Game.Tests.Visual { var dllStore = new DllResourceStore(GetType().Assembly); + argonSkin = new ArgonSkin(this); + trianglesSkin = new TrianglesSkin(this); metricsSkin = new TestLegacySkin(new SkinInfo { Name = "metrics-skin" }, new NamespacedResourceStore(dllStore, "Resources/metrics_skin"), this, true); - defaultSkin = new DefaultLegacySkin(this); + legacySkin = new DefaultLegacySkin(this); specialSkin = new TestLegacySkin(new SkinInfo { Name = "special-skin" }, new NamespacedResourceStore(dllStore, "Resources/special_skin"), this, true); oldSkin = new TestLegacySkin(new SkinInfo { Name = "old-skin" }, new NamespacedResourceStore(dllStore, "Resources/old_skin"), this, true); } @@ -59,11 +65,12 @@ namespace osu.Game.Tests.Visual var beatmap = CreateBeatmapForSkinProvider(); - Cell(0).Child = createProvider(null, creationFunction, beatmap); - Cell(1).Child = createProvider(metricsSkin, creationFunction, beatmap); - Cell(2).Child = createProvider(defaultSkin, creationFunction, beatmap); - Cell(3).Child = createProvider(specialSkin, creationFunction, beatmap); - Cell(4).Child = createProvider(oldSkin, creationFunction, beatmap); + Cell(0).Child = createProvider(argonSkin, creationFunction, beatmap); + Cell(1).Child = createProvider(trianglesSkin, creationFunction, beatmap); + Cell(2).Child = createProvider(metricsSkin, creationFunction, beatmap); + Cell(3).Child = createProvider(legacySkin, creationFunction, beatmap); + Cell(4).Child = createProvider(specialSkin, creationFunction, beatmap); + Cell(5).Child = createProvider(oldSkin, creationFunction, beatmap); } protected IEnumerable CreatedDrawables => createdDrawables; @@ -78,16 +85,13 @@ namespace osu.Game.Tests.Visual OutlineBox outlineBox; SkinProvidingContainer skinProvider; - ISkin provider = skin; - - if (provider is LegacySkin legacyProvider) - provider = Ruleset.Value.CreateInstance().CreateLegacySkinProvider(legacyProvider, beatmap); + ISkin provider = Ruleset.Value.CreateInstance().CreateSkinTransformer(skin, beatmap) ?? skin; var children = new Container { RelativeSizeAxes = Axes.Both, BorderColour = Color4.White, - BorderThickness = 5, + BorderThickness = 3, Masking = true, Children = new Drawable[] @@ -100,7 +104,7 @@ namespace osu.Game.Tests.Visual }, new OsuSpriteText { - Text = skin?.SkinInfo.Value.Name ?? "none", + Text = skin.SkinInfo.Value.Name, Scale = new Vector2(1.5f), Padding = new MarginPadding(5), }, @@ -136,9 +140,17 @@ namespace osu.Game.Tests.Visual { c.RelativeSizeAxes = Axes.None; c.AutoSizeAxes = Axes.None; + c.Size = Vector2.Zero; - c.RelativeSizeAxes = !autoSize ? Axes.Both : Axes.None; - c.AutoSizeAxes = autoSize ? Axes.Both : Axes.None; + if (autoSize) + c.AutoSizeAxes = Axes.Both; + else + { + c.RelativeSizeAxes = Axes.Both; + c.Anchor = Anchor.Centre; + c.Origin = Anchor.Centre; + c.Size = new Vector2(0.97f); + } } outlineBox.Alpha = autoSize ? 1 : 0; @@ -157,6 +169,7 @@ namespace osu.Game.Tests.Visual #region IResourceStorageProvider + public IRenderer Renderer => host.Renderer; public AudioManager AudioManager => Audio; public IResourceStore Files => null; public new IResourceStore Resources => base.Resources; @@ -165,7 +178,7 @@ namespace osu.Game.Tests.Visual #endregion - private class OutlineBox : CompositeDrawable + private partial class OutlineBox : CompositeDrawable { public OutlineBox() { diff --git a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs index ac7cb43e02..1db35b3aaa 100644 --- a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs +++ b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs @@ -1,11 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable enable - using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -18,7 +17,7 @@ using osu.Game.Scoring; namespace osu.Game.Tests.Visual.Spectator { - public class TestSpectatorClient : SpectatorClient + public partial class TestSpectatorClient : SpectatorClient { /// /// Maximum number of frames sent per bundle via . @@ -39,6 +38,7 @@ namespace osu.Game.Tests.Visual.Spectator private readonly Dictionary lastReceivedUserFrames = new Dictionary(); private readonly Dictionary userBeatmapDictionary = new Dictionary(); + private readonly Dictionary userModsDictionary = new Dictionary(); private readonly Dictionary userNextFrameDictionary = new Dictionary(); [Resolved] @@ -54,9 +54,11 @@ namespace osu.Game.Tests.Visual.Spectator /// /// The user to start play for. /// The playing beatmap id. - public void SendStartPlay(int userId, int beatmapId) + /// The mods the user has applied. + public void SendStartPlay(int userId, int beatmapId, APIMod[]? mods = null) { userBeatmapDictionary[userId] = beatmapId; + userModsDictionary[userId] = mods ?? Array.Empty(); userNextFrameDictionary[userId] = 0; sendPlayingState(userId); } @@ -75,10 +77,12 @@ namespace osu.Game.Tests.Visual.Spectator { BeatmapID = userBeatmapDictionary[userId], RulesetID = 0, + Mods = userModsDictionary[userId], State = state }); userBeatmapDictionary.Remove(userId); + userModsDictionary.Remove(userId); } /// @@ -122,11 +126,12 @@ namespace osu.Game.Tests.Visual.Spectator } } - protected override Task BeginPlayingInternal(SpectatorState state) + protected override Task BeginPlayingInternal(long? scoreToken, SpectatorState state) { // Track the local user's playing beatmap ID. Debug.Assert(state.BeatmapID != null); userBeatmapDictionary[api.LocalUser.Value.Id] = state.BeatmapID.Value; + userModsDictionary[api.LocalUser.Value.Id] = state.Mods.ToArray(); return ((ISpectatorClient)this).UserBeganPlaying(api.LocalUser.Value.Id, state); } @@ -136,7 +141,7 @@ namespace osu.Game.Tests.Visual.Spectator FrameSendAttempts++; if (ShouldFailSendingFrames) - return Task.FromException(new InvalidOperationException()); + return Task.FromException(new InvalidOperationException($"Intentional fail via {nameof(ShouldFailSendingFrames)}")); return ((ISpectatorClient)this).UserSentFrames(api.LocalUser.Value.Id, bundle); } @@ -160,6 +165,7 @@ namespace osu.Game.Tests.Visual.Spectator { BeatmapID = userBeatmapDictionary[userId], RulesetID = 0, + Mods = userModsDictionary[userId], State = SpectatedUserState.Playing }); } diff --git a/osu.Game/Tests/Visual/TestPlayer.cs b/osu.Game/Tests/Visual/TestPlayer.cs index 66a956ca3d..d9cae6b03b 100644 --- a/osu.Game/Tests/Visual/TestPlayer.cs +++ b/osu.Game/Tests/Visual/TestPlayer.cs @@ -1,13 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Screens; using osu.Game.Online.API; using osu.Game.Online.Rooms; +using osu.Game.Online.Spectator; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; @@ -20,7 +24,7 @@ namespace osu.Game.Tests.Visual /// /// A player that exposes many components that would otherwise not be available, for testing purposes. /// - public class TestPlayer : SoloPlayer + public partial class TestPlayer : SoloPlayer { protected override bool PauseOnFocusLost { get; } @@ -44,6 +48,9 @@ namespace osu.Game.Tests.Visual public readonly List Results = new List(); + [Resolved] + private SpectatorClient spectatorClient { get; set; } + public TestPlayer(bool allowPause = true, bool showResults = true, bool pauseOnFocusLost = false) : base(new PlayerConfiguration { @@ -96,5 +103,28 @@ namespace osu.Game.Tests.Visual ScoreProcessor.NewJudgement += r => Results.Add(r); } + + public override bool OnExiting(ScreenExitEvent e) + { + bool exiting = base.OnExiting(e); + + // SubmittingPlayer performs EndPlaying on a fire-and-forget async task, which allows for the chance of BeginPlaying to be called before EndPlaying is called here. + // Until this is handled properly at game-side, ensure EndPlaying is called before exiting player. + // see: https://github.com/ppy/osu/issues/22220 + if (LoadedBeatmapSuccessfully) + spectatorClient?.EndPlaying(GameplayState); + + return exiting; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + // Specific to tests, the player can be disposed without OnExiting() ever being called. + // We should make sure that the gameplay session has finished even in this case. + if (LoadedBeatmapSuccessfully) + spectatorClient?.EndPlaying(GameplayState); + } } } diff --git a/osu.Game/Tests/Visual/TestReplayPlayer.cs b/osu.Game/Tests/Visual/TestReplayPlayer.cs index bacb2427b0..bc6dc9bb27 100644 --- a/osu.Game/Tests/Visual/TestReplayPlayer.cs +++ b/osu.Game/Tests/Visual/TestReplayPlayer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using System.Linq; using osu.Framework.Bindables; @@ -15,7 +17,7 @@ namespace osu.Game.Tests.Visual /// /// A player that exposes many components that would otherwise not be available, for testing purposes. /// - public class TestReplayPlayer : ReplayPlayer + public partial class TestReplayPlayer : ReplayPlayer { protected override bool PauseOnFocusLost { get; } diff --git a/osu.Game/Tests/Visual/TestUserLookupCache.cs b/osu.Game/Tests/Visual/TestUserLookupCache.cs index ce1bbd5f15..a3028f1a34 100644 --- a/osu.Game/Tests/Visual/TestUserLookupCache.cs +++ b/osu.Game/Tests/Visual/TestUserLookupCache.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Threading; using System.Threading.Tasks; using osu.Game.Database; @@ -8,7 +10,7 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Tests.Visual { - public class TestUserLookupCache : UserLookupCache + public partial class TestUserLookupCache : UserLookupCache { /// /// A special user ID which would return a for. diff --git a/osu.Game/Tests/VisualTestRunner.cs b/osu.Game/Tests/VisualTestRunner.cs index 6aa75ec147..c8279b9e3c 100644 --- a/osu.Game/Tests/VisualTestRunner.cs +++ b/osu.Game/Tests/VisualTestRunner.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework; using osu.Framework.Platform; @@ -12,7 +14,7 @@ namespace osu.Game.Tests [STAThread] public static int Main(string[] args) { - using (DesktopGameHost host = Host.GetSuitableDesktopHost(@"osu", new HostOptions { BindIPC = true, })) + using (DesktopGameHost host = Host.GetSuitableDesktopHost(@"osu-development", new HostOptions { BindIPC = true, })) { host.Run(new OsuTestBrowser()); return 0; diff --git a/osu.Game/Updater/GitHubAsset.cs b/osu.Game/Updater/GitHubAsset.cs index 4783161859..a5dfd9e1ac 100644 --- a/osu.Game/Updater/GitHubAsset.cs +++ b/osu.Game/Updater/GitHubAsset.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using Newtonsoft.Json; namespace osu.Game.Updater diff --git a/osu.Game/Updater/GitHubRelease.cs b/osu.Game/Updater/GitHubRelease.cs index 363b2b628f..effabdbc04 100644 --- a/osu.Game/Updater/GitHubRelease.cs +++ b/osu.Game/Updater/GitHubRelease.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Collections.Generic; using Newtonsoft.Json; diff --git a/osu.Game/Updater/NoActionUpdateManager.cs b/osu.Game/Updater/NoActionUpdateManager.cs index 8f9c4c6f16..97d3275757 100644 --- a/osu.Game/Updater/NoActionUpdateManager.cs +++ b/osu.Game/Updater/NoActionUpdateManager.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; @@ -14,7 +16,7 @@ namespace osu.Game.Updater /// An update manager that shows notifications if a newer release is detected. /// This is a case where updates are handled externally by a package manager or other means, so no action is performed on clicking the notification. /// - public class NoActionUpdateManager : UpdateManager + public partial class NoActionUpdateManager : UpdateManager { private string version; diff --git a/osu.Game/Updater/SimpleUpdateManager.cs b/osu.Game/Updater/SimpleUpdateManager.cs index c57a7c768e..1ecb73a154 100644 --- a/osu.Game/Updater/SimpleUpdateManager.cs +++ b/osu.Game/Updater/SimpleUpdateManager.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Linq; using System.Runtime.InteropServices; @@ -18,7 +20,7 @@ namespace osu.Game.Updater /// An update manager that shows notifications if a newer release is detected. /// Installation is left up to the user. /// - public class SimpleUpdateManager : UpdateManager + public partial class SimpleUpdateManager : UpdateManager { private string version; diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs index c17d8304b9..47c2a169ed 100644 --- a/osu.Game/Updater/UpdateManager.cs +++ b/osu.Game/Updater/UpdateManager.cs @@ -3,19 +3,21 @@ using System.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; +using osuTK; namespace osu.Game.Updater { /// /// An update manager which only shows notifications after an update completes. /// - public class UpdateManager : CompositeDrawable + public partial class UpdateManager : CompositeDrawable { /// /// Whether this UpdateManager should be or is capable of checking for updates. @@ -25,13 +27,13 @@ namespace osu.Game.Updater GetType() != typeof(UpdateManager); [Resolved] - private OsuConfigManager config { get; set; } + private OsuConfigManager config { get; set; } = null!; [Resolved] - private OsuGameBase game { get; set; } + private OsuGameBase game { get; set; } = null!; [Resolved] - protected INotificationOverlay Notifications { get; private set; } + protected INotificationOverlay Notifications { get; private set; } = null!; protected override void LoadComplete() { @@ -57,7 +59,7 @@ namespace osu.Game.Updater private readonly object updateTaskLock = new object(); - private Task updateCheckTask; + private Task? updateCheckTask; public async Task CheckForUpdateAsync() { @@ -83,7 +85,7 @@ namespace osu.Game.Updater /// Whether any update is waiting. May return true if an error occured (there is potentially an update available). protected virtual Task PerformUpdateCheck() => Task.FromResult(false); - private class UpdateCompleteNotification : SimpleNotification + private partial class UpdateCompleteNotification : SimpleNotification { private readonly string version; @@ -97,7 +99,7 @@ namespace osu.Game.Updater private void load(OsuColour colours, ChangelogOverlay changelog, INotificationOverlay notificationOverlay) { Icon = FontAwesome.Solid.CheckSquare; - IconBackground.Colour = colours.BlueDark; + IconContent.Colour = colours.BlueDark; Activated = delegate { @@ -107,5 +109,77 @@ namespace osu.Game.Updater }; } } + + public partial class UpdateApplicationCompleteNotification : ProgressCompletionNotification + { + public UpdateApplicationCompleteNotification() + { + Text = @"Update ready to install. Click to restart!"; + } + } + + public partial class UpdateProgressNotification : ProgressNotification + { + protected override Notification CreateCompletionNotification() => new UpdateApplicationCompleteNotification + { + Activated = CompletionClickAction + }; + + [BackgroundDependencyLoader] + private void load() + { + IconContent.AddRange(new Drawable[] + { + new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.Upload, + Size = new Vector2(34), + Colour = OsuColour.Gray(0.2f), + Depth = float.MaxValue, + } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + StartDownload(); + } + + public override void Close(bool runFlingAnimation) + { + // cancelling updates is not currently supported by the underlying updater. + // only allow dismissing for now. + + switch (State) + { + case ProgressNotificationState.Cancelled: + case ProgressNotificationState.Completed: + base.Close(runFlingAnimation); + break; + } + } + + public void StartDownload() + { + State = ProgressNotificationState.Active; + Progress = 0; + Text = @"Downloading update..."; + } + + public void StartInstall() + { + Progress = 0; + Text = @"Installing update..."; + } + + public void FailDownload() + { + State = ProgressNotificationState.Cancelled; + Close(false); + } + } } } diff --git a/osu.Game/Users/Badge.cs b/osu.Game/Users/Badge.cs index 9d46a1992b..b87e2ddecd 100644 --- a/osu.Game/Users/Badge.cs +++ b/osu.Game/Users/Badge.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using Newtonsoft.Json; @@ -16,5 +18,8 @@ namespace osu.Game.Users [JsonProperty("image_url")] public string ImageUrl; + + [JsonProperty("url")] + public string Url; } } diff --git a/osu.Game/Users/Country.cs b/osu.Game/Users/Country.cs deleted file mode 100644 index a9fcd69286..0000000000 --- a/osu.Game/Users/Country.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using Newtonsoft.Json; - -namespace osu.Game.Users -{ - public class Country : IEquatable - { - /// - /// The name of this country. - /// - [JsonProperty(@"name")] - public string FullName; - - /// - /// Two-letter flag acronym (ISO 3166 standard) - /// - [JsonProperty(@"code")] - public string FlagName; - - public bool Equals(Country other) => FlagName == other?.FlagName; - } -} diff --git a/osu.Game/Users/CountryCode.cs b/osu.Game/Users/CountryCode.cs new file mode 100644 index 0000000000..775de2bcf5 --- /dev/null +++ b/osu.Game/Users/CountryCode.cs @@ -0,0 +1,768 @@ +// 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 JetBrains.Annotations; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace osu.Game.Users +{ + [JsonConverter(typeof(StringEnumConverter))] + [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] + public enum CountryCode + { + [Description("Unknown")] + Unknown = 0, + + [Description("Bangladesh")] + BD, + + [Description("Belgium")] + BE, + + [Description("Burkina Faso")] + BF, + + [Description("Bulgaria")] + BG, + + [Description("Bosnia and Herzegovina")] + BA, + + [Description("Barbados")] + BB, + + [Description("Wallis and Futuna")] + WF, + + [Description("Saint Barthelemy")] + BL, + + [Description("Bermuda")] + BM, + + [Description("Brunei")] + BN, + + [Description("Bolivia")] + BO, + + [Description("Bahrain")] + BH, + + [Description("Burundi")] + BI, + + [Description("Benin")] + BJ, + + [Description("Bhutan")] + BT, + + [Description("Jamaica")] + JM, + + [Description("Bouvet Island")] + BV, + + [Description("Botswana")] + BW, + + [Description("Samoa")] + WS, + + [Description("Bonaire, Saint Eustatius and Saba")] + BQ, + + [Description("Brazil")] + BR, + + [Description("Bahamas")] + BS, + + [Description("Jersey")] + JE, + + [Description("Belarus")] + BY, + + [Description("Belize")] + BZ, + + [Description("Russia")] + RU, + + [Description("Rwanda")] + RW, + + [Description("Serbia")] + RS, + + [Description("East Timor")] + TL, + + [Description("Reunion")] + RE, + + [Description("Turkmenistan")] + TM, + + [Description("Tajikistan")] + TJ, + + [Description("Romania")] + RO, + + [Description("Tokelau")] + TK, + + [Description("Guinea-Bissau")] + GW, + + [Description("Guam")] + GU, + + [Description("Guatemala")] + GT, + + [Description("South Georgia and the South Sandwich Islands")] + GS, + + [Description("Greece")] + GR, + + [Description("Equatorial Guinea")] + GQ, + + [Description("Guadeloupe")] + GP, + + [Description("Japan")] + JP, + + [Description("Guyana")] + GY, + + [Description("Guernsey")] + GG, + + [Description("French Guiana")] + GF, + + [Description("Georgia")] + GE, + + [Description("Grenada")] + GD, + + [Description("United Kingdom")] + GB, + + [Description("Gabon")] + GA, + + [Description("El Salvador")] + SV, + + [Description("Guinea")] + GN, + + [Description("Gambia")] + GM, + + [Description("Greenland")] + GL, + + [Description("Gibraltar")] + GI, + + [Description("Ghana")] + GH, + + [Description("Oman")] + OM, + + [Description("Tunisia")] + TN, + + [Description("Jordan")] + JO, + + [Description("Croatia")] + HR, + + [Description("Haiti")] + HT, + + [Description("Hungary")] + HU, + + [Description("Hong Kong")] + HK, + + [Description("Honduras")] + HN, + + [Description("Heard Island and McDonald Islands")] + HM, + + [Description("Venezuela")] + VE, + + [Description("Puerto Rico")] + PR, + + [Description("Palestinian Territory")] + PS, + + [Description("Palau")] + PW, + + [Description("Portugal")] + PT, + + [Description("Svalbard and Jan Mayen")] + SJ, + + [Description("Paraguay")] + PY, + + [Description("Iraq")] + IQ, + + [Description("Panama")] + PA, + + [Description("French Polynesia")] + PF, + + [Description("Papua New Guinea")] + PG, + + [Description("Peru")] + PE, + + [Description("Pakistan")] + PK, + + [Description("Philippines")] + PH, + + [Description("Pitcairn")] + PN, + + [Description("Poland")] + PL, + + [Description("Saint Pierre and Miquelon")] + PM, + + [Description("Zambia")] + ZM, + + [Description("Western Sahara")] + EH, + + [Description("Estonia")] + EE, + + [Description("Egypt")] + EG, + + [Description("South Africa")] + ZA, + + [Description("Ecuador")] + EC, + + [Description("Italy")] + IT, + + [Description("Vietnam")] + VN, + + [Description("Solomon Islands")] + SB, + + [Description("Ethiopia")] + ET, + + [Description("Somalia")] + SO, + + [Description("Zimbabwe")] + ZW, + + [Description("Saudi Arabia")] + SA, + + [Description("Spain")] + ES, + + [Description("Eritrea")] + ER, + + [Description("Montenegro")] + ME, + + [Description("Moldova")] + MD, + + [Description("Madagascar")] + MG, + + [Description("Saint Martin")] + MF, + + [Description("Morocco")] + MA, + + [Description("Monaco")] + MC, + + [Description("Uzbekistan")] + UZ, + + [Description("Myanmar")] + MM, + + [Description("Mali")] + ML, + + [Description("Macao")] + MO, + + [Description("Mongolia")] + MN, + + [Description("Marshall Islands")] + MH, + + [Description("North Macedonia")] + MK, + + [Description("Mauritius")] + MU, + + [Description("Malta")] + MT, + + [Description("Malawi")] + MW, + + [Description("Maldives")] + MV, + + [Description("Martinique")] + MQ, + + [Description("Northern Mariana Islands")] + MP, + + [Description("Montserrat")] + MS, + + [Description("Mauritania")] + MR, + + [Description("Isle of Man")] + IM, + + [Description("Uganda")] + UG, + + [Description("Tanzania")] + TZ, + + [Description("Malaysia")] + MY, + + [Description("Mexico")] + MX, + + [Description("Israel")] + IL, + + [Description("France")] + FR, + + [Description("British Indian Ocean Territory")] + IO, + + [Description("Saint Helena")] + SH, + + [Description("Finland")] + FI, + + [Description("Fiji")] + FJ, + + [Description("Falkland Islands")] + FK, + + [Description("Micronesia")] + FM, + + [Description("Faroe Islands")] + FO, + + [Description("Nicaragua")] + NI, + + [Description("Netherlands")] + NL, + + [Description("Norway")] + NO, + + [Description("Namibia")] + NA, + + [Description("Vanuatu")] + VU, + + [Description("New Caledonia")] + NC, + + [Description("Niger")] + NE, + + [Description("Norfolk Island")] + NF, + + [Description("Nigeria")] + NG, + + [Description("New Zealand")] + NZ, + + [Description("Nepal")] + NP, + + [Description("Nauru")] + NR, + + [Description("Niue")] + NU, + + [Description("Cook Islands")] + CK, + + [Description("Kosovo")] + XK, + + [Description("Ivory Coast")] + CI, + + [Description("Switzerland")] + CH, + + [Description("Colombia")] + CO, + + [Description("China")] + CN, + + [Description("Cameroon")] + CM, + + [Description("Chile")] + CL, + + [Description("Cocos Islands")] + CC, + + [Description("Canada")] + CA, + + [Description("Republic of the Congo")] + CG, + + [Description("Central African Republic")] + CF, + + [Description("Democratic Republic of the Congo")] + CD, + + [Description("Czech Republic")] + CZ, + + [Description("Cyprus")] + CY, + + [Description("Christmas Island")] + CX, + + [Description("Costa Rica")] + CR, + + [Description("Curacao")] + CW, + + [Description("Cabo Verde")] + CV, + + [Description("Cuba")] + CU, + + [Description("Eswatini")] + SZ, + + [Description("Syria")] + SY, + + [Description("Sint Maarten")] + SX, + + [Description("Kyrgyzstan")] + KG, + + [Description("Kenya")] + KE, + + [Description("South Sudan")] + SS, + + [Description("Suriname")] + SR, + + [Description("Kiribati")] + KI, + + [Description("Cambodia")] + KH, + + [Description("Saint Kitts and Nevis")] + KN, + + [Description("Comoros")] + KM, + + [Description("Sao Tome and Principe")] + ST, + + [Description("Slovakia")] + SK, + + [Description("South Korea")] + KR, + + [Description("Slovenia")] + SI, + + [Description("North Korea")] + KP, + + [Description("Kuwait")] + KW, + + [Description("Senegal")] + SN, + + [Description("San Marino")] + SM, + + [Description("Sierra Leone")] + SL, + + [Description("Seychelles")] + SC, + + [Description("Kazakhstan")] + KZ, + + [Description("Cayman Islands")] + KY, + + [Description("Singapore")] + SG, + + [Description("Sweden")] + SE, + + [Description("Sudan")] + SD, + + [Description("Dominican Republic")] + DO, + + [Description("Dominica")] + DM, + + [Description("Djibouti")] + DJ, + + [Description("Denmark")] + DK, + + [Description("British Virgin Islands")] + VG, + + [Description("Germany")] + DE, + + [Description("Yemen")] + YE, + + [Description("Algeria")] + DZ, + + [Description("United States")] + US, + + [Description("Uruguay")] + UY, + + [Description("Mayotte")] + YT, + + [Description("United States Minor Outlying Islands")] + UM, + + [Description("Lebanon")] + LB, + + [Description("Saint Lucia")] + LC, + + [Description("Laos")] + LA, + + [Description("Tuvalu")] + TV, + + [Description("Taiwan")] + TW, + + [Description("Trinidad and Tobago")] + TT, + + [Description("Turkey")] + TR, + + [Description("Sri Lanka")] + LK, + + [Description("Liechtenstein")] + LI, + + [Description("Latvia")] + LV, + + [Description("Tonga")] + TO, + + [Description("Lithuania")] + LT, + + [Description("Luxembourg")] + LU, + + [Description("Liberia")] + LR, + + [Description("Lesotho")] + LS, + + [Description("Thailand")] + TH, + + [Description("French Southern Territories")] + TF, + + [Description("Togo")] + TG, + + [Description("Chad")] + TD, + + [Description("Turks and Caicos Islands")] + TC, + + [Description("Libya")] + LY, + + [Description("Vatican")] + VA, + + [Description("Saint Vincent and the Grenadines")] + VC, + + [Description("United Arab Emirates")] + AE, + + [Description("Andorra")] + AD, + + [Description("Antigua and Barbuda")] + AG, + + [Description("Afghanistan")] + AF, + + [Description("Anguilla")] + AI, + + [Description("U.S. Virgin Islands")] + VI, + + [Description("Iceland")] + IS, + + [Description("Iran")] + IR, + + [Description("Armenia")] + AM, + + [Description("Albania")] + AL, + + [Description("Angola")] + AO, + + [Description("Antarctica")] + AQ, + + [Description("American Samoa")] + AS, + + [Description("Argentina")] + AR, + + [Description("Australia")] + AU, + + [Description("Austria")] + AT, + + [Description("Aruba")] + AW, + + [Description("India")] + IN, + + [Description("Aland Islands")] + AX, + + [Description("Azerbaijan")] + AZ, + + [Description("Ireland")] + IE, + + [Description("Indonesia")] + ID, + + [Description("Ukraine")] + UA, + + [Description("Qatar")] + QA, + + [Description("Mozambique")] + MZ, + } +} diff --git a/osu.Game/Users/CountryStatistics.cs b/osu.Game/Users/CountryStatistics.cs index 000553c32b..03d455bc04 100644 --- a/osu.Game/Users/CountryStatistics.cs +++ b/osu.Game/Users/CountryStatistics.cs @@ -1,17 +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 Newtonsoft.Json; namespace osu.Game.Users { public class CountryStatistics { - [JsonProperty] - public Country Country; - [JsonProperty(@"code")] - public string FlagName; + public CountryCode Code; [JsonProperty(@"active_users")] public long ActiveUsers; diff --git a/osu.Game/Users/Drawables/ClickableAvatar.cs b/osu.Game/Users/Drawables/ClickableAvatar.cs index d85648c078..e74ffc9d54 100644 --- a/osu.Game/Users/Drawables/ClickableAvatar.cs +++ b/osu.Game/Users/Drawables/ClickableAvatar.cs @@ -1,9 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Graphics.Containers; @@ -11,56 +10,49 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Users.Drawables { - public class ClickableAvatar : Container + public partial class ClickableAvatar : OsuClickableContainer { private const string default_tooltip_text = "view profile"; - /// - /// Whether to open the user's profile when clicked. - /// - public bool OpenOnClick + public override LocalisableString TooltipText { - set => clickableArea.Enabled.Value = clickableArea.Action != null && value; + get + { + if (!Enabled.Value) + return string.Empty; + + return ShowUsernameTooltip ? (user?.Username ?? string.Empty) : default_tooltip_text; + } + set => throw new NotSupportedException(); } /// /// By default, the tooltip will show "view profile" as avatars are usually displayed next to a username. /// Setting this to true exposes the username via tooltip for special cases where this is not true. /// - public bool ShowUsernameTooltip - { - set => clickableArea.TooltipText = value ? (user?.Username ?? string.Empty) : default_tooltip_text; - } + public bool ShowUsernameTooltip { get; set; } - private readonly APIUser user; + private readonly APIUser? user; - [Resolved(CanBeNull = true)] - private OsuGame game { get; set; } - - private readonly ClickableArea clickableArea; + [Resolved] + private OsuGame? game { get; set; } /// /// A clickable avatar for the specified user, with UI sounds included. - /// If is true, clicking will open the user's profile. /// /// The user. A null value will get a placeholder avatar. - public ClickableAvatar(APIUser user = null) + public ClickableAvatar(APIUser? user = null) { this.user = user; - Add(clickableArea = new ClickableArea - { - RelativeSizeAxes = Axes.Both, - }); - if (user?.Id != APIUser.SYSTEM_USER_ID) - clickableArea.Action = openProfile; + Action = openProfile; } [BackgroundDependencyLoader] private void load() { - LoadComponentAsync(new DrawableAvatar(user), clickableArea.Add); + LoadComponentAsync(new DrawableAvatar(user), Add); } private void openProfile() @@ -69,23 +61,12 @@ namespace osu.Game.Users.Drawables game?.ShowUser(user); } - private class ClickableArea : OsuClickableContainer + protected override bool OnClick(ClickEvent e) { - private LocalisableString tooltip = default_tooltip_text; + if (!Enabled.Value) + return false; - public override LocalisableString TooltipText - { - get => Enabled.Value ? tooltip : default; - set => tooltip = value; - } - - protected override bool OnClick(ClickEvent e) - { - if (!Enabled.Value) - return false; - - return base.OnClick(e); - } + return base.OnClick(e); } } } diff --git a/osu.Game/Users/Drawables/DrawableAvatar.cs b/osu.Game/Users/Drawables/DrawableAvatar.cs index e6d7bb692d..bd09b95164 100644 --- a/osu.Game/Users/Drawables/DrawableAvatar.cs +++ b/osu.Game/Users/Drawables/DrawableAvatar.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; @@ -10,15 +12,15 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Users.Drawables { [LongRunningLoad] - public class DrawableAvatar : Sprite + public partial class DrawableAvatar : Sprite { - private readonly APIUser user; + private readonly IUser user; /// /// A simple, non-interactable avatar sprite for the specified user. /// /// The user. A null value will get a placeholder avatar. - public DrawableAvatar(APIUser user = null) + public DrawableAvatar(IUser user = null) { this.user = user; @@ -31,10 +33,10 @@ namespace osu.Game.Users.Drawables [BackgroundDependencyLoader] private void load(LargeTextureStore textures) { - if (user != null && user.Id > 1) + if (user != null && user.OnlineID > 1) // TODO: The fallback here should not need to exist. Users should be looked up and populated via UserLookupCache or otherwise // in remaining cases where this is required (chat tabs, local leaderboard), at which point this should be removed. - Texture = textures.Get(user.AvatarUrl ?? $@"https://a.ppy.sh/{user.Id}"); + Texture = textures.Get((user as APIUser)?.AvatarUrl ?? $@"https://a.ppy.sh/{user.OnlineID}"); Texture ??= textures.Get(@"Online/avatar-guest"); } diff --git a/osu.Game/Users/Drawables/DrawableFlag.cs b/osu.Game/Users/Drawables/DrawableFlag.cs index aea40a01ae..929a29251d 100644 --- a/osu.Game/Users/Drawables/DrawableFlag.cs +++ b/osu.Game/Users/Drawables/DrawableFlag.cs @@ -1,8 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; @@ -10,24 +13,24 @@ using osu.Framework.Localisation; namespace osu.Game.Users.Drawables { - public class DrawableFlag : Sprite, IHasTooltip + public partial class DrawableFlag : Sprite, IHasTooltip { - private readonly Country country; + private readonly CountryCode countryCode; - public LocalisableString TooltipText => country?.FullName; + public LocalisableString TooltipText => countryCode == CountryCode.Unknown ? string.Empty : countryCode.GetDescription(); - public DrawableFlag(Country country) + public DrawableFlag(CountryCode countryCode) { - this.country = country; + this.countryCode = countryCode; } [BackgroundDependencyLoader] private void load(TextureStore ts) { - if (ts == null) - throw new ArgumentNullException(nameof(ts)); + ArgumentNullException.ThrowIfNull(ts); - Texture = ts.Get($@"Flags/{country?.FlagName ?? @"__"}") ?? ts.Get(@"Flags/__"); + string textureName = countryCode == CountryCode.Unknown ? "__" : countryCode.ToString(); + Texture = ts.Get($@"Flags/{textureName}") ?? ts.Get(@"Flags/__"); } } } diff --git a/osu.Game/Users/Drawables/StatusIcon.cs b/osu.Game/Users/Drawables/StatusIcon.cs new file mode 100644 index 0000000000..18d06a48f8 --- /dev/null +++ b/osu.Game/Users/Drawables/StatusIcon.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osuTK; + +namespace osu.Game.Users.Drawables +{ + public partial class StatusIcon : CircularContainer + { + public StatusIcon() + { + Size = new Vector2(25); + BorderThickness = 4; + BorderColour = Colour4.White; // the colour is being applied through Colour - since it's multiplicative it applies to the border as well + Masking = true; + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.White.Opacity(0) + }; + } + } +} diff --git a/osu.Game/Users/Drawables/UpdateableAvatar.cs b/osu.Game/Users/Drawables/UpdateableAvatar.cs index 8ce6ce3460..c659685807 100644 --- a/osu.Game/Users/Drawables/UpdateableAvatar.cs +++ b/osu.Game/Users/Drawables/UpdateableAvatar.cs @@ -11,9 +11,9 @@ namespace osu.Game.Users.Drawables /// /// An avatar which can update to a new user when needed. /// - public class UpdateableAvatar : ModelBackedDrawable + public partial class UpdateableAvatar : ModelBackedDrawable { - public APIUser User + public APIUser? User { get => Model; set => Model = value; @@ -56,7 +56,7 @@ namespace osu.Game.Users.Drawables /// If set to true, hover/click sounds will play and clicking the avatar will open the user's profile. /// Whether to show the username rather than "view profile" on the tooltip. (note: this only applies if is also true) /// Whether to show a default guest representation on null user (as opposed to nothing). - public UpdateableAvatar(APIUser user = null, bool isInteractive = true, bool showUsernameTooltip = false, bool showGuestOnNull = true) + public UpdateableAvatar(APIUser? user = null, bool isInteractive = true, bool showUsernameTooltip = false, bool showGuestOnNull = true) { this.isInteractive = isInteractive; this.showUsernameTooltip = showUsernameTooltip; @@ -65,7 +65,7 @@ namespace osu.Game.Users.Drawables User = user; } - protected override Drawable CreateDrawable(APIUser user) + protected override Drawable? CreateDrawable(APIUser? user) { if (user == null && !showGuestOnNull) return null; @@ -74,7 +74,6 @@ namespace osu.Game.Users.Drawables { return new ClickableAvatar(user) { - OpenOnClick = true, ShowUsernameTooltip = showUsernameTooltip, RelativeSizeAxes = Axes.Both, }; diff --git a/osu.Game/Users/Drawables/UpdateableFlag.cs b/osu.Game/Users/Drawables/UpdateableFlag.cs index d0ef760e59..8f8d7052e5 100644 --- a/osu.Game/Users/Drawables/UpdateableFlag.cs +++ b/osu.Game/Users/Drawables/UpdateableFlag.cs @@ -11,33 +11,33 @@ using osu.Game.Overlays; namespace osu.Game.Users.Drawables { - public class UpdateableFlag : ModelBackedDrawable + public partial class UpdateableFlag : ModelBackedDrawable { - public Country Country + public CountryCode CountryCode { get => Model; set => Model = value; } /// - /// Whether to show a place holder on null country. + /// Whether to show a place holder on unknown country. /// - public bool ShowPlaceholderOnNull = true; + public bool ShowPlaceholderOnUnknown = true; /// /// Perform an action in addition to showing the country ranking. /// This should be used to perform auxiliary tasks and not as a primary action for clicking a flag (to maintain a consistent UX). /// - public Action Action; + public Action? Action; - public UpdateableFlag(Country country = null) + public UpdateableFlag(CountryCode countryCode = CountryCode.Unknown) { - Country = country; + CountryCode = countryCode; } - protected override Drawable CreateDrawable(Country country) + protected override Drawable? CreateDrawable(CountryCode countryCode) { - if (country == null && !ShowPlaceholderOnNull) + if (countryCode == CountryCode.Unknown && !ShowPlaceholderOnUnknown) return null; return new Container @@ -45,7 +45,7 @@ namespace osu.Game.Users.Drawables RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - new DrawableFlag(country) + new DrawableFlag(countryCode) { RelativeSizeAxes = Axes.Both }, @@ -54,13 +54,13 @@ namespace osu.Game.Users.Drawables }; } - [Resolved(canBeNull: true)] - private RankingsOverlay rankingsOverlay { get; set; } + [Resolved] + private RankingsOverlay? rankingsOverlay { get; set; } protected override bool OnClick(ClickEvent e) { Action?.Invoke(); - rankingsOverlay?.ShowCountry(Country); + rankingsOverlay?.ShowCountry(CountryCode); return true; } } diff --git a/osu.Game/Users/ExtendedUserPanel.cs b/osu.Game/Users/ExtendedUserPanel.cs index d0f693c37c..3c1b68f9ef 100644 --- a/osu.Game/Users/ExtendedUserPanel.cs +++ b/osu.Game/Users/ExtendedUserPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osuTK; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -8,14 +10,13 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osu.Framework.Graphics.Sprites; using osu.Game.Users.Drawables; using osu.Framework.Input.Events; using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Users { - public abstract class ExtendedUserPanel : UserPanel + public abstract partial class ExtendedUserPanel : UserPanel { public readonly Bindable Status = new Bindable(); @@ -23,7 +24,7 @@ namespace osu.Game.Users protected TextFlowContainer LastVisitMessage { get; private set; } - private SpriteIcon statusIcon; + private StatusIcon statusIcon; private OsuSpriteText statusMessage; protected ExtendedUserPanel(APIUser user) @@ -51,17 +52,13 @@ namespace osu.Game.Users protected UpdateableAvatar CreateAvatar() => new UpdateableAvatar(User, false); - protected UpdateableFlag CreateFlag() => new UpdateableFlag(User.Country) + protected UpdateableFlag CreateFlag() => new UpdateableFlag(User.CountryCode) { - Size = new Vector2(39, 26), + Size = new Vector2(36, 26), Action = Action, }; - protected SpriteIcon CreateStatusIcon() => statusIcon = new SpriteIcon - { - Icon = FontAwesome.Regular.Circle, - Size = new Vector2(25) - }; + protected Container CreateStatusIcon() => statusIcon = new StatusIcon(); protected FillFlowContainer CreateStatusMessage(bool rightAlignedChildren) { @@ -109,7 +106,7 @@ namespace osu.Game.Users // Set status message based on activity (if we have one) and status is not offline if (activity != null && !(status is UserStatusOffline)) { - statusMessage.Text = activity.Status; + statusMessage.Text = activity.GetStatus(); statusIcon.FadeColour(activity.GetAppropriateColour(Colours), 500, Easing.OutQuint); return; } diff --git a/osu.Game/Users/IUser.cs b/osu.Game/Users/IUser.cs index d9a352872f..b7f545f68b 100644 --- a/osu.Game/Users/IUser.cs +++ b/osu.Game/Users/IUser.cs @@ -4,14 +4,14 @@ using System; using osu.Game.Database; -#nullable enable - namespace osu.Game.Users { public interface IUser : IHasOnlineID, IEquatable { string Username { get; } + CountryCode CountryCode { get; } + bool IsBot { get; } bool IEquatable.Equals(IUser? other) diff --git a/osu.Game/Users/Medal.cs b/osu.Game/Users/Medal.cs index 2e4f6de1d7..75718e714b 100644 --- a/osu.Game/Users/Medal.cs +++ b/osu.Game/Users/Medal.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + namespace osu.Game.Users { public class Medal diff --git a/osu.Game/Users/TournamentBanner.cs b/osu.Game/Users/TournamentBanner.cs new file mode 100644 index 0000000000..e7fada1eff --- /dev/null +++ b/osu.Game/Users/TournamentBanner.cs @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; + +namespace osu.Game.Users +{ + public class TournamentBanner + { + [JsonProperty("id")] + public int Id; + + [JsonProperty("tournament_id")] + public int TournamentId; + + [JsonProperty("image")] + public string ImageLowRes = null!; + + [JsonProperty("image@2x")] + public string Image = null!; + } +} diff --git a/osu.Game/Users/UserActivity.cs b/osu.Game/Users/UserActivity.cs index 2f945d6e1c..0b11d12c46 100644 --- a/osu.Game/Users/UserActivity.cs +++ b/osu.Game/Users/UserActivity.cs @@ -1,28 +1,37 @@ // 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.Beatmaps; using osu.Game.Graphics; using osu.Game.Online.Rooms; using osu.Game.Rulesets; +using osu.Game.Scoring; using osuTK.Graphics; namespace osu.Game.Users { public abstract class UserActivity { - public abstract string Status { get; } + public abstract string GetStatus(bool hideIdentifiableInformation = false); + public virtual Color4 GetAppropriateColour(OsuColour colours) => colours.GreenDarker; - public class Modding : UserActivity + public class ModdingBeatmap : EditingBeatmap { - public override string Status => "Modding a map"; + public override string GetStatus(bool hideIdentifiableInformation = false) => "Modding a beatmap"; public override Color4 GetAppropriateColour(OsuColour colours) => colours.PurpleDark; + + public ModdingBeatmap(IBeatmapInfo info) + : base(info) + { + } } public class ChoosingBeatmap : UserActivity { - public override string Status => "Choosing a beatmap"; + public override string GetStatus(bool hideIdentifiableInformation = false) => "Choosing a beatmap"; } public abstract class InGame : UserActivity @@ -37,7 +46,7 @@ namespace osu.Game.Users Ruleset = ruleset; } - public override string Status => Ruleset.CreateInstance().PlayingVerb; + public override string GetStatus(bool hideIdentifiableInformation = false) => Ruleset.CreateInstance().PlayingVerb; } public class InMultiplayerGame : InGame @@ -47,7 +56,7 @@ namespace osu.Game.Users { } - public override string Status => $@"{base.Status} with others"; + public override string GetStatus(bool hideIdentifiableInformation = false) => $@"{base.GetStatus(hideIdentifiableInformation)} with others"; } public class SpectatingMultiplayerGame : InGame @@ -57,7 +66,7 @@ namespace osu.Game.Users { } - public override string Status => $"Watching others {base.Status.ToLowerInvariant()}"; + public override string GetStatus(bool hideIdentifiableInformation = false) => $"Watching others {base.GetStatus(hideIdentifiableInformation).ToLowerInvariant()}"; } public class InPlaylistGame : InGame @@ -76,31 +85,62 @@ namespace osu.Game.Users } } - public class Editing : UserActivity + public class TestingBeatmap : InGame + { + public override string GetStatus(bool hideIdentifiableInformation = false) => "Testing a beatmap"; + + public TestingBeatmap(IBeatmapInfo beatmapInfo, IRulesetInfo ruleset) + : base(beatmapInfo, ruleset) + { + } + } + + public class EditingBeatmap : UserActivity { public IBeatmapInfo BeatmapInfo { get; } - public Editing(IBeatmapInfo info) + public EditingBeatmap(IBeatmapInfo info) { BeatmapInfo = info; } - public override string Status => @"Editing a beatmap"; + public override string GetStatus(bool hideIdentifiableInformation = false) => @"Editing a beatmap"; } - public class Spectating : UserActivity + public class WatchingReplay : UserActivity { - public override string Status => @"Spectating a game"; + private readonly ScoreInfo score; + + protected string Username => score.User.Username; + + public BeatmapInfo BeatmapInfo => score.BeatmapInfo; + + public WatchingReplay(ScoreInfo score) + { + this.score = score; + } + + public override string GetStatus(bool hideIdentifiableInformation = false) => hideIdentifiableInformation ? @"Watching a replay" : $@"Watching {Username}'s replay"; + } + + public class SpectatingUser : WatchingReplay + { + public override string GetStatus(bool hideIdentifiableInformation = false) => hideIdentifiableInformation ? @"Spectating a user" : $@"Spectating {Username}"; + + public SpectatingUser(ScoreInfo score) + : base(score) + { + } } public class SearchingForLobby : UserActivity { - public override string Status => @"Looking for a lobby"; + public override string GetStatus(bool hideIdentifiableInformation = false) => @"Looking for a lobby"; } public class InLobby : UserActivity { - public override string Status => @"In a lobby"; + public override string GetStatus(bool hideIdentifiableInformation = false) => @"In a lobby"; public readonly Room Room; diff --git a/osu.Game/Users/UserBrickPanel.cs b/osu.Game/Users/UserBrickPanel.cs index 3debdb6e72..69b390b36e 100644 --- a/osu.Game/Users/UserBrickPanel.cs +++ b/osu.Game/Users/UserBrickPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -12,7 +14,7 @@ using osuTK; namespace osu.Game.Users { - public class UserBrickPanel : UserPanel + public partial class UserBrickPanel : UserPanel { public UserBrickPanel(APIUser user) : base(user) diff --git a/osu.Game/Users/UserCoverBackground.cs b/osu.Game/Users/UserCoverBackground.cs index 3f1f838b27..de6a306b2a 100644 --- a/osu.Game/Users/UserCoverBackground.cs +++ b/osu.Game/Users/UserCoverBackground.cs @@ -15,15 +15,15 @@ using osuTK.Graphics; namespace osu.Game.Users { - public class UserCoverBackground : ModelBackedDrawable + public partial class UserCoverBackground : ModelBackedDrawable { - public APIUser User + public APIUser? User { get => Model; set => Model = value; } - protected override Drawable CreateDrawable(APIUser user) => new Cover(user); + protected override Drawable CreateDrawable(APIUser? user) => new Cover(user); protected override double LoadDelay => 300; @@ -36,11 +36,11 @@ namespace osu.Game.Users => new DelayedLoadUnloadWrapper(createContentFunc, timeBeforeLoad, UnloadDelay); [LongRunningLoad] - private class Cover : CompositeDrawable + private partial class Cover : CompositeDrawable { - private readonly APIUser user; + private readonly APIUser? user; - public Cover(APIUser user) + public Cover(APIUser? user) { this.user = user; diff --git a/osu.Game/Users/UserGridPanel.cs b/osu.Game/Users/UserGridPanel.cs index c37eeb1cbf..90b6c11f0e 100644 --- a/osu.Game/Users/UserGridPanel.cs +++ b/osu.Game/Users/UserGridPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,7 +12,7 @@ using osuTK; namespace osu.Game.Users { - public class UserGridPanel : ExtendedUserPanel + public partial class UserGridPanel : ExtendedUserPanel { private const int margin = 10; diff --git a/osu.Game/Users/UserListPanel.cs b/osu.Game/Users/UserListPanel.cs index f06e412315..3047e70a1a 100644 --- a/osu.Game/Users/UserListPanel.cs +++ b/osu.Game/Users/UserListPanel.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Graphics; using osu.Framework.Allocation; using osu.Framework.Graphics.Colour; @@ -13,7 +15,7 @@ using osu.Game.Overlays.Profile.Header.Components; namespace osu.Game.Users { - public class UserListPanel : ExtendedUserPanel + public partial class UserListPanel : ExtendedUserPanel { public UserListPanel(APIUser user) : base(user) @@ -65,6 +67,7 @@ namespace osu.Game.Users { username.Anchor = Anchor.CentreLeft; username.Origin = Anchor.CentreLeft; + username.UseFullGlyphHeight = false; }) } }, @@ -93,13 +96,23 @@ namespace osu.Game.Users } }; + if (User.Groups != null) + { + details.Add(new GroupBadgeFlow + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + User = { Value = User } + }); + } + if (User.IsSupporter) { details.Add(new SupporterIcon { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Height = 20, + Height = 16, SupportLevel = User.SupportLevel }); } diff --git a/osu.Game/Users/UserPanel.cs b/osu.Game/Users/UserPanel.cs index 40d70ca406..e2dc511391 100644 --- a/osu.Game/Users/UserPanel.cs +++ b/osu.Game/Users/UserPanel.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; @@ -12,12 +13,15 @@ using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; using osu.Framework.Graphics.Cursor; using osu.Game.Graphics.Containers; -using JetBrains.Annotations; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Localisation; namespace osu.Game.Users { - public abstract class UserPanel : OsuClickableContainer, IHasContextMenu + public abstract partial class UserPanel : OsuClickableContainer, IHasContextMenu { public readonly APIUser User; @@ -25,29 +29,37 @@ namespace osu.Game.Users /// Perform an action in addition to showing the user's profile. /// This should be used to perform auxiliary tasks and not as a primary action for clicking a user panel (to maintain a consistent UX). /// - public new Action Action; + public new Action? Action; - protected Action ViewProfile { get; private set; } + protected Action ViewProfile { get; private set; } = null!; - protected Drawable Background { get; private set; } + protected Drawable Background { get; private set; } = null!; protected UserPanel(APIUser user) : base(HoverSampleSet.Button) { - if (user == null) - throw new ArgumentNullException(nameof(user)); + ArgumentNullException.ThrowIfNull(user); User = user; } - [Resolved(canBeNull: true)] - private UserProfileOverlay profileOverlay { get; set; } - - [Resolved(canBeNull: true)] - protected OverlayColourProvider ColourProvider { get; private set; } + [Resolved] + private UserProfileOverlay? profileOverlay { get; set; } [Resolved] - protected OsuColour Colours { get; private set; } + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private ChannelManager? channelManager { get; set; } + + [Resolved] + private ChatOverlay? chatOverlay { get; set; } + + [Resolved] + protected OverlayColourProvider? ColourProvider { get; private set; } + + [Resolved] + protected OsuColour Colours { get; private set; } = null!; [BackgroundDependencyLoader] private void load() @@ -78,7 +90,6 @@ namespace osu.Game.Users }; } - [NotNull] protected abstract Drawable CreateLayout(); protected OsuSpriteText CreateUsername() => new OsuSpriteText @@ -88,9 +99,26 @@ namespace osu.Game.Users Text = User.Username, }; - public MenuItem[] ContextMenuItems => new MenuItem[] + public MenuItem[] ContextMenuItems { - new OsuMenuItem("View Profile", MenuItemType.Highlighted, ViewProfile), - }; + get + { + List items = new List + { + new OsuMenuItem(ContextMenuStrings.ViewProfile, MenuItemType.Highlighted, ViewProfile) + }; + + if (!User.Equals(api.LocalUser.Value)) + { + items.Add(new OsuMenuItem(UsersStrings.CardSendMessage, MenuItemType.Standard, () => + { + channelManager?.OpenPrivateChannel(User); + chatOverlay?.Show(); + })); + } + + return items.ToArray(); + } + } } } diff --git a/osu.Game/Users/UserStatistics.cs b/osu.Game/Users/UserStatistics.cs index f8d26fe421..918a1b6968 100644 --- a/osu.Game/Users/UserStatistics.cs +++ b/osu.Game/Users/UserStatistics.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using Newtonsoft.Json; using osu.Framework.Localisation; diff --git a/osu.Game/Users/UserStatus.cs b/osu.Game/Users/UserStatus.cs index 7f275b3b2a..075463c1e0 100644 --- a/osu.Game/Users/UserStatus.cs +++ b/osu.Game/Users/UserStatus.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using osu.Framework.Localisation; using osuTK.Graphics; using osu.Game.Graphics; diff --git a/osu.Game/Utils/BatteryInfo.cs b/osu.Game/Utils/BatteryInfo.cs index dd9b695e1f..ef75857a26 100644 --- a/osu.Game/Utils/BatteryInfo.cs +++ b/osu.Game/Utils/BatteryInfo.cs @@ -9,10 +9,16 @@ namespace osu.Game.Utils public abstract class BatteryInfo { /// - /// The charge level of the battery, from 0 to 1. + /// The charge level of the battery, from 0 to 1, or null if a battery isn't present. /// - public abstract double ChargeLevel { get; } + public abstract double? ChargeLevel { get; } - public abstract bool IsCharging { get; } + /// + /// Whether the current power source is the battery. + /// + /// + /// This is false when the device is charging or doesn't have a battery. + /// + public abstract bool OnBattery { get; } } } diff --git a/osu.Game/Utils/HumanizerUtils.cs b/osu.Game/Utils/HumanizerUtils.cs index 5b7c3630d9..0da346ed73 100644 --- a/osu.Game/Utils/HumanizerUtils.cs +++ b/osu.Game/Utils/HumanizerUtils.cs @@ -4,6 +4,7 @@ using System; using System.Globalization; using Humanizer; +using Humanizer.Localisation; namespace osu.Game.Utils { @@ -26,5 +27,27 @@ namespace osu.Game.Utils return input.Humanize(culture: new CultureInfo("en-US")); } } + + /// + /// Turns the current or provided timespan into a human readable sentence + /// + /// The date to be humanized + /// The maximum number of time units to return. Defaulted is 1 which means the largest unit is returned + /// The maximum unit of time to output. The default value is . The time units and will give approximations for time spans bigger 30 days by calculating with 365.2425 days a year and 30.4369 days a month. + /// The minimum unit of time to output. + /// Uses words instead of numbers if true. E.g. one day. + /// distance of time in words + public static string Humanize(TimeSpan input, int precision = 1, TimeUnit maxUnit = TimeUnit.Week, TimeUnit minUnit = TimeUnit.Millisecond, bool toWords = false) + { + // this works around https://github.com/xamarin/xamarin-android/issues/2012 and https://github.com/Humanizr/Humanizer/issues/690#issuecomment-368536282 + try + { + return input.Humanize(precision: precision, maxUnit: maxUnit, minUnit: minUnit); + } + catch (ArgumentException) + { + return input.Humanize(culture: new CultureInfo("en-US"), precision: precision, maxUnit: maxUnit, minUnit: minUnit); + } + } } } diff --git a/osu.Game/Rulesets/Difficulty/Utils/LimitedCapacityQueue.cs b/osu.Game/Utils/LimitedCapacityQueue.cs similarity index 98% rename from osu.Game/Rulesets/Difficulty/Utils/LimitedCapacityQueue.cs rename to osu.Game/Utils/LimitedCapacityQueue.cs index bc0eb8af88..86a106a678 100644 --- a/osu.Game/Rulesets/Difficulty/Utils/LimitedCapacityQueue.cs +++ b/osu.Game/Utils/LimitedCapacityQueue.cs @@ -1,11 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using System.Collections; using System.Collections.Generic; -namespace osu.Game.Rulesets.Difficulty.Utils +namespace osu.Game.Utils { /// /// An indexed queue with limited capacity. diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs index ea092a8ca3..edf9cc80da 100644 --- a/osu.Game/Utils/ModUtils.cs +++ b/osu.Game/Utils/ModUtils.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 enable - using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; diff --git a/osu.Game/Utils/NamingUtils.cs b/osu.Game/Utils/NamingUtils.cs index 482e3d0954..97220f4201 100644 --- a/osu.Game/Utils/NamingUtils.cs +++ b/osu.Game/Utils/NamingUtils.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.IO; using System.Text.RegularExpressions; namespace osu.Game.Utils @@ -28,8 +29,53 @@ namespace osu.Game.Utils /// public static string GetNextBestName(IEnumerable existingNames, string desiredName) { - string pattern = $@"^(?i){Regex.Escape(desiredName)}(?-i)( \((?[1-9][0-9]*)\))?$"; + string pattern = $@"^{getBaselineNameDetectingPattern(desiredName)}$"; var regex = new Regex(pattern, RegexOptions.Compiled); + + int bestNumber = findBestNumber(existingNames, regex); + + return bestNumber == 0 + ? desiredName + : $"{desiredName} ({bestNumber.ToString()})"; + } + + /// + /// Given a set of and a desired target + /// finds a filename closest to that is not in + /// + public static string GetNextBestFilename(IEnumerable existingFilenames, string desiredFilename) + { + string name = Path.GetFileNameWithoutExtension(desiredFilename); + string extension = Path.GetExtension(desiredFilename); + + string pattern = $@"^{getBaselineNameDetectingPattern(name)}(?i){Regex.Escape(extension)}(?-i)$"; + var regex = new Regex(pattern, RegexOptions.Compiled); + + int bestNumber = findBestNumber(existingFilenames, regex); + + return bestNumber == 0 + ? desiredFilename + : $"{name} ({bestNumber.ToString()}){extension}"; + } + + /// + /// Generates a basic regex pattern that will match all possible conflicting filenames when picking the best available name, given the . + /// The generated pattern can be composed into more complicated regexes for particular uses, such as picking filenames, which need additional file extension handling. + /// + /// + /// The regex shall detect: + /// + /// all strings that are equal to , + /// all strings of the format desiredName (number), where number is a number written using Arabic numerals. + /// + /// All comparisons are made in a case-insensitive manner. + /// If a number is detected in the matches, it will be output to the copyNumber named group. + /// + private static string getBaselineNameDetectingPattern(string desiredName) + => $@"(?i){Regex.Escape(desiredName)}(?-i)( \((?[1-9][0-9]*)\))?"; + + private static int findBestNumber(IEnumerable existingNames, Regex regex) + { var takenNumbers = new HashSet(); foreach (string name in existingNames) @@ -53,9 +99,7 @@ namespace osu.Game.Utils while (takenNumbers.Contains(bestNumber)) bestNumber += 1; - return bestNumber == 0 - ? desiredName - : $"{desiredName} ({bestNumber})"; + return bestNumber; } } } diff --git a/osu.Game/Utils/Optional.cs b/osu.Game/Utils/Optional.cs index fdb7623be5..301767ba08 100644 --- a/osu.Game/Utils/Optional.cs +++ b/osu.Game/Utils/Optional.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 enable - namespace osu.Game.Utils { /// diff --git a/osu.Game/Utils/SentryLogger.cs b/osu.Game/Utils/SentryLogger.cs index c12fd607b4..8c39a2d15a 100644 --- a/osu.Game/Utils/SentryLogger.cs +++ b/osu.Game/Utils/SentryLogger.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 enable - using System; using System.Diagnostics; using System.IO; @@ -11,6 +9,7 @@ using System.Net; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Configuration; using osu.Framework.Logging; using osu.Framework.Statistics; using osu.Game.Beatmaps; @@ -114,8 +113,8 @@ namespace osu.Game.Utils scope.Contexts[@"config"] = new { - Game = game.Dependencies.Get().GetLoggableState() - // TODO: add framework config here. needs some consideration on how to expose. + Game = game.Dependencies.Get().GetCurrentConfigurationForLogging(), + Framework = game.Dependencies.Get().GetCurrentConfigurationForLogging(), }; game.Dependencies.Get().Run(realm => diff --git a/osu.Game/Utils/TaskChain.cs b/osu.Game/Utils/TaskChain.cs index df28faf9fb..7e7d26aa5d 100644 --- a/osu.Game/Utils/TaskChain.cs +++ b/osu.Game/Utils/TaskChain.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 enable - using System; using System.Threading; using System.Threading.Tasks; diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 63b8cf4cb5..085f78b27b 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -1,8 +1,9 @@ - netstandard2.1 + net6.0 Library true + 10 osu! @@ -18,30 +19,35 @@ - + - + - - - - - - - + + + + + + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + + + - + + + + + + diff --git a/osu.iOS.props b/osu.iOS.props index a0fafa635b..127994c670 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -1,94 +1,21 @@  - 8.0 - {FEACFBD2-3405-455C-9665-78FE426C6842};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - Resources - PackageReference - bin\$(Platform)\$(Configuration) - cjk,mideast,other,rare,west - false - NSUrlSessionHandler - iPhone Developer - true - - - - --nosymbolstrip=BASS_FX_BPM_BeatCallbackReset --nosymbolstrip=BASS_FX_BPM_BeatCallbackSet --nosymbolstrip=BASS_FX_BPM_BeatDecodeGet --nosymbolstrip=BASS_FX_BPM_BeatFree --nosymbolstrip=BASS_FX_BPM_BeatGetParameters --nosymbolstrip=BASS_FX_BPM_BeatSetParameters --nosymbolstrip=BASS_FX_BPM_CallbackReset --nosymbolstrip=BASS_FX_BPM_CallbackSet --nosymbolstrip=BASS_FX_BPM_DecodeGet --nosymbolstrip=BASS_FX_BPM_Free --nosymbolstrip=BASS_FX_BPM_Translate --nosymbolstrip=BASS_FX_GetVersion --nosymbolstrip=BASS_FX_ReverseCreate --nosymbolstrip=BASS_FX_ReverseGetSource --nosymbolstrip=BASS_FX_TempoCreate --nosymbolstrip=BASS_FX_TempoGetRateRatio --nosymbolstrip=BASS_FX_TempoGetSource --nosymbolstrip=BASS_Mixer_ChannelFlags --nosymbolstrip=BASS_Mixer_ChannelGetData --nosymbolstrip=BASS_Mixer_ChannelGetEnvelopePos --nosymbolstrip=BASS_Mixer_ChannelGetLevel --nosymbolstrip=BASS_Mixer_ChannelGetLevelEx --nosymbolstrip=BASS_Mixer_ChannelGetMatrix --nosymbolstrip=BASS_Mixer_ChannelGetMixer --nosymbolstrip=BASS_Mixer_ChannelGetPosition --nosymbolstrip=BASS_Mixer_ChannelGetPositionEx --nosymbolstrip=BASS_Mixer_ChannelIsActive --nosymbolstrip=BASS_Mixer_ChannelRemove --nosymbolstrip=BASS_Mixer_ChannelRemoveSync --nosymbolstrip=BASS_Mixer_ChannelSetEnvelope --nosymbolstrip=BASS_Mixer_ChannelSetEnvelopePos --nosymbolstrip=BASS_Mixer_ChannelSetMatrix --nosymbolstrip=BASS_Mixer_ChannelSetMatrixEx --nosymbolstrip=BASS_Mixer_ChannelSetPosition --nosymbolstrip=BASS_Mixer_ChannelSetSync --nosymbolstrip=BASS_Mixer_GetVersion --nosymbolstrip=BASS_Mixer_StreamAddChannel --nosymbolstrip=BASS_Mixer_StreamAddChannelEx --nosymbolstrip=BASS_Mixer_StreamCreate --nosymbolstrip=BASS_Mixer_StreamGetChannels --nosymbolstrip=BASS_Split_StreamCreate --nosymbolstrip=BASS_Split_StreamGetAvailable --nosymbolstrip=BASS_Split_StreamGetSource --nosymbolstrip=BASS_Split_StreamGetSplits --nosymbolstrip=BASS_Split_StreamReset --nosymbolstrip=BASS_Split_StreamResetEx - - --nolinkaway --nostrip $(GeneratedMtouchSymbolStripFlags) - - - true - full - false - DEBUG;ENABLE_TEST_CLOUD; - true - true - - - pdbonly - true - - - x86_64 - None + true + + true + + $(NoWarn);MT7091 - true - SdkOnly - ARM64 - Entitlements.plist + ios-arm64 - - true - 25823 - false - - - true - - - true - 28126 + + iossimulator-x64 - - - - - - - - - - - - - - $(NoWarn);NU1605 - - - - - none - - - none - - - - - - - - - - - - - - - + diff --git a/osu.iOS/AppDelegate.cs b/osu.iOS/AppDelegate.cs deleted file mode 100644 index 14e3627752..0000000000 --- a/osu.iOS/AppDelegate.cs +++ /dev/null @@ -1,27 +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.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 ffabdb4698..74bd58acb8 100644 --- a/osu.iOS/Application.cs +++ b/osu.iOS/Application.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.iOS; -using UIKit; namespace osu.iOS { @@ -10,7 +9,7 @@ namespace osu.iOS { public static void Main(string[] args) { - UIApplication.Main(args, typeof(GameUIApplication), typeof(AppDelegate)); + GameApplication.Main(new OsuGameIOS()); } } } diff --git a/osu.iOS/Info.plist b/osu.iOS/Info.plist index 16cb68fa7d..0ce1d952d0 100644 --- a/osu.iOS/Info.plist +++ b/osu.iOS/Info.plist @@ -8,14 +8,10 @@ osu! CFBundleDisplayName osu! - CFBundleShortVersionString - 0.1 - CFBundleVersion - 0.1.0 LSRequiresIPhoneOS MinimumOSVersion - 10.0 + 13.4 UIDeviceFamily 1 diff --git a/osu.iOS/Linker.xml b/osu.iOS/Linker.xml deleted file mode 100644 index 04591c55d0..0000000000 --- a/osu.iOS/Linker.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/osu.iOS/OsuGameIOS.cs b/osu.iOS/OsuGameIOS.cs index 9c1795e45e..c49e6907ff 100644 --- a/osu.iOS/OsuGameIOS.cs +++ b/osu.iOS/OsuGameIOS.cs @@ -1,17 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable disable + using System; using Foundation; +using Microsoft.Maui.Devices; using osu.Framework.Graphics; using osu.Game; using osu.Game.Updater; using osu.Game.Utils; -using Xamarin.Essentials; namespace osu.iOS { - public class OsuGameIOS : OsuGame + public partial class OsuGameIOS : OsuGame { public override Version AssemblyVersion => new Version(NSBundle.MainBundle.InfoDictionary["CFBundleVersion"].ToString()); @@ -26,9 +28,9 @@ namespace osu.iOS private class IOSBatteryInfo : BatteryInfo { - public override double ChargeLevel => Battery.ChargeLevel; + public override double? ChargeLevel => Battery.ChargeLevel; - public override bool IsCharging => Battery.PowerSource != BatteryPowerSource.Battery; + public override bool OnBattery => Battery.PowerSource == BatteryPowerSource.Battery; } } } diff --git a/osu.iOS/osu.iOS.csproj b/osu.iOS/osu.iOS.csproj index 1203c3659b..2d61b73125 100644 --- a/osu.iOS/osu.iOS.csproj +++ b/osu.iOS/osu.iOS.csproj @@ -1,123 +1,19 @@ - - - - Debug - iPhoneSimulator + + + net6.0-ios + 13.4 Exe - {3F082D0B-A964-43D7-BDF7-C256D76A50D0} - osu.iOS - osu.iOS - false + true + 0.1.0 + $(Version) + $(Version) - + + + + + - - - - - - - - - - - - - - - - - - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D} - osu.Game - - - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3} - osu.Game.Rulesets.Catch - - - {48F4582B-7687-4621-9CBE-5C24197CB536} - osu.Game.Rulesets.Mania - - - {C92A607B-1FDD-4954-9F92-03FF547D9080} - osu.Game.Rulesets.Osu - - - {F167E17A-7DE6-4AF5-B920-A5112296C695} - osu.Game.Rulesets.Taiko - - - - - - - - - false - - - false - - - false - - - false - - - false - - - false - - - false - - - false - - - false - - - false - - - false - - - false - - - false - - - false - - - false - - - false - - - false - - - false - - - false - - - false - - - - - - diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings index 286a1eb29f..367dfccb71 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -7,11 +7,15 @@ ExplicitlyExcluded SOLUTION WARNING + WARNING + WARNING WARNING WARNING HINT HINT WARNING + WARNING + WARNING WARNING True WARNING @@ -59,13 +63,16 @@ HINT HINT WARNING + DO_NOT_SHOW WARNING WARNING WARNING WARNING + DO_NOT_SHOW WARNING WARNING WARNING + DO_NOT_SHOW WARNING WARNING WARNING @@ -91,6 +98,7 @@ WARNING HINT DO_NOT_SHOW + HINT HINT HINT ERROR @@ -118,7 +126,7 @@ WARNING WARNING WARNING - HINT + HINT WARNING WARNING WARNING @@ -126,6 +134,8 @@ HINT HINT WARNING + HINT + HINT HINT HINT HINT @@ -133,8 +143,10 @@ HINT HINT WARNING + HINT + HINT WARNING - WARNING + HINT WARNING WARNING WARNING @@ -143,6 +155,7 @@ DO_NOT_SHOW DO_NOT_SHOW DO_NOT_SHOW + WARNING WARNING WARNING WARNING @@ -205,6 +218,7 @@ WARNING WARNING WARNING + WARNING WARNING HINT WARNING @@ -263,6 +277,7 @@ Explicit ExpressionBody BlockBody + ExplicitlyTyped True NEXT_LINE True @@ -320,6 +335,7 @@ GL GLSL HID + HSPA HSV HTML HUD @@ -336,6 +352,7 @@ PM RGB RNG + SDL SHA SRGB TK @@ -791,10 +808,10 @@ See the LICENCE file in the repository root for full licence text. <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> True - True - True + True True True + True True True True